diff --git a/.env.dev.enc b/.env.dev.enc index f7c270e..9162ddb 100644 --- a/.env.dev.enc +++ b/.env.dev.enc @@ -1,28 +1,34 @@ -#ENC[AES256_GCM,data:OCPlAFaovFK3,iv:0kjpX6hOeUnbVjIeLQZ62jqoXU44TYQmgnbz1Y236pw=,tag:8c7PIc9+GGAJAmhBVOnxDQ==,type:comment] -DATABASE_URL=ENC[AES256_GCM,data:DpRTqAHg1KAL9tcT0ixkMcP02TOQuhO2/3YiRpiP4BEnHCrNOThTustHN83720lTSt+ACPY=,iv:SwALWORlsFBOsIQcHvoA6kyj05xFI39Z9rQOL3DnMaQ=,tag:R8GG9PLv+goIYbBInHfEDQ==,type:str] -SESSION_SECRET=ENC[AES256_GCM,data:pp21tNYfb1EIaxYs4GLKQZ9/VWg=,iv:C50RMb6EMzovdswv4QD3QlgtJFBx/TVKiF8hfQ/FV/U=,tag:rGW7PbcswlOJdMDI3EEP0w==,type:str] -PORT=ENC[AES256_GCM,data:IyTLQQ==,iv:dUIhEZNTjqMOzocrDa5BHG52oOpeU/CjFa1YvCF2n3U=,tag:MshU1OXnJmoJknHdyUxyWQ==,type:str] -APP_REPLICAS=ENC[AES256_GCM,data:hQ==,iv:oqu4wkEDIREBYrAbQqLxex7ZUwyoFypOZ5W5rx8c8Zo=,tag:4a5qbvh+Cz3AgbZQFYXydQ==,type:str] -#ENC[AES256_GCM,data:dxLq,iv:GvipsKwMnRd0MptrGc0x8k9fH8/+53O/rg4KfUTASos=,tag:JlEUvdWUmpQCILV7I2PiQA==,type:comment] -S3_BUCKET=ENC[AES256_GCM,data:nLbCqd92YTLW5Qo7y0Wt,iv:OiLDALBNF9XGE38HW0hPpT0TcTgIWyuklIFU8/mlNtk=,tag:1qsTnVpzcm4lwqlr7YU76A==,type:str] -S3_ENDPOINT=ENC[AES256_GCM,data:hIWvW3iLfACNfhuKGv+Cn2ezyzQwQQhv9KcvC4pSVQ2AnG4nEgtxZXH1DUdEfzCrsoV4Ryo0H1SMkAcIgowUA4g=,iv:jESzVgN+ahXaaVa9mS+bEMoyUhSon1b+Oz/oP+sqGmA=,tag:gCWHjbcJeYFLgTUAonOthg==,type:str] -S3_ACCESS_KEY=ENC[AES256_GCM,data:SBra+VoXH37FCcRHjw1H+p5+AOxSuB5PhyraI+F+k9c=,iv:D+9rfI5EreUPzVsk3Z+v53oDzsN6jz28k35yjIBctco=,tag:HapR/0/BY7piyS52l3Mdbg==,type:str] -S3_SECRET_KEY=ENC[AES256_GCM,data:SF6Hc1DLGlrvZM3YlWeNyyztX46/fmzE/mfX7HG6iAuwdoD/+SC97eZM+1QTpXEYlnc2yQjs8z10XSP6M3MSnA==,iv:KcH+inx+8gah4ttD2jyNJEo9cQBWR6MudyU0tBQhU64=,tag:ytXswxzq83pbVpLWk6vaJw==,type:str] -ARK_DEFAULT_NAAN=ENC[AES256_GCM,data:hjFOe8Q=,iv:qmGNbmyW0w7egzlGPhSJRdcqSE0bZk/0ihNl0lv18Vk=,tag:86uYhWGlo2Hk5NMv1VmznA==,type:str] -CF_ACCOUNT_ID=ENC[AES256_GCM,data:KLsJwfgIFozf9F9HlNJAS9/O/ZHBqEKfU3PEapLvxMk=,iv:m4vM7+TQeJGUllQdmB6BX6djmrGjOrO3w2tUeGOir28=,tag:bJk22TskNz7CkfBg75atmg==,type:str] -CF_API_TOKEN=ENC[AES256_GCM,data:cMN6jax9UwxpM2R3+qjj0hoiMSt6WgHvLx9mhZVg+ZDk7n3/No7rNFQnFTqB0mrh5ScgIhI=,iv:JHP28NZsZmki3caQFOJIcKLRa2MfDG9VaKX46S1SpNk=,tag:3UdImYMN6uZjs/Nk4IcBxQ==,type:str] -DEPLOY_HOST=ENC[AES256_GCM,data:nOH2epPSSi1J9wIg8Q==,iv:t5smLDqPn30SodjcyU8l4mABlhNfT4AfSqjlBkqoFSQ=,tag:hSEFWBAtXEqETwg5jeo7HA==,type:str] -sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB4UDRnTGhwTkpZblBOMXpR\nOHFsOWxsWUdCUHgzd2RSTEJBdlRSVDErelFVCng1RDFxTEJxTktUVDVtWnl0UmV6\ndzN4S3VnOGZoakQ4MXgrRVVDcFgzdXMKLS0tIFJ1QkZuT0s3bzczNllNWHJVWWtD\nSlcxS2RKWUJxSURIT2d6eDhKNFBGdzQKCuQ5d89qgJ0CYzVtWndgzUpeKrqkBPB9\n53XEfzpFzIg0bNmpcAbS6ykQLUzQvNdXbxqfAZR1qevt5fv93ONbCg==\n-----END AGE ENCRYPTED FILE-----\n +#ENC[AES256_GCM,data:lmG0AqBdAGWZ,iv:MwavSYQHmVIdo7D/1H2P6IefCglKP/zO8H2Xid/Q3aA=,tag:erWfGwBLgtkurOL1QyEfqA==,type:comment] +DATABASE_URL=ENC[AES256_GCM,data:qaQbPyVtnXzaLBvXgbJmz0u8lbMucfDeWndAdohHyv0Obgaw41YP1MHHYhGu9CVwaD1gtnU=,iv:bMnTxRSRxyrk1+w4J4LlxxHTBB3kHegGDZMpFud9xJk=,tag:wf5APMrohuj9mjB3bxRLuA==,type:str] +SESSION_SECRET=ENC[AES256_GCM,data:Ndjpa8UbWLB4mERTyYRzdnHOYeA=,iv:hL3b1nEA7oRzCKwk9eysL7wJpdf2/IatYNQOUOOEH4g=,tag:27AtxAFW47IoEyeu4RTiLQ==,type:str] +PORT=ENC[AES256_GCM,data:cwe3nQ==,iv:P2g2H8uZayCkxK72zt6iEXz6gYuBjMzGSLAT9bjmDF8=,tag:bHCQjJR3+q1b9UsBeFp11g==,type:str] +APP_REPLICAS=ENC[AES256_GCM,data:kw==,iv:xziOg6i4k+7EW1/9qRLqa+81MHI3C8qpf4SBX2hTtkI=,tag:+ZaHEXP3SuQwz7RbmEJlEw==,type:str] +#ENC[AES256_GCM,data:0wTn,iv:47NbN2UqfY6m1bc236726HMblGjeeG/Zhq6OusazugY=,tag:8eVx9TY7QKVVtTfZ6vZr0A==,type:comment] +S3_BUCKET=ENC[AES256_GCM,data:vYPB9/hnBdidqjZSTSji,iv:h01LvE7PgzdlGbPDlO4hvEXOrHyUIwa0V5HLrt65shc=,tag:oeSdbohtQ3MM7hKSJGmnhg==,type:str] +S3_ENDPOINT=ENC[AES256_GCM,data:NVE8DTSt7kOhNAN1jDb2OnIyQO1CUUVYJVd9DOOEOGB+8+thHPft6qLTsKUTLAMM6Hhwe/fz3KvKoEZPL1Jp5GY=,iv:zGhNHAuVT+j5FlMbOLvn1MFDhkItiF8ULmAsTYDaOGo=,tag:4KY5RC60LvE+O7e+Z2asEw==,type:str] +S3_ACCESS_KEY=ENC[AES256_GCM,data:YCQiNgmHl5Y7iOufuXH/h0MMcV9FD5s17dLNKZOVfKw=,iv:6r/l6bFkvPu0pjfv1hVzDmAUXHYKCcikyNsOLKst1Rw=,tag:xf39HHqjnCSdmGCYvRlsVg==,type:str] +S3_SECRET_KEY=ENC[AES256_GCM,data:m/oU91hR/3oMOSCq7JfXvMj1F6B4bXORol8s8NCjzeT0INjUxUopiOrjWyPczQ6Ob+UNUQqYMG9UakmppK8aJg==,iv:+g3Zp1OsklYlR0LTV/M7PONRxtfy1PNvv9NFw3c9mxg=,tag:OiZcJW9JHboPleMAaW4ZHQ==,type:str] +ARK_DEFAULT_NAAN=ENC[AES256_GCM,data:0xHZcdQ=,iv:YTsBYn4WFyFOp5jI5KvEt++jMttMbAKG3wf4QClGq3c=,tag:qKNachtilRJuhNOMRf8D7Q==,type:str] +CF_ACCOUNT_ID=ENC[AES256_GCM,data:NJtRMOUyRcuch+gO/uDL/mm2lZfzmv2BSsrUa+/2AAo=,iv:gXK7qb5N8X/nLHTruVJA0S3SIOwk0yBS3FTmoN5BrCE=,tag:wboPv1RKrwIokNKrvmvD4w==,type:str] +CF_API_TOKEN=ENC[AES256_GCM,data:ftZnNMauVci3t2kigVJ8YzZ/Iht4tanQCEMYJM+MX/iqHli39T9TeyNomjemt4ckCNy7CEk=,iv:AoXwvL2jsF13CjKjJEIb0Gsxbfdy9pPQFc7nXkGA/+U=,tag:D2nTBOaW+9nl3Pikw96xXw==,type:str] +DEPLOY_HOST=ENC[AES256_GCM,data:hmfvyFfEthMPpl7glA==,iv:dzQE2927TQW59oxEszmOc+nprDmj9MVhCywBxzuE7yU=,tag:KKaUYhYwVfgQhEoyBy8I0w==,type:str] +#ENC[AES256_GCM,data:5EmRUT5TP04DiujwA4ybhpxmpwg=,iv:KL1SBHuUitjog44NMZM0NDljMebwx8sYPUMFIWCPp80=,tag:0uDxOgyB1yDf2y981u7Upw==,type:comment] +KF_AUTH_URL=ENC[AES256_GCM,data:E34WyjcN/ynL8nqSBE4cGrWkFgHpVpq+6quoX5IS4mKxCYPNuQ==,iv:TRqhyuD69okX6T3Zfh+DRG87X6a06S7u2+Pf4nUEwWM=,tag:SpOJTCdcjg705lDzft16EA==,type:str] +KF_AUTH_CLIENT_ID=ENC[AES256_GCM,data:iztGhF7KOBQGGiM=,iv:azzug4Y7T90ICdRwITtSFpPoX3g3fpMr5V7s1c3wrno=,tag:QIGjCq5g49IYoJhweqjCYQ==,type:str] +KF_AUTH_CLIENT_SECRET=ENC[AES256_GCM,data:nP7Nd/LsicIDeEAN6xbWGQ3jYECinkca+PMve8qUHISTKECNEGvs/BBhkSPTTbT/gb/wqZuqLMa7I9RVc7rWWQ==,iv:Ngb+Bg1bPVuHxtHY0hpF1BgKU14ECE7SG+Vl+wCB0ms=,tag:LIc8nj+gxQdlyggLMHT4/g==,type:str] +KF_INTERNAL_API_KEY=ENC[AES256_GCM,data:D4P6kATJx5yiKzvN6syctZJxDTpyw5A7k+61LkJG3FS7frqnIYop7EKc5tBoF/CdI/4tkwXYBchDxXsK89k59g==,iv:p82O44FMgnxV2okordv8Jm+JcVLB6sCERbzXRY9yJaQ=,tag:f28C3tInSKpxhrxlitWq1g==,type:str] +APP_URL=ENC[AES256_GCM,data:IPJstMk95GuHJunALmEIDZDa9QRgcsid,iv:llGmGCn+mXDPj1zB8H05z8pLGTuyecjoYeL0uyQyBfk=,tag:sM7e9S73CGC0KOQHOAFjHw==,type:str] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBPa0tzZ1k4eGR4NzhXWmU1\nQll0SXZUWUFsRGVNNnFUc08rZ3ozSXRDNlhBCkVKeEZYQlRzWnlUTVZZSWUyTFZn\naHV6eGhlVjR4TElUTzVOVVByYmxPZ3cKLS0tIDh1QVUxY2ZGY05pZklwUEFOVHU1\neUxzTmhnTEl5ZHM5WDhySnBTRThwYUUKtb4XQILCcsJWRDmt2ckSeey1jonQbmx7\nxq3wc07dNjXLTQOKzbqRPCRRl3ivxRSEQhP3GRPXISh9V+Pl9aUj3g==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_0__map_recipient=age1wravpjmed26772xfjhawmnsnc4933htapg6y5xseqml0jdv8z9hqemzhcr -sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA3WjY1R1RhZGdWcGdNeXFV\nUUpnc2NmSVF0eFZvQ1Nxd281TkNJVGZPZ244CmpMWGJycW9MaExRVXdTVUlFM1dn\nZ2ViWGdYRlhRWSt3Y0VQdytTK3lBN28KLS0tIGdlaW52OExXZTVvT08yUGpwaHgz\naHBBZkF5dFozSWx1Q0xoVm8zN0pORlUKZMyb3Zg742BMnNxbstk50wfrekuwUD6C\njlGEjMKSEjsnUbFS+Rzf/Ladh5Tjp3KHTy9LCgpiEQv3apKsFdhGcA==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBhWkUvRnQ1VFV0eGc1NTBD\nZlh3NGE0eU92S0tkQjdUenBCNDFBYW9Rc0hBCi9rRkxLaDhkNzZycktaT1NKaEFk\nUzFkblU1RkUvQVNxcmQyQXVzWjJFY2sKLS0tIDhncEowTDV3QzFaQ1RyM2RMdGNG\nb1FKKzhwRXpkOW9kREc2OEh4NmFQS28KQq7UW46I8aivy7v54ssfMlzss3uHxv2V\nsuPkfJhuWynpGZC6SOh++M7jqskLilR+mi67DjFNGhth+ehu09A/bQ==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_1__map_recipient=age1ysddqggsx3h8zkv7xn3z26sjak5pqms6pyqhnky9ukrvpk7es5jsayz8w7 -sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSByZ1FQVXVZSG92UFVDeTdN\nSUNpQ2hmeG9DdE5xaVRDZ1p5cHVpRWRZRmlBClVoOEZHbXVxQVdsWmczRlpiTE92\nUHBLTGpSd0o1bjdnUlZ5QXlmQlA5cXMKLS0tIGFNVnVrSUlTL0s2SkV0RmVtZGw5\nbXptNWZxaE53MjRMclZPRnFja3dxa2MKMFurZwB3tIMd6E4EC0h8rXDUJBe0Lq5K\n3ZHsunwMtp6aOT692QEzOBWDwlKYz3x1CW+oeKUU4fO8gU7gjFQPaQ==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBzQ3paZ0ZNaFg2NmE5c3B1\nY0lMdTc5ZTZ5SVBPZHo3M21pbFNhQ2dFdkJNCjU5WUxtMlM4VGRZVDcrY1htQ3VB\nVDZ6aGRPWWpnMWZITjRnVEFXdmIzSU0KLS0tIHA4VlljSEp2UzV4TGs4bVg0bjFD\naS9TUTQ1NE9Ga0pMR3Q4ZUNiVWIrQWcKd+zLbd1tHj1dVq5iY1B9cVmCwXGwdaQU\np5Wyyz2oygptyUheShfTim68mTDYm2k0+Ip6l9pIrtCTP9gGtM9Meg==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_2__map_recipient=age1pgxk292zq30wafwg03gge7hu5dlu3h7yfldp2y8kqekfaljjky7s752uwy -sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBCZHVlVUVnQkVnK3hHOExX\nV2FQYnlWQ0w5TDRqZjB0TzZZSHdVcXYxZ0c4CmQyR2I3eXE2S3pBZUJybnM5RnhZ\nOERWNDF6N2M4WlpzM25hN1JxV0RpMkEKLS0tIGdSVUJCVVBWL0FjRlZjNFBiVFBn\neXdmY0Y2S21RcmUwdjl5NlQ0T2Z1M1UKwmBxmdSa2FL2ic4dAN7NnW7eJ+2iORI0\nialcwZyAAXgaXUlkSvRyH+v6/xTQifRnO49v5mAq3c6fWOHUNAXrzQ==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBscnFReUxTYklKZWZxdWc4\nK1M0WHA0bjhwMGhzN1dGYTJhaFdyVW5ob0JrCkxGTmd0UVdmNmdNa0ZBR0VKRWMv\nZEdXYzF2RXdSZFVwREthR244SGVVcEkKLS0tIGZrSW5DQnBGUG9Zb3ZPUS85SXVY\nODdPeDFXWEtDQ1RTTHZUUlZKZmxoOUEK02DjreAQVgqpgrE29yyJ72D+OmlBXC8C\nwAZEZxZBbEL0wo145N2Tnm22tHkQ7B6P2C46fymxUS4l49qF6DvLHw==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_3__map_recipient=age1qn0x93jhqjpqwvx5tgxnrwq5e3vuzur9whrkdnrvapd58esm45rqfkuxqh -sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBENWR5cXRvL3VSWkFockV5\nbDV3dk5EdEtucGg2aGpiYXo4N2hVMUdaMWdjCkp4VEczMkdIRll4ZldUcnFZRzVQ\nK25uN1ZLNVNFMVZ1OHR5UFpUaTdCVG8KLS0tIEdQcEcyaWtETUlISC9wbmtMUGJv\nMmhKL1hkbFZKZlRkY043MTUzcHorREkKlxMPqJVDU4CXxnVetBQ/oD0AMOBlH6u3\nSUXS/D4yxi/PLwrJ3PtHGiNtPD/IjFH4Ql1B4lhH4edgPmJfLapTrQ==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBVWTRRNStCUy85T0J3TFlZ\nTEZIUEZVSElreTFLVUVsMVY1aU5MRlFhSVZ3CjFBUnY1TWtNbHdZM0VvR3Znalg4\naWVwdnhYOTkxR3p4Vis1K2xyeGI1UDgKLS0tIDh4bytib2JLU1pvUTU4NUJFc1U4\nR2JjS3FYNUIrS01jTmxEbE1kNlkzajQKEfSkMPUMCDMoZD/exYQDJ0OrWdfHW5K+\nXgII97m2GwW+oOrajdXhZCxgWsOZ0QnS1WP8JE4NN9vp6sxthoC72g==\n-----END AGE ENCRYPTED FILE-----\n sops_age__list_4__map_recipient=age1h86dek80u5t677tsparz395uk3zvz4yuj9m5t2v2nsdfsvyjmafsra5yt7 -sops_lastmodified=2026-05-11T15:15:46Z -sops_mac=ENC[AES256_GCM,data:w1zr/38iHQffzfx+u+n2qWSkCrrDwi03nPBwuN6Q+pBq/FZ948fzHcwAAhg47kApJK1i1SBGy0VA7RWBiBO4visLmWEMbhE46ibEp5n79qt82HA8bZwPIx/aIVG3al7mjw39VmOkZ1WROZNGVZUZF3xxyp78Mfezhn4xsGBqTBE=,iv:SAR37GQATV+hANXmmLLOd7VyWw0F6O8O+e8wSZIly7s=,tag:PzGelN+KJAdW54ineA8oIg==,type:str] +sops_lastmodified=2026-05-17T03:28:30Z +sops_mac=ENC[AES256_GCM,data:qhUZeGuCDydHnrSDM5AQUTct8ss+O3pKFWphuIh88YCfRMSFuZjjVDbql5SqD5etXyGSxG4+pxVUFCvyfF0DKw8a73XdnYsPM05zrnFuX1p3UOikVtajKWYCHBu/Yjx6VOOS29HP9YCCJJX3bKfpTcNpkh30YWm8IH4/zUz8yb0=,iv:m48OnT821Uetn/GSoGeteqMn2awWLU1QgdDca8SV+bE=,tag:F9NIItju1QfEEXvZElgZgA==,type:str] sops_unencrypted_suffix=_unencrypted sops_version=3.11.0 diff --git a/.env.local.enc b/.env.local.enc new file mode 100644 index 0000000..e2be693 --- /dev/null +++ b/.env.local.enc @@ -0,0 +1,35 @@ +#ENC[AES256_GCM,data:EwG7RRV6tRRp,iv:szSa5gysyS7lr5/4rnulyYhX33/6v6kWNoREGFrj2JM=,tag:k9obKCeUYSEzuUnnAapqEg==,type:comment] +NODE_ENV=ENC[AES256_GCM,data:xfSbj3OvQSlkuhI=,iv:U7OzOOp3BlZGITKK7ixzN2StVi5yGwobli26ZqRWRyY=,tag:kx+KDazKRB/8T2XdHjN3kw==,type:str] +PORT=ENC[AES256_GCM,data:igm7kQ==,iv:I1WRKuavFad/4voErHMyT7HfJT2OeCjktH1zv7CvOZ8=,tag:3DAa/hCbrp2ItT976w8eRg==,type:str] +DATABASE_URL=ENC[AES256_GCM,data:Zyccn7eQMZu6N5o4/VqB0Gafsip1V8ZPWjR4PytIIevmvMef0Br52FkhsVPXj9qD2g/Kuo0=,iv:eWo/ggSxXL3nRYrNv9bPPnu922H9F4JN6MUYt+Xp+PA=,tag:fpy8wx+0dPnnlaBfzSGQFw==,type:str] +SESSION_SECRET=ENC[AES256_GCM,data:+COS9fGKuGEMyHQBXWJMwlBVfnY=,iv:pv8tJfJhZrAgLPO8Txvf5gEUZ2m9eioxVhj6KziIopg=,tag:D8yOO+xCKUuax4B5EuqGVQ==,type:str] +#ENC[AES256_GCM,data:iqmMy/fCrzgR0yK3OpWkHWaBwg==,iv:uqznSmxMAyseEyqy7yQgrY0cT2AIG7tXbyIB7DX2/XE=,tag:DxyQyxqvHe0jTBshYfWfPw==,type:comment] +S3_BUCKET=ENC[AES256_GCM,data:qQCv9WqEOCpxZfRJvejV,iv:PX8OTqKO34d/gomzjF4pHAU3Xi3NWdXQfQmBTXOEs3E=,tag:ylKc+e6T8jWDm43O1XNBEA==,type:str] +S3_ENDPOINT=ENC[AES256_GCM,data:doEPI2dGiv08oLdBfrtNSxFA2G3JAfHPeSSJclj4APVFDrqTrFGnVdxVZEsELUBsvtPBf87942fX7L4+opTqYtU=,iv:Aa2ZPZUbsuMSrAWUF/fIbZEIJqvlvjbZiYAhCUdUhIo=,tag:v4Q77lniZGDMj+ZTdMBmgg==,type:str] +S3_ACCESS_KEY=ENC[AES256_GCM,data:vwB86XE3vg4efKrDMnxFgNjMh9+9m+IqfZzcgmT6eJE=,iv:JNx8Uyw4qGA9y77wCyBuOwXj4kY+Or3mTiYflQORQHc=,tag:doZWw9vFc9WmAeYCwnXI7w==,type:str] +S3_SECRET_KEY=ENC[AES256_GCM,data:Iu0wo4qgPUdk/zpk4S0yHXJCqVH9C9S0Vw1TPbVo8z9shhl0/GmSdgKeDUIrpXuM2PGyqgPIsBf46A6ORnwGVA==,iv:ZZWajwWE0PLjEN4hPpx+Im1cisfZlUkIg4PBm3HQ5b0=,tag:8eK9TCD+q2V6j7sRFkSWOA==,type:str] +#ENC[AES256_GCM,data:wAJC60sWyg==,iv:RjxyGe9nMuoemVnBj8jH7/zXwtm7dT6LTVdsA6DvQ5w=,tag:6RaUkl8fb2nlBq1AbhGOJQ==,type:comment] +BACKUP_S3_PREFIX=ENC[AES256_GCM,data:HXINLjypKwQN,iv:0MX7Qebmyha8ZRuN/vt/3mIvApsXbtpmsDhYl/i0je8=,tag:VTXo8vAJV3ESu810/bjeDQ==,type:str] +CF_ACCOUNT_ID=ENC[AES256_GCM,data:NTsh9OYOuOpdobd0yl5u6kG8Dpr0FW6nPh3VZkRGocM=,iv:HYcfttiil7d6bnC+Lzf5aw6IyV8XoEDH/YiL9rypJT4=,tag:rwWbpxDsdN+NzVSnLsTp3g==,type:str] +CF_API_TOKEN=ENC[AES256_GCM,data:utXG/zszpYGYy9BBTl+toYzMncKdI6USIFCYQntaUQqbvdQ9NTqZIyXRmSQHG4l9r4QMPP0=,iv:vjsT4dZlICdI+wwzMOtIC/i5pOQIwgV1Mhnr1KHmMSc=,tag:RV9uy6EpzmAVxkoQktT3oQ==,type:str] +KF_AUTH_URL=ENC[AES256_GCM,data:nmJJWcAYd5rYkWB/NFJNeblQ/qoM,iv:q7HaKrlDKIfm8T4NsjBXlkr4qNbwGLaMNWk+C4fm8TY=,tag:U2yzNSb35kkZViX11P8BzA==,type:str] +KF_AUTH_INTERNAL_URL=ENC[AES256_GCM,data:Kv0ZgmnWS9l7CwkXgxV9fjVIsJnue4MfHqFz4acxL2o=,iv:HXKcVpIq8/GznRznSz7CzVoTet3tjgaw3SyqMPDDUf4=,tag:dCWZnd6g6y/3livKpUfjmQ==,type:str] +KF_ACCOUNT_URL=ENC[AES256_GCM,data:Un3dm3GYts2mKBC8V1dBdTG5R7h6,iv:QuGXX1JSV+egkGpsWdDUhVBvScUSn6ra/PW4h9Tqeq4=,tag:4ax4V1aKfA9HZW1sPSr2uw==,type:str] +KF_AUTH_CLIENT_ID=ENC[AES256_GCM,data:t8yRbfOcoNq79zE=,iv:O7qE3QStFljwyEiJoGnWAukglolacPTuHN5nrftZ9KA=,tag:Js6yNFM7XPvfz8tW1avZZw==,type:str] +KF_AUTH_CLIENT_SECRET=ENC[AES256_GCM,data:rKG4MU2+iYvutBwjZEXvekzrphn/G5rS7w7AYyjmfvolpeN2n2A/0/qsRzObfDmUK9oHSny9hMh87quULJEMEA==,iv:G98aGrbpCOcKevLemnKoUDfCznWEyk/OIJObVdljMS4=,tag:uknMJTV24+ann9+n2zzklg==,type:str] +APP_URL=ENC[AES256_GCM,data:P2FPGGTVQorCezo3vpd1RQf17mLy,iv:p47nF3xiNcciBQE7ixVYPkFLDv1HvoqPMWmVg/OhAz8=,tag:6Np2Pp1VDHauxP2g52hYXg==,type:str] +KF_INTERNAL_API_KEY=ENC[AES256_GCM,data:5W76Sb3vbv3G/GY49V8vhpZeOn9nQ4Ezgg2EfTajGVA=,iv:suuMiX8lKXKhHp+JAmT7VkESeFDgJZK7+ATw3jTsRvU=,tag:Fz1xxA3CAmgNb40fVEkllQ==,type:str] +sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBpc1J2cHdHL1ZuWHFwZGZs\nUUR0OHhHUGlKWk5PRlE3azhGZ0EwSHcwbWdJCktpVHBLSXVxUW01dmd2ZWFZQ092\nQ0VjeGZ0b1hWaG51VmhsMGNMSkZCMnMKLS0tIFUxd3FLS1NUWWFLWkdNa0RhS2Zt\nUWZGYi93N3ExWTg5elVxWnhQSXdxdWsKpvGWBahHOOTicknPKDOqgkzF0VSuYtwA\nw3SMwZzwQ00gnRLw7LrY/EDAM+KYk/C1egMEAtAPPDfyX5xAGSDIIw==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_0__map_recipient=age1wravpjmed26772xfjhawmnsnc4933htapg6y5xseqml0jdv8z9hqemzhcr +sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBPaTdrOFo0RGpOQkp3NCt4\nUEUzS3FZTjZWc3czbUhISHZyVFVrVm9CaEhZCi85WkR2NmxObFVneDdqZFpja1NO\nZldyYXRZSjRnUmhMRnlOamVRa2VCdGsKLS0tIHpTTGJDWUFJQThiR3pERUhXY2tM\nV3huTDVYRnlabkNuQmJqWnFwT2ZWMlkKlTbID7me8QBHvBjljXvj+kl5Hmo0Oprn\nSLT6MSYP0rvuD3uu4Qj+Z6IELbAzrt6ffGjMx+xohWse5R+u8UFMzA==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_1__map_recipient=age1ysddqggsx3h8zkv7xn3z26sjak5pqms6pyqhnky9ukrvpk7es5jsayz8w7 +sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBZMkhPQmF5RWJvWS9UL2hD\nWmlEK2QrckhIN255Y2FpcGdkenF0eEJZSXpJCkdESlB5SEZMVW1uNkdWeVQrUHVY\nQjNtZy8wZjVwcjZWTzFLam1LaWw3b1EKLS0tIDRyM1RiTERUN0hFMWVCQnAraktu\nWW1pQ2lwZmZBN0JlYThKRTdLRE5qTDgK3LZGWZwVlmmiQ4CghkA1VEJLWaOsRCE7\nD57rKhXq5/QKjoziyXsc//PxZnJaTiJQ2xxsG8uMLDL5sCMz9NDiTg==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_2__map_recipient=age1pgxk292zq30wafwg03gge7hu5dlu3h7yfldp2y8kqekfaljjky7s752uwy +sops_age__list_3__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBYV0ZhbVVxZVcxa2ZrZVRU\nVnorTWZvc1JMc2ZhM01mbFBRUTNLeTAyUTA4CjVubkNNZ0xoUjdhVXloNDZ4clpO\neUlGVlB4QTB4TEhGdVNPaXV1UW1YUTAKLS0tIFJrOUJSSlc4RmpBWW5QK1pRdXhK\nS05OWGh6a0RrekxteTRGSnhZd0VMRUEKcYsvFySf+hAGk3zsHN1MOK2/AayQQsQI\ntWerQqnKHoKHt5Fd2WZRUMrtzyV5stTZ0WwUhMIvFnapz+sQNDSYSA==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_3__map_recipient=age1qn0x93jhqjpqwvx5tgxnrwq5e3vuzur9whrkdnrvapd58esm45rqfkuxqh +sops_age__list_4__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBnY0RGNkNoczNrTHdYbmtN\nZTNtY1RQNHVHMmNhOEJCNzZ6ZVVxUUJLeUd3Ckovdm1vdWttR2NMeWpZZnhnN0xD\ncS8yZEJjUkhQWG1ReWdTNDJOU3cvMUUKLS0tIEpRSzBOZmxRVERIQ1pWZDFiV2l5\neGxGQ1ZxVmZTUjB5OUNadENnTWNUWVUK45kOkImJ/sdznjiCBSV1BLa6Z3ZFZh/H\n4WhN0fUKLPoBIY2MtcETa0XFuIdx54p+CJuMlok6KUuMh3h8ORxsVQ==\n-----END AGE ENCRYPTED FILE-----\n +sops_age__list_4__map_recipient=age1h86dek80u5t677tsparz395uk3zvz4yuj9m5t2v2nsdfsvyjmafsra5yt7 +sops_lastmodified=2026-05-18T03:03:32Z +sops_mac=ENC[AES256_GCM,data:QYYsO6U+2eK6GX90Y8+uy1LtPlJdiWTkj9QPZ1jgEUlZJOc79xPBai7i7TfwSbfG9Pv0aPVkcWjoIIBQkGC3Jdt1wIGMLCmlIeHU+MNH1ZcdOlmn9PI72GbMhWGF534kJFfgSxWJDrH2ow9NqOebcDESsZ/S35ajCUeyqAD5Yas=,iv:ZVbQlqQF5IU7YkfexRNQPDbXHnPlZ6z6soBpvzUVpIc=,tag:vINZd9Dznsyf0zxXdbOU5g==,type:str] +sops_unencrypted_suffix=_unencrypted +sops_version=3.11.0 diff --git a/.env.enc b/.env.prod.enc similarity index 100% rename from .env.enc rename to .env.prod.enc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d66c85..fef46aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Format check + run: pnpm fmt:check + - name: Lint run: pnpm lint diff --git a/.github/workflows/deploy-mirror.yml b/.github/workflows/deploy-mirror.yml index 9b11e1b..2abf120 100644 --- a/.github/workflows/deploy-mirror.yml +++ b/.github/workflows/deploy-mirror.yml @@ -1,6 +1,6 @@ name: Deploy Mirror -run-name: "Deploy mirror: ${{ github.sha }}" +run-name: 'Deploy mirror: ${{ github.sha }}' concurrency: group: deploy-mirror @@ -139,9 +139,9 @@ jobs: git checkout "${BRANCH}" git pull origin "${BRANCH}" - # Decrypt .env.enc into this directory (self-contained, no dependency on prod) + # Decrypt prod env to source S3 creds and API keys umask 077 - sops -d --input-type dotenv --output-type dotenv .env.enc > .env + sops -d --input-type dotenv --output-type dotenv .env.prod.enc > .env set -a source <(grep -v '^#' .env | grep -v '^$') set +a diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a2d48a0..a071d74 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -59,7 +59,7 @@ jobs: run: | if [[ "${{ github.event_name }}" == "release" ]]; then echo "image_tag=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT - echo "env_file=.env.enc" >> $GITHUB_OUTPUT + echo "env_file=.env.prod.enc" >> $GITHUB_OUTPUT echo "stack_name=underlay-prod" >> $GITHUB_OUTPUT else echo "image_tag=${{ github.sha }}" >> $GITHUB_OUTPUT diff --git a/.gitignore b/.gitignore index c5aa268..ff770ee 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ node_modules dist -.astro .env .env.* !.env.enc diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..242abd1 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,7 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "semi": false, + "singleQuote": true, + "sortImports": true, + "sortTailwindcss": true +} diff --git a/.sops.yaml b/.sops.yaml index 59abbc3..1fb0434 100644 --- a/.sops.yaml +++ b/.sops.yaml @@ -1,9 +1,9 @@ # SOPS configuration — specifies which age public keys can decrypt. -# Matches .env and .env.dev files (both plain and .enc encrypted). +# Matches .env.local, .env.prod, and .env.dev files (both plain and .enc encrypted). # Generate a keypair: age-keygen -o key.txt creation_rules: - - path_regex: \.env(\.dev)?(\.enc)?$ + - path_regex: \.env\.(local|prod|dev)(\.enc)?$ age: >- age1wravpjmed26772xfjhawmnsnc4933htapg6y5xseqml0jdv8z9hqemzhcr, age1ysddqggsx3h8zkv7xn3z26sjak5pqms6pyqhnky9ukrvpk7es5jsayz8w7, diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..04fe0a7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017-present Knowledge Futures, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index d0db9d1..2bb382f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ -# Underlay +

  Underlay

-A versioned, content-addressed registry for structured knowledge. Apps publish snapshots of their data to Underlay; Underlay preserves them, deduplicates files, and exposes them via a stable HTTPS API. +Underlay is a versioned, content-addressed registry for structured public knowledge. Data published on Underlay is preserved, API accessible, and becomes the basis for any number of applications that can be built on top. -Built by [Knowledge Futures](https://www.knowledgefutures.org), a 501(c)(3) nonprofit. +Structured knowledge that lives inside institutional repositories and databases can be published as Underlay collections, making it available as the foundation for discovery tools, LLM integrations, custom interfaces, and any other application that needs reliable access to well-described data. + +Underlay is built by [Knowledge Futures](https://www.knowledgefutures.org), a 501(c)(3) public charity dedicated to building open-source knowledge infrastructure. ## Quick Start @@ -20,11 +22,11 @@ cd underlay ``` This starts: -- **PostgreSQL 16** on port 5433 (host) → 5432 (container) -- **MinIO** (S3-compatible storage) on ports 9000/9001 -- **Underlay** on port 3000 -The dev script auto-creates `.env.local` from `.env.test` defaults if one doesn't exist. +- **PostgreSQL 17** on port 5433 (host) → 5432 (container) +- **Underlay** on port 4100 + +For team members with SOPS keys, the dev script auto-decrypts `.env.local` from `.env.local.enc`. External contributors should run `cp .env.test .env.local` first. ### Without Docker @@ -39,25 +41,23 @@ pnpm dev:app ### Default Seed User -- **Email:** admin@underlay.org -- **Password:** admin - -Also creates a "Knowledge Futures" org with sample collections. +The seed script creates a "Knowledge Futures" org with sample collections. +In production, user accounts are created automatically on first sign-in via [KF Auth](https://auth.knowledgefutures.org) (OIDC SSO). ## Architecture -| Layer | Technology | -|-------|-----------| -| Server | Hono 4 + @hono/node-server | -| Frontend | React 19 + React Router v7 (SSR + client hydration) | -| Styling | Tailwind CSS 4 (@tailwindcss/vite) | -| Build | Vite 6 (client + SSR bundles) | -| Database | PostgreSQL 16 + Drizzle ORM | -| File Storage | Cloudflare R2 (prod) / MinIO (dev) — S3-compatible | -| Auth | Session cookies (web) + API keys (programmatic) | -| Deployment | Docker Swarm on Hetzner, Caddy reverse proxy, Cloudflare DNS | -| CI/CD | GitHub Actions → GHCR → SSH → `docker stack deploy` | -| Secrets | SOPS + age encryption | +| Layer | Technology | +| ------------ | ------------------------------------------------------------- | +| Server | Hono 4 + @hono/node-server | +| Frontend | React 19 + React Router v7 (SSR + client hydration) | +| Styling | Tailwind CSS 4 (@tailwindcss/vite) | +| Build | Vite 6 (client + SSR bundles) | +| Database | PostgreSQL 17 + Drizzle ORM | +| File Storage | S3-compatible (Cloudflare R2 in production) | +| Auth | KF Auth SSO (OIDC) for web sessions + API keys (programmatic) | +| Deployment | Docker Swarm on Hetzner, Caddy reverse proxy, Cloudflare DNS | +| CI/CD | GitHub Actions → GHCR → SSH → `docker stack deploy` | +| Secrets | SOPS + age encryption | The app runs as a single Hono server on one port (default 3000). In dev, Vite runs in middleware mode for HMR. In production, Vite builds client and SSR bundles that Hono serves directly. @@ -124,21 +124,21 @@ tools/ ### Infrastructure -- **Hetzner** — Single box (8 vCPU, 16GB RAM) running Docker Swarm -- **Caddy** — Host-level reverse proxy, TLS via `tls internal` (Cloudflare Full mode) -- **Cloudflare** — DNS + CDN + DDoS protection -- **R2** — Object storage (zero egress fees), single bucket with prefixes: - - `files/` — Content-addressed immutable uploads - - `_backups/` — Compressed Postgres dumps +- **Hetzner** - Single box (8 vCPU, 16GB RAM) running Docker Swarm +- **Caddy** - Host-level reverse proxy, TLS via `tls internal` (Cloudflare Full mode) +- **Cloudflare** - DNS + CDN + DDoS protection +- **R2** - Object storage (zero egress fees), single bucket with prefixes: + - `files/` - Content-addressed immutable uploads + - `_backups/` - Compressed Postgres dumps ### Stacks Two Docker Swarm stacks run on the same box: -| Stack | Domain | Host Port | Purpose | -|-------|--------|-----------|---------| -| `underlay-prod` | www.underlay.org | 3001 | Production | -| `underlay-dev` | dev.underlay.org | 3000 | Staging | +| Stack | Domain | Host Port | Purpose | +| --------------- | ---------------- | --------- | ---------- | +| `underlay-prod` | www.underlay.org | 3001 | Production | +| `underlay-dev` | dev.underlay.org | 3000 | Staging | Container-internal port is always 3000. Host port is configured via `PORT` in .env files. @@ -150,27 +150,27 @@ Container-internal port is always 3000. Host port is configured via `PORT` in .e The workflow: build Docker image → push to GHCR → decrypt env file for `DEPLOY_HOST` → SSH to server → `docker stack deploy` → wait for healthy rollout. -Required GitHub secrets: `SSH_PRIVATE_KEY`, `SSH_USER`, `GHCR_USER`, `GHCR_TOKEN`. +Required GitHub secrets: `SSH_PRIVATE_KEY`, `SSH_USER`, `GHCR_USER`, `GHCR_TOKEN`, `SOPS_AGE_SECRET_KEY`. ### Docker Compose Files -| File | Purpose | -|------|---------| -| `docker-compose.yml` | Deployed stacks (prod & dev via Swarm) | -| `docker-compose.local.yml` | Local development (source-mounted, MinIO, hot reload) | +| File | Purpose | +| -------------------------- | ----------------------------------------------------- | +| `docker-compose.yml` | Deployed stacks (prod & dev via Swarm) | +| `docker-compose.local.yml` | Local development (source-mounted, hot reload) | ## Environment Variables -| Variable | Description | -|----------|-------------| -| `DATABASE_URL` | PostgreSQL connection string | +| Variable | Description | +| ---------------- | ---------------------------------- | +| `DATABASE_URL` | PostgreSQL connection string | | `SESSION_SECRET` | Secret for signing session cookies | -| `PORT` | Server port (default: 3000) | -| `S3_BUCKET` | S3 bucket name | -| `S3_REGION` | S3 region (`auto` for R2) | -| `S3_ENDPOINT` | S3 endpoint URL | -| `S3_ACCESS_KEY` | S3 access key | -| `S3_SECRET_KEY` | S3 secret key | +| `PORT` | Server port (default: 3000) | +| `S3_BUCKET` | S3 bucket name | +| `S3_REGION` | S3 region (`auto` for R2) | +| `S3_ENDPOINT` | S3 endpoint URL | +| `S3_ACCESS_KEY` | S3 access key | +| `S3_SECRET_KEY` | S3 secret key | `NODE_ENV` is set in `docker-compose.yml` `environment:` block (not in .env files). @@ -186,7 +186,7 @@ pnpm start # Start production server # Code quality pnpm typecheck # TypeScript type checking pnpm lint # Lint with oxlint -pnpm fmt # Format with dprint +pnpm fmt # Format with oxfmt pnpm fmt:check # Check formatting # Database @@ -200,10 +200,12 @@ pnpm tool:restore # Restore database from backup pnpm tool:pruneBackups # Prune old backups # Secrets (SOPS + age) -pnpm secrets:encrypt # Encrypt .env → .env.enc -pnpm secrets:encrypt:dev # Encrypt .env.dev → .env.dev.enc -pnpm secrets:decrypt # Decrypt .env.enc → .env -pnpm secrets:decrypt:dev # Decrypt .env.dev.enc → .env.dev +pnpm secrets:encrypt:local # Encrypt .env.local → .env.local.enc +pnpm secrets:encrypt:prod # Encrypt .env.prod → .env.prod.enc +pnpm secrets:encrypt:dev # Encrypt .env.dev → .env.dev.enc +pnpm secrets:decrypt:local # Decrypt .env.local.enc → .env.local +pnpm secrets:decrypt:prod # Decrypt .env.prod.enc → .env.prod +pnpm secrets:decrypt:dev # Decrypt .env.dev.enc → .env.dev ``` ## Schema System @@ -214,7 +216,7 @@ Underlay uses **globally deduplicated, content-addressed schemas** for record va - Each record type in a collection has its own JSON Schema, stored as an immutable, content-addressed row in the global `schemas` table. - A version declares its full set of type→schema bindings via the `version_schemas` join table. -- If two collections define the same fields and types for a record type, they produce the same schema hash — alignment is automatic. +- If two collections define the same fields and types for a record type, they produce the same schema hash. Alignment is automatic. - Schemas are never modified. Evolving a type produces a new hash and a new row. ### Push payload @@ -237,18 +239,18 @@ Fields that hold record IDs of another type use `"x-ref-type": "TypeName"` to do Schemas can be labeled post-hoc with human-readable names or URIs (e.g. `schema.org/Person`, `dc.author.v1`). Labels enable discovery across collections without upfront coordination. -- `POST /api/schemas/:id/labels` — Add a label -- `DELETE /api/schemas/:id/labels/:label` — Remove a label -- `GET /api/schemas?label=...` — Search by label +- `POST /api/schemas/:id/labels` - Add a label +- `DELETE /api/schemas/:id/labels/:label` - Remove a label +- `GET /api/schemas?label=...` - Search by label - Labels are injected as `x-underlay-labels` in schema exports (opt-out via `?raw=true`) ### Schema discovery API -| Endpoint | Purpose | -|----------|--------| -| `GET /api/schemas` | Global search (filter by `q`, `slug`, `label`, `schema_hash`) | -| `GET /api/schemas/:id` | Single schema with labels + usage info | -| `GET /api/collections/:owner/:slug/schemas` | Collection's schemas (with label enrichment) | +| Endpoint | Purpose | +| ------------------------------------------- | ------------------------------------------------------------- | +| `GET /api/schemas` | Global search (filter by `q`, `slug`, `label`, `schema_hash`) | +| `GET /api/schemas/:id` | Single schema with labels + usage info | +| `GET /api/collections/:owner/:slug/schemas` | Collection's schemas (with label enrichment) | ### Versioning semantics @@ -260,27 +262,28 @@ Schemas can be labeled post-hoc with human-readable names or URIs (e.g. `schema. When adding or changing features, update these locations: -| What | Where | Purpose | -|------|-------|---------| -| API documentation | `public/.well-known/ai.txt` | Machine-readable docs for LLMs and bots | -| Concepts | `src/routes/docs/concepts.tsx` | Core concepts explanation | -| API reference | `src/routes/docs/api/*.tsx` | Endpoint-level docs with examples | -| Integration guide | `src/routes/docs/integration.tsx` | Developer onboarding guide | -| Quick start | `src/routes/docs/quickstart.tsx` | Getting started tutorial | -| Self-hosting | `src/routes/docs/self-host.tsx` | Deployment instructions | -| DB schema | `src/db/schema.ts` → `pnpm db:generate` | Schema changes need a migration | -| Schema discovery | `src/api/schemas.ts` | Schema search, labeling, cross-referencing | -| Encrypted secrets | `.env.enc` / `.env.dev.enc` | Re-encrypt after changing .env files | +| What | Where | Purpose | +| ----------------- | --------------------------------------- | ------------------------------------------ | +| API documentation | `public/.well-known/ai.txt` | Machine-readable docs for LLMs and bots | +| Concepts | `src/routes/docs/concepts.tsx` | Core concepts explanation | +| API reference | `src/routes/docs/api/*.tsx` | Endpoint-level docs with examples | +| Integration guide | `src/routes/docs/integration.tsx` | Developer onboarding guide | +| Quick start | `src/routes/docs/quickstart.tsx` | Getting started tutorial | +| Self-hosting | `src/routes/docs/self-host.tsx` | Deployment instructions | +| DB schema | `src/db/schema.ts` → `pnpm db:generate` | Schema changes need a migration | +| Schema discovery | `src/api/schemas.ts` | Schema search, labeling, cross-referencing | +| Encrypted secrets | `.env.enc` / `.env.dev.enc` | Re-encrypt after changing .env files | ### Privacy features The system supports three levels of privacy (type-level, field-level, record-level) via `"private": true` annotations in per-type schemas. When changing how privacy works, update: -- `src/api/versions.ts` — filtering logic (reads from `version_schemas` JOIN `schemas`) -- `src/api/files.ts` — file access checks -- `src/api/schemas.ts` — public schema filtering -- `public/.well-known/ai.txt` — Privacy section -- `src/routes/docs/concepts.tsx` — Privacy section -- `src/routes/docs/api/versions.tsx` — Push endpoint docs + +- `src/api/versions.ts` - filtering logic (reads from `version_schemas` JOIN `schemas`) +- `src/api/files.ts` - file access checks +- `src/api/schemas.ts` - public schema filtering +- `public/.well-known/ai.txt` - Privacy section +- `src/routes/docs/concepts.tsx` - Privacy section +- `src/routes/docs/api/versions.tsx` - Push endpoint docs ## License diff --git a/content/blog/2024-04-27-institutional-repositories.md b/content/blog/2024-04-27-institutional-repositories.md index 5bf50de..78f88ef 100644 --- a/content/blog/2024-04-27-institutional-repositories.md +++ b/content/blog/2024-04-27-institutional-repositories.md @@ -1,5 +1,5 @@ --- -title: "The IR of the Future Is a Reading List" +title: 'The IR of the Future Is a Reading List' subtitle: "Institutional repositories don't need to be monoliths. They need to be views." date: 2024-04-27 --- @@ -62,4 +62,4 @@ The institutional repository we are describing is a curated reading list backed Institutional repositories were always meant to make a university's knowledge accessible. They ended up becoming another silo that knowledge had to be manually deposited into. The better path is to make the knowledge accessible first, structured and versioned and public, and let the institution put its frame around it. -*Underlay is a public registry for structured knowledge. An institutional repository is one way to read it.* +_Underlay is a public registry for structured knowledge. An institutional repository is one way to read it._ diff --git a/content/blog/2024-04-27-underlay-revived.md b/content/blog/2024-04-27-underlay-revived.md index 027f15e..235dc48 100644 --- a/content/blog/2024-04-27-underlay-revived.md +++ b/content/blog/2024-04-27-underlay-revived.md @@ -1,6 +1,6 @@ --- -title: "Underlay, Revived" -subtitle: "The landscape changed. The project can finally be simple." +title: 'Underlay, Revived' +subtitle: 'The landscape changed. The project can finally be simple.' date: 2024-04-27 --- @@ -20,7 +20,7 @@ Three things, each removing a class of complexity the original design had to abs That is no longer true. With modern tooling and AI-assisted development, a bespoke publishing application with custom workflows and a tailored editorial process can be built in a fraction of the time and cost. Better for the user: they get exactly the tool they need. -But if the application is bespoke, and perhaps even disposable, the *data* needs to live somewhere durable. The application is the interface; the archive is the thing that lasts. +But if the application is bespoke, and perhaps even disposable, the _data_ needs to live somewhere durable. The application is the interface; the archive is the thing that lasts. **LLMs changed what "interoperability" requires.** The original Underlay spent enormous effort on alignment: reconciling schemas, mapping ontologies, resolving entity references across datasets. This was genuinely hard when the consumer was a rigid program that needed exact field names and precise types. @@ -69,4 +69,4 @@ The Underlay was always about one thing: public knowledge should be a public res The hard problems we originally tried to solve in the protocol (alignment, mapping, transformation, discovery) are now better solved by tools and models that sit on top of structured data. The Underlay does not need to be smart. It needs to be reliable, durable, and clear. Get the structure right, make it public, and the rest follows. -*Underlay is a public registry for structured knowledge. The structure is the infrastructure.* +_Underlay is a public registry for structured knowledge. The structure is the infrastructure._ diff --git a/content/blog/2026-04-28-atproto-integration.md b/content/blog/2026-04-28-atproto-integration.md index e7b7ee5..5e6e889 100644 --- a/content/blog/2026-04-28-atproto-integration.md +++ b/content/blog/2026-04-28-atproto-integration.md @@ -1,6 +1,6 @@ --- -title: "Underlay Meets AT Protocol" -subtitle: "Every collection is a feed. Every push is an event." +title: 'Underlay Meets AT Protocol' +subtitle: 'Every collection is a feed. Every push is an event.' date: 2026-04-28 --- diff --git a/content/blog/2026-04-30-schema-evolution.md b/content/blog/2026-04-30-schema-evolution.md index 037ecb5..8d44ed3 100644 --- a/content/blog/2026-04-30-schema-evolution.md +++ b/content/blog/2026-04-30-schema-evolution.md @@ -1,6 +1,6 @@ --- -title: "From Monolithic Schemas to Content-Addressed Types" -subtitle: "How we made interoperability automatic by treating schemas like files." +title: 'From Monolithic Schemas to Content-Addressed Types' +subtitle: 'How we made interoperability automatic by treating schemas like files.' date: 2026-04-30 --- diff --git a/dev.sh b/dev.sh index 94e9b3f..d094d6b 100755 --- a/dev.sh +++ b/dev.sh @@ -2,13 +2,18 @@ set -euo pipefail cd "$(dirname "$0")" -# Load env vars (prefer .env.local for local dev) +# Decrypt local env if needed +if [[ -f .env.local.enc ]] && [[ ! -f .env.local ]]; then + sops -d --input-type dotenv --output-type dotenv --output .env.local .env.local.enc +fi + +# Load env vars set -a -[[ -f .env.local ]] && source .env.local || [[ -f .env ]] && source .env +[[ -f .env.local ]] && source .env.local set +a -# Find an available port, incrementing from PORT (default 3000) -BASE_PORT="${PORT:-3000}" +# Find an available port, incrementing from PORT (default 4100) +BASE_PORT="${PORT:-4100}" PORT="$BASE_PORT" while lsof -iTCP:"$PORT" -sTCP:LISTEN -t &>/dev/null; do ((PORT++)) @@ -20,4 +25,4 @@ export PORT trap "docker compose -f docker-compose.local.yml down" EXIT -docker compose -f docker-compose.local.yml up --build --attach app +docker compose --env-file .env.local -f docker-compose.local.yml up --build --attach app diff --git a/docker-compose.local.yml b/docker-compose.local.yml index c687052..f39c40a 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -1,6 +1,6 @@ # Local development compose — source-mounted for fast reload. # Start with: ./dev.sh -# Access at localhost:${PORT:-3000} +# Access at localhost:${PORT:-4100} name: underlay-local @@ -34,12 +34,14 @@ services: dockerfile: Dockerfile target: dev ports: - - '${PORT:-3000}:${PORT:-3000}' + - '${PORT:-4100}:${PORT:-4100}' volumes: - .:/app - - /app/node_modules + - app_node_modules:/app/node_modules + env_file: + - .env.local environment: - PORT: ${PORT:-3000} + PORT: ${PORT:-4100} DATABASE_URL: postgresql://underlay:underlay@db:5432/underlay command: sh -c "pnpm db:migrate && pnpm db:seed && pnpm dev:app" depends_on: @@ -48,3 +50,4 @@ services: volumes: pgdata-dev: + app_node_modules: diff --git a/docker-compose.yml b/docker-compose.yml index 50472de..425b131 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,5 @@ # Stack file — deploy with: docker stack deploy -c docker-compose.yml -# SOPS decrypts .env.enc → .env before this runs (see deploy workflow). +# SOPS decrypts .env.{prod,dev}.enc → .env before this runs (see deploy workflow). services: app: @@ -38,7 +38,11 @@ services: tmpfs: - /tmp:size=64m healthcheck: - test: ['CMD-SHELL', 'node -e "Promise.all([fetch(\"http://127.0.0.1:${PORT:-3000}/api/health\"),fetch(\"http://127.0.0.1:${PORT:-3000}/\")]).then(rs=>{for(const r of rs)if(!r.ok)throw r.status}).catch(()=>process.exit(1))"'] + test: + [ + 'CMD-SHELL', + 'node -e "Promise.all([fetch(\"http://127.0.0.1:${PORT:-3000}/api/health\"),fetch(\"http://127.0.0.1:${PORT:-3000}/\")]).then(rs=>{for(const r of rs)if(!r.ok)throw r.status}).catch(()=>process.exit(1))"', + ] interval: 30s timeout: 10s retries: 3 @@ -105,6 +109,8 @@ services: -c random_page_cost=1.1 volumes: - pgdata:/var/lib/postgresql/data + ports: + - '${DB_PORT:-5432}:5432' networks: - appnet - dbaccess @@ -114,24 +120,24 @@ services: resources: limits: memory: 4g - cpus: "2.0" + cpus: '2.0' reservations: memory: 1g - cpus: "0.5" + cpus: '0.5' placement: constraints: [node.role == manager] restart_policy: condition: any healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-underlay}"] + test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-underlay}'] interval: 10s timeout: 5s retries: 5 logging: driver: json-file options: - max-size: "10m" - max-file: "3" + max-size: '10m' + max-file: '3' networks: appnet: diff --git a/dprint.json b/dprint.json deleted file mode 100644 index bd92cbf..0000000 --- a/dprint.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "typescript": { - "indentWidth": 2, - "semiColons": "asi", - "quoteStyle": "preferSingle", - "trailingCommas": "always" - }, - "json": { - "indentWidth": 2 - }, - "plugins": [ - "https://plugins.dprint.dev/typescript-0.93.3.wasm", - "https://plugins.dprint.dev/json-0.19.4.wasm", - "https://plugins.dprint.dev/g-plane/malva-v0.11.0.wasm" - ], - "includes": [ - "src/**/*.{ts,tsx,css}", - "*.{ts,json}", - "server.ts", - "vite.config.ts", - "vitest.config.ts", - "drizzle.config.ts" - ], - "excludes": ["node_modules", "dist"] -} diff --git a/drizzle.config.ts b/drizzle.config.ts index 36f59a5..d81dc9d 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,4 +1,4 @@ -import type { Config, } from 'drizzle-kit' +import type { Config } from 'drizzle-kit' export default { schema: './src/db/schema.ts', diff --git a/package.json b/package.json index a08edc8..62f6a65 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,6 @@ "version": "0.1.0", "private": true, "type": "module", - "packageManager": "pnpm@10.33.3", "scripts": { "dev": "./dev.sh", "dev:app": "tsx --env-file=.env.local server.ts", @@ -11,8 +10,8 @@ "start": "NODE_ENV=production node --import tsx/esm server.ts", "typecheck": "tsc --noEmit", "lint": "oxlint .", - "fmt": "dprint fmt", - "fmt:check": "dprint check", + "fmt": "oxfmt", + "fmt:check": "oxfmt --check", "db:generate": "drizzle-kit generate", "db:migrate": "tsx src/db/migrate.ts", "db:seed": "tsx src/db/seed.ts", @@ -22,9 +21,11 @@ "tool:seed-mirror": "tsx tools/seedMirror.ts", "test": "vitest run", "test:watch": "vitest", - "secrets:encrypt": "sops -e --input-type dotenv --output-type dotenv --output .env.enc .env", + "secrets:encrypt:local": "sops -e --input-type dotenv --output-type dotenv --output .env.local.enc .env.local", + "secrets:encrypt:prod": "sops -e --input-type dotenv --output-type dotenv --output .env.prod.enc .env.prod", "secrets:encrypt:dev": "sops -e --input-type dotenv --output-type dotenv --output .env.dev.enc .env.dev", - "secrets:decrypt": "sops -d --input-type dotenv --output-type dotenv --output .env .env.enc", + "secrets:decrypt:local": "sops -d --input-type dotenv --output-type dotenv --output .env.local .env.local.enc", + "secrets:decrypt:prod": "sops -d --input-type dotenv --output-type dotenv --output .env.prod .env.prod.enc", "secrets:decrypt:dev": "sops -d --input-type dotenv --output-type dotenv --output .env.dev .env.dev.enc" }, "dependencies": { @@ -69,10 +70,10 @@ "@types/react-dom": "^19.1.0", "@types/tar-stream": "^3.1.4", "@vitejs/plugin-react": "^4", - "dprint": "latest", "drizzle-kit": "^0.31.0", "happy-dom": "^20.9.0", "lint-staged": "^17.0.4", + "oxfmt": "latest", "oxlint": "latest", "simple-git-hooks": "^2.13.1", "tailwindcss": "^4.1.0", @@ -84,6 +85,7 @@ "pre-commit": "pnpm lint-staged" }, "lint-staged": { - "*.{ts,tsx,css,json}": "dprint fmt --" - } + "*.{ts,tsx,css,json}": "oxfmt --" + }, + "packageManager": "pnpm@10.33.3" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2057bc2..cea68fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,9 +126,6 @@ importers: '@vitejs/plugin-react': specifier: ^4 version: 4.7.0(vite@6.4.2(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) - dprint: - specifier: latest - version: 0.54.0 drizzle-kit: specifier: ^0.31.0 version: 0.31.10 @@ -138,6 +135,9 @@ importers: lint-staged: specifier: ^17.0.4 version: 17.0.4 + oxfmt: + specifier: latest + version: 0.50.0 oxlint: specifier: latest version: 1.63.0 @@ -429,68 +429,6 @@ packages: '@codemirror/view@6.42.1': resolution: {integrity: sha512-ToN3oFc0nsxNUYVF5P0ztLgbC4UPPjPtA9aKYhkOKQaZASpOUo6ISXyQLP66ctVwlDc+j6Jv0uK5IFALkiXztg==} - '@dprint/darwin-arm64@0.54.0': - resolution: {integrity: sha512-yqRI4enH+BDp+4+ZsPVdZM5h873JK1lN7li9l9A5u4C4cvh1oEsiBWAzEPccRkJ2ctF8LgaizBSxO38sqEVYbw==} - cpu: [arm64] - os: [darwin] - - '@dprint/darwin-x64@0.54.0': - resolution: {integrity: sha512-W9BARpgHypcQwatg5mnHaCpX6pLX5dBxxiv+tZKruhOmq8MKYOrAYDXlceMuHSowmWREfUF5yL4SRgXDGI6WQw==} - cpu: [x64] - os: [darwin] - - '@dprint/linux-arm64-glibc@0.54.0': - resolution: {integrity: sha512-VhM7p70VFuNqxZMdiv1e+nMboPj/hMFlTIBWrRaX7+6VThs9mJr9+94wrUeXgfnfsyaEKSbRFa/dru1PINoSNw==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@dprint/linux-arm64-musl@0.54.0': - resolution: {integrity: sha512-QS1A74Lv60/L9oemHCzbHgOLbV2smSJG5IxS5fjf8ZWetyUt918WDzIHBilz/+uiB+OlW2UVTsm952UG0YOrLw==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@dprint/linux-loong64-glibc@0.54.0': - resolution: {integrity: sha512-8Myka2/0KbhuZnEKL6jagPXTgDKVpd/tfXDRa0oibUBgaqOSku6iRMzHGa/PhqHL+s14Gcp+/cIHz0zU3Tkgug==} - cpu: [loong64] - os: [linux] - libc: [glibc] - - '@dprint/linux-loong64-musl@0.54.0': - resolution: {integrity: sha512-/AN3xCuMhC4PK7Pbj7/4zBuhFGr4m0OHV/5uGTfzpkKX/3+AXoyKl7PbT2VlNMGXAK0kuRThfjtx23gIwlWk7Q==} - cpu: [loong64] - os: [linux] - libc: [musl] - - '@dprint/linux-riscv64-glibc@0.54.0': - resolution: {integrity: sha512-Aw2vXzzwFDpPbXh6ajsSabVCkCc66C3hCyMKprR/IxYvFtjYX80nh1ox0c7iaw6c4HacHMRLGw7FUSXvomPaEQ==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@dprint/linux-x64-glibc@0.54.0': - resolution: {integrity: sha512-zZqj3wQELOX8n6QfT2uuWoMf64Wv0lMXNyam3btm+PKkg0P6a54TPL09Bs9XsViOdxgTcamsiQ7HlErt/LEjIA==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@dprint/linux-x64-musl@0.54.0': - resolution: {integrity: sha512-it6Qdt06dyW2adbAXpOCb7/KQLxlm4i1UphUAWqWsZk4t3EYetyAza9J0g3Vu9itIWSEIo9MnccgANckQJ6+qw==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@dprint/win32-arm64@0.54.0': - resolution: {integrity: sha512-F5kjV/6I9YtNOTDWHUpTqM2HHHS510BPL7z4NJuU0nDnaVeks7GwNEltGr56CcsG8XQYhkiAsqZytPu6AhA2hQ==} - cpu: [arm64] - os: [win32] - - '@dprint/win32-x64@0.54.0': - resolution: {integrity: sha512-AAr2ye/DtgYXDplRoPS+5U++x7T6W4a3I9FvTFWFxziFmUptvAg5G2c4FcXoAduSruhYZJvjDZrLseR2c3IwXg==} - cpu: [x64] - os: [win32] - '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -983,6 +921,128 @@ packages: '@nodable/entities@2.1.0': resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==} + '@oxfmt/binding-android-arm-eabi@0.50.0': + resolution: {integrity: sha512-ICXQVKrDvsWUtfx6EiVJxfWrajKTwTfRV8vz2XiMkxZeuCKJLgD4YAj6dE3BWvpqDlkVkie4VSTAtMUWO9LDXg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.50.0': + resolution: {integrity: sha512-quwjLQFkuW6OwLHeDeIXsTzOmipQFQbqsYN9HLk2B5I01IlAQZHP1UiLIg0O7pP+dUgPD2AD7SCYA3gs6NH5/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.50.0': + resolution: {integrity: sha512-ikU5umElcMi78/TNI334wtjr5WZ5F4nWa1aIDseAKKGL0W3ygxeYKkrIJ0fggWa8MOon66BmG3xCqmX1m9YAOw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.50.0': + resolution: {integrity: sha512-WT4MOYG4mv9IXrH0m60vHsJh+rRMPSOKTQmwDpwmgQ+DuW/i5dU4pqc0HDO5uclO5vjz5IFX5z/taW86LSVe/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.50.0': + resolution: {integrity: sha512-gH0rycVXqV4juWkvLs2uPMtTyppDc7qEUVzXAxnQ7FpcSZNXqKowUgtjH8q67ngj416r8+4NnAlyR/D35zwwhQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.50.0': + resolution: {integrity: sha512-wL/k+o0hiTeRvi/gPzeC1L/yTHTXIeHDKWU09s2zTBmv7ma59wTm+fADNSGYxhJQDxyavQbwTf1QpW3Zj924tQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.50.0': + resolution: {integrity: sha512-Y59FKqoUM3Gf00E395b4ixfWyJGwO2GzaZawF5MZoVWcb3f6CkWUXyao0jyOvoIxDMzMybcVRuXyG7ih/Nxweg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.50.0': + resolution: {integrity: sha512-OvXbfTjMignXWyJXg/NOFsiy996vFe8wb9tkxJaUq8ylq0XrzJg3ttavC5Tcmm6F8/GUs2r3XFJWWu9q/27uYw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-arm64-musl@0.50.0': + resolution: {integrity: sha512-rqmvHZm7vMa3NLYa0khwkhReCmp9tqKnF23TFZ7S5cYJLvIE4b0k8famWE7kO897/DXznJe675n5SohFBggbxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-ppc64-gnu@0.50.0': + resolution: {integrity: sha512-49bAdYbMSde42tzPDtuHnBWzOgmoS0PT9THCjvMnDVYMQYiHzPc2Mv5rkpBHVQOXM+PHfafJlxgK0anXSWBVvw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-gnu@0.50.0': + resolution: {integrity: sha512-VFT25/6kckkIM62KeWB2bi+xCEmC/zC+DcMaIpEfaio8ulkGDLSiTz11TyK0eqgTl3x5OklYEGDWohvAgOr8Bw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-musl@0.50.0': + resolution: {integrity: sha512-BBJMuNy6jjkXjUUINF5UTQqb/nvjmtJad43Gp7bab0AAURAdthhJvduR7rHpWInpWYiaMzYsdrmURNcrmpxdZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-s390x-gnu@0.50.0': + resolution: {integrity: sha512-Xd4y+yjAYHKmryXhyUUwbyRD01iKfcvI74iE01L6p4F8SwjhZQXDshK+T8PcrPZLiFqH263P5xqJk94amjkjzQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-gnu@0.50.0': + resolution: {integrity: sha512-Qp96rYJru7l++7mk4R+eh8qq9GFfFAMdmoN6VGoRHI8AA1XMnUIzH4u+zOcKZZwY+irHdsaBldDearwB4nOH7A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-musl@0.50.0': + resolution: {integrity: sha512-5XLGp+yd5w2Key5LMqJO+X3XVsJKgeeUKljy32+MBF/J/JZ5m8WHl6dI5eOQOr3ixopxPiXIyDAxn3slI3UXiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-openharmony-arm64@0.50.0': + resolution: {integrity: sha512-QAxwzh7+GHugCD7WuERolVs8TKQwXNIAZXAHHTecbKVc9oWBkWzOiLauQuezXS57tVcof5zhi1IjZ8tOV0htTg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.50.0': + resolution: {integrity: sha512-3nKN/kqClm9iCFWTwtJ9UpR5SGyExp5l3nw6uIiBt+3XitQtszin+vjHrL7JHfDksZ7Svigdaow2zqz/IKCfqw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.50.0': + resolution: {integrity: sha512-3r6XZ8+X6qlLbXaPW2NygfiAWSpKbkE36pAVzS83mY+cYY+pSMalJ+qnCgkr92tr+Iqv988XKQ1CpARTg9ITbQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.50.0': + resolution: {integrity: sha512-BSE8D8KsvquMG9vU+Qt4qGuoOcZ36rxU5S6ZkHNguj+MlWkXWCBETnno3yJ9CfWvfCrbmieaN9LK6hdcdHNZ/w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxlint/binding-android-arm-eabi@1.63.0': resolution: {integrity: sha512-A9xLtQt7i0OA1PoB/meog6kikXI9CdwEp7ZwQqmgnpKn3G3b1orvTDy8CQ6T7w1HvDrgWGB78PkFKcWgibcTCg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1850,10 +1910,6 @@ packages: dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} - dprint@0.54.0: - resolution: {integrity: sha512-sIy25poR2gRP/tWPTgP0MPeJoJcpv0xzYDcsboapvthbEt1Qw3Al252CA0xFyIh2cYEGGKyBJtKokryv4ERlJw==} - hasBin: true - drizzle-kit@0.31.10: resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} hasBin: true @@ -2269,6 +2325,16 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} + oxfmt@0.50.0: + resolution: {integrity: sha512-owwjTnhfM5aCOJhYeqDvk7iM504OeYFZpdRU7cxx7xtZMo4uVpjlryTUon+Cf76CugsvnqA32e6rC73pr1hXaw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + svelte: ^5.0.0 + peerDependenciesMeta: + svelte: + optional: true + oxlint@1.63.0: resolution: {integrity: sha512-9TGXetdjgIHOJ9OiReomP7nnrMkV9HxC1xM2ramJSLQpzxjsAJtQwa4wqkJN2f/uCrqZuJseFuSlWDdvcruveg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2496,6 +2562,10 @@ packages: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinypool@2.1.0: + resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + engines: {node: ^20.0.0 || >=22.0.0} + tinyrainbow@3.1.0: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} @@ -3281,39 +3351,6 @@ snapshots: style-mod: 4.1.3 w3c-keyname: 2.2.8 - '@dprint/darwin-arm64@0.54.0': - optional: true - - '@dprint/darwin-x64@0.54.0': - optional: true - - '@dprint/linux-arm64-glibc@0.54.0': - optional: true - - '@dprint/linux-arm64-musl@0.54.0': - optional: true - - '@dprint/linux-loong64-glibc@0.54.0': - optional: true - - '@dprint/linux-loong64-musl@0.54.0': - optional: true - - '@dprint/linux-riscv64-glibc@0.54.0': - optional: true - - '@dprint/linux-x64-glibc@0.54.0': - optional: true - - '@dprint/linux-x64-musl@0.54.0': - optional: true - - '@dprint/win32-arm64@0.54.0': - optional: true - - '@dprint/win32-x64@0.54.0': - optional: true - '@drizzle-team/brocli@0.10.2': {} '@esbuild-kit/core-utils@3.3.2': @@ -3585,6 +3622,63 @@ snapshots: '@nodable/entities@2.1.0': {} + '@oxfmt/binding-android-arm-eabi@0.50.0': + optional: true + + '@oxfmt/binding-android-arm64@0.50.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.50.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.50.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.50.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.50.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.50.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.50.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.50.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.50.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.50.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.50.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.50.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.50.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.50.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.50.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.50.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.50.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.50.0': + optional: true + '@oxlint/binding-android-arm-eabi@1.63.0': optional: true @@ -4417,20 +4511,6 @@ snapshots: dom-accessibility-api@0.5.16: {} - dprint@0.54.0: - optionalDependencies: - '@dprint/darwin-arm64': 0.54.0 - '@dprint/darwin-x64': 0.54.0 - '@dprint/linux-arm64-glibc': 0.54.0 - '@dprint/linux-arm64-musl': 0.54.0 - '@dprint/linux-loong64-glibc': 0.54.0 - '@dprint/linux-loong64-musl': 0.54.0 - '@dprint/linux-riscv64-glibc': 0.54.0 - '@dprint/linux-x64-glibc': 0.54.0 - '@dprint/linux-x64-musl': 0.54.0 - '@dprint/win32-arm64': 0.54.0 - '@dprint/win32-x64': 0.54.0 - drizzle-kit@0.31.10: dependencies: '@drizzle-team/brocli': 0.10.2 @@ -4768,6 +4848,30 @@ snapshots: dependencies: mimic-function: 5.0.1 + oxfmt@0.50.0: + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.50.0 + '@oxfmt/binding-android-arm64': 0.50.0 + '@oxfmt/binding-darwin-arm64': 0.50.0 + '@oxfmt/binding-darwin-x64': 0.50.0 + '@oxfmt/binding-freebsd-x64': 0.50.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.50.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.50.0 + '@oxfmt/binding-linux-arm64-gnu': 0.50.0 + '@oxfmt/binding-linux-arm64-musl': 0.50.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.50.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.50.0 + '@oxfmt/binding-linux-riscv64-musl': 0.50.0 + '@oxfmt/binding-linux-s390x-gnu': 0.50.0 + '@oxfmt/binding-linux-x64-gnu': 0.50.0 + '@oxfmt/binding-linux-x64-musl': 0.50.0 + '@oxfmt/binding-openharmony-arm64': 0.50.0 + '@oxfmt/binding-win32-arm64-msvc': 0.50.0 + '@oxfmt/binding-win32-ia32-msvc': 0.50.0 + '@oxfmt/binding-win32-x64-msvc': 0.50.0 + oxlint@1.63.0: optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.63.0 @@ -5043,6 +5147,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinypool@2.1.0: {} + tinyrainbow@3.1.0: {} tslib@2.8.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6d6aebc..aa9dd4d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,5 @@ allowBuilds: bcrypt: true better-sqlite3: true - dprint: true esbuild: true simple-git-hooks: false diff --git a/public/.well-known/ai.txt b/public/.well-known/ai.txt index 8763292..14fb9fe 100644 --- a/public/.well-known/ai.txt +++ b/public/.well-known/ai.txt @@ -15,12 +15,11 @@ There are two auth methods: 1. API Key (for programmatic access): Header: Authorization: Bearer ul_ Keys have scopes: read, write, admin. - Create keys at https://underlay.org/settings/keys or via POST /api/accounts/keys. + Create keys at https://underlay.org/settings/keys. 2. Session cookie (for browser use): - POST /api/accounts/login with {"email": "...", "password": "..."} sets a session cookie. - POST /api/accounts/signup with {"email", "password", "username", "displayName"} creates an account. - POST /api/accounts/logout clears the session. + Users sign in via KF Auth SSO (OIDC) at https://underlay.org/login. + Accounts are created automatically on first sign-in. GET /api/accounts/me returns the current user (works with either auth method). All GET requests are public — no auth required to read public data. diff --git a/server.ts b/server.ts index 1cc0ecb..f575334 100644 --- a/server.ts +++ b/server.ts @@ -1,262 +1,308 @@ -import { serve, } from '@hono/node-server' -import { serveStatic, } from '@hono/node-server/serve-static' -import { Hono, } from 'hono' -import { cors, } from 'hono/cors' -import { marked, } from 'marked' -import { existsSync, readFileSync, } from 'node:fs' -import { resolve, } from 'node:path' +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +import { serve } from '@hono/node-server' +import { serveStatic } from '@hono/node-server/serve-static' +import { Hono } from 'hono' +import { cors } from 'hono/cors' +import { marked } from 'marked' import * as accounts from '~/api/accounts' import * as admin from '~/api/admin' import * as ark from '~/api/ark' -import { arkMiddleware, } from '~/api/ark-middleware.server' -import type { AuthEnv, } from '~/api/auth.server' -import { authMiddleware, requireAuth, } from '~/api/auth.server' +import { arkMiddleware } from '~/api/ark-middleware.server' +import type { AuthEnv } from '~/api/auth.server' +import { authMiddleware, requireAuth } from '~/api/auth.server' import * as collections from '~/api/collections' import * as files from '~/api/files' import * as health from '~/api/health' +import * as kfAuth from '~/api/kf-auth' +import * as kfSummary from '~/api/kf-summary' import * as query from '~/api/query' import * as schemas from '~/api/schemas' import * as uploads from '~/api/uploads' import * as versions from '~/api/versions' -import { getMirrorConfig, } from '~/lib/mirror-config' +import { getMirrorConfig } from '~/lib/mirror-config' const isProd = process.env.NODE_ENV === 'production' const app = new Hono() // --- CORS --- -app.use('/api/*', cors({ origin: '*', credentials: true, },),) +app.use('/api/*', cors({ origin: '*', credentials: true })) // --- Auth middleware for API routes --- -app.use('/api/*', authMiddleware,) +app.use('/api/*', authMiddleware) // --- Mirror mode guard for admin routes --- -app.use('/api/admin/*', async (c, next,) => { +app.use('/api/admin/*', async (c, next) => { const config = getMirrorConfig() if (!config.enabled) { - return c.json({ error: 'Not found', statusCode: 404, }, 404,) + return c.json({ error: 'Not found', statusCode: 404 }, 404) } await next() -},) +}) // --- ARK resolution middleware --- -app.use('/ark\\:*', arkMiddleware,) +app.use('/ark\\:*', arkMiddleware) + +// --- KF Auth (OIDC login) --- +app.get('/login', async (c, next) => { + // Server-side redirect to avoid client-side "Redirecting..." flash. + // Fall through to the React route only when there's an error to display. + const url = new URL(c.req.url) + if (!url.searchParams.has('error')) { + const returnTo = url.searchParams.get('return_to') ?? '' + const target = returnTo + ? `/auth/login?return_to=${encodeURIComponent(returnTo)}` + : '/auth/login' + return c.redirect(target) + } + await next() +}) +app.get('/auth/login', kfAuth.login) +app.get('/auth/callback', kfAuth.callback) +app.post('/auth/logout', kfAuth.logout) // --- API routes --- -app.get('/api/health', health.check,) +app.get('/api/health', health.check) + +// KF internal (service-to-service) +app.get('/api/kf/summary', kfSummary.summary) // Admin (mirror) -app.get('/api/admin/mirror/status', admin.mirrorStatus,) -app.post('/api/admin/mirror/test', admin.mirrorTest,) -app.post('/api/admin/mirror/sync', admin.mirrorSync,) -app.post('/api/admin/mirror/sync/stop', admin.mirrorSyncStop,) -app.get('/api/admin/mirror/sync/progress', admin.mirrorSyncProgress,) -app.get('/api/admin/mirror/sync/active', admin.mirrorSyncActive,) -app.get('/api/admin/mirror/history', admin.mirrorHistory,) +app.get('/api/admin/mirror/status', admin.mirrorStatus) +app.post('/api/admin/mirror/test', admin.mirrorTest) +app.post('/api/admin/mirror/sync', admin.mirrorSync) +app.post('/api/admin/mirror/sync/stop', admin.mirrorSyncStop) +app.get('/api/admin/mirror/sync/progress', admin.mirrorSyncProgress) +app.get('/api/admin/mirror/sync/active', admin.mirrorSyncActive) +app.get('/api/admin/mirror/history', admin.mirrorHistory) // Query -app.get('/api/query/sqlite/:owner/:slug/:version', query.sqlite,) -app.get('/api/query/ddl/:owner/:slug/:version', query.ddl,) -app.post('/api/query/generate-sql', query.generateSql,) -app.get('/api/query/collections/search', query.searchCollections,) -app.get('/api/query/collections/:owner/:slug/versions', query.collectionVersions,) +app.get('/api/query/sqlite/:owner/:slug/:version', query.sqlite) +app.get('/api/query/ddl/:owner/:slug/:version', query.ddl) +app.post('/api/query/generate-sql', query.generateSql) +app.get('/api/query/collections/search', query.searchCollections) +app.get('/api/query/collections/:owner/:slug/versions', query.collectionVersions) // Schemas -app.get('/api/schemas', schemas.listSchemas,) -app.get('/api/schemas/:id', schemas.getSchema,) -app.get('/api/collections/:owner/:slug/schemas', schemas.collectionSchemas,) -app.post('/api/schemas/:id/labels', requireAuth('write',), schemas.addLabel,) -app.delete('/api/schemas/:id/labels/:label', requireAuth('admin',), schemas.removeLabel,) +app.get('/api/schemas', schemas.listSchemas) +app.get('/api/schemas/:id', schemas.getSchema) +app.get('/api/collections/:owner/:slug/schemas', schemas.collectionSchemas) +app.post('/api/schemas/:id/labels', requireAuth('write'), schemas.addLabel) +app.delete('/api/schemas/:id/labels/:label', requireAuth('admin'), schemas.removeLabel) // ARK -app.get('/api/ark/resolve', ark.resolve,) -app.get('/api/collections/:owner/:slug/ark', requireAuth('read',), ark.getArk,) -app.patch('/api/collections/:owner/:slug/ark', requireAuth('write',), ark.updateArk,) -app.get('/api/collections/:owner/:slug/ark/record-types', requireAuth('read',), ark.getArkRecordTypes,) -app.patch('/api/collections/:owner/:slug/ark/record-types', requireAuth('write',), ark.updateArkRecordTypes,) -app.patch('/api/accounts/:slug/ark', requireAuth('admin',), ark.updateAccountArk,) +app.get('/api/ark/resolve', ark.resolve) +app.get('/api/collections/:owner/:slug/ark', requireAuth('read'), ark.getArk) +app.patch('/api/collections/:owner/:slug/ark', requireAuth('write'), ark.updateArk) +app.get( + '/api/collections/:owner/:slug/ark/record-types', + requireAuth('read'), + ark.getArkRecordTypes, +) +app.patch( + '/api/collections/:owner/:slug/ark/record-types', + requireAuth('write'), + ark.updateArkRecordTypes, +) +app.patch('/api/accounts/:slug/ark', requireAuth('admin'), ark.updateAccountArk) // Collections -app.get('/api/collections', collections.list,) -app.post('/api/accounts/:owner/collections', requireAuth('write',), collections.create,) -app.get('/api/collections/:owner/:slug', collections.get,) -app.patch('/api/collections/:owner/:slug', requireAuth('write',), collections.update,) -app.delete('/api/collections/:owner/:slug', requireAuth('admin',), collections.remove,) -app.get('/api/accounts/:owner/collections', collections.listByOwner,) -app.get('/api/collections/:owner/:slug/export', collections.exportArchive,) +app.get('/api/collections', collections.list) +app.post('/api/accounts/:owner/collections', requireAuth('write'), collections.create) +app.get('/api/collections/:owner/:slug', collections.get) +app.patch('/api/collections/:owner/:slug', requireAuth('write'), collections.update) +app.delete('/api/collections/:owner/:slug', requireAuth('admin'), collections.remove) +app.post('/api/collections/:owner/:slug/transfer', requireAuth(), collections.transfer) +app.get('/api/accounts/:owner/collections', collections.listByOwner) +app.get('/api/collections/:owner/:slug/export', collections.exportArchive) // Files -app.on('HEAD', '/api/collections/:owner/:slug/files/:hash', files.headFile,) -app.get('/api/collections/:owner/:slug/files/:hash', files.getFile,) -app.put('/api/collections/:owner/:slug/files/:hash', requireAuth('write',), files.putFile,) +app.on('HEAD', '/api/collections/:owner/:slug/files/:hash', files.headFile) +app.get('/api/collections/:owner/:slug/files/:hash', files.getFile) +app.put('/api/collections/:owner/:slug/files/:hash', requireAuth('write'), files.putFile) // Uploads -app.post('/api/collections/:owner/:slug/versions/upload', requireAuth('write',), uploads.startSession,) -app.put('/api/collections/:owner/:slug/versions/upload/:sessionId', requireAuth('write',), uploads.appendBatch,) -app.get('/api/collections/:owner/:slug/versions/upload/:sessionId', requireAuth('read',), uploads.getSession,) -app.post('/api/collections/:owner/:slug/versions/upload/:sessionId/finalize', requireAuth('write',), uploads.finalize,) -app.delete('/api/collections/:owner/:slug/versions/upload/:sessionId', requireAuth('write',), uploads.cancelSession,) +app.post( + '/api/collections/:owner/:slug/versions/upload', + requireAuth('write'), + uploads.startSession, +) +app.put( + '/api/collections/:owner/:slug/versions/upload/:sessionId', + requireAuth('write'), + uploads.appendBatch, +) +app.get( + '/api/collections/:owner/:slug/versions/upload/:sessionId', + requireAuth('read'), + uploads.getSession, +) +app.post( + '/api/collections/:owner/:slug/versions/upload/:sessionId/finalize', + requireAuth('write'), + uploads.finalize, +) +app.delete( + '/api/collections/:owner/:slug/versions/upload/:sessionId', + requireAuth('write'), + uploads.cancelSession, +) // Versions -app.get('/api/collections/:owner/:slug/versions', versions.list,) -app.get('/api/collections/:owner/:slug/versions/latest', versions.latest,) -app.get('/api/collections/:owner/:slug/versions/:n', versions.getByNumber,) -app.get('/api/collections/:owner/:slug/versions/:n/records', versions.records,) -app.get('/api/collections/:owner/:slug/versions/:n/files', versions.files,) -app.get('/api/collections/:owner/:slug/versions/:n/manifest', versions.manifest,) -app.post('/api/collections/:owner/:slug/versions', requireAuth('write',), versions.push,) -app.get('/api/collections/:owner/:slug/versions/:n/diff', versions.diff,) +app.get('/api/collections/:owner/:slug/versions', versions.list) +app.get('/api/collections/:owner/:slug/versions/latest', versions.latest) +app.get('/api/collections/:owner/:slug/versions/:n', versions.getByNumber) +app.get('/api/collections/:owner/:slug/versions/:n/records', versions.records) +app.get('/api/collections/:owner/:slug/versions/:n/files', versions.files) +app.get('/api/collections/:owner/:slug/versions/:n/manifest', versions.manifest) +app.post('/api/collections/:owner/:slug/versions', requireAuth('write'), versions.push) +app.get('/api/collections/:owner/:slug/versions/:n/diff', versions.diff) // Accounts -app.post('/api/accounts/signup', accounts.signup,) -app.post('/api/accounts/login', accounts.login,) -app.post('/api/accounts/logout', accounts.logout,) -app.get('/api/accounts/me', requireAuth(), accounts.getMe,) -app.get('/api/accounts/:slug', accounts.getBySlug,) -app.patch('/api/accounts/me', requireAuth(), accounts.updateMe,) -app.post('/api/accounts/me/email', requireAuth(), accounts.updateEmail,) -app.post('/api/accounts/me/password', requireAuth(), accounts.updatePassword,) -app.post('/api/accounts/me/avatar', requireAuth(), accounts.uploadAvatar,) -app.get('/api/accounts/me/sessions', requireAuth(), accounts.listSessions,) -app.delete('/api/accounts/me/sessions/:sessionId', requireAuth(), accounts.deleteSession,) -app.delete('/api/accounts/me', requireAuth(), accounts.deleteMe,) -app.post('/api/accounts/forgot-password', accounts.forgotPassword,) -app.post('/api/accounts/reset-password', accounts.resetPassword,) -app.post('/api/accounts/keys', requireAuth(), accounts.createKey,) -app.get('/api/accounts/keys', requireAuth(), accounts.listKeys,) -app.delete('/api/accounts/keys/:id', requireAuth(), accounts.deleteKey,) -app.post('/api/accounts/:slug/keys', requireAuth(), accounts.createOrgKey,) -app.get('/api/accounts/:slug/keys', requireAuth(), accounts.listOrgKeys,) -app.delete('/api/accounts/:slug/keys/:id', requireAuth(), accounts.deleteOrgKey,) -app.post('/api/accounts/orgs', requireAuth(), accounts.createOrg,) -app.get('/api/accounts/:slug/members', requireAuth(), accounts.listMembers,) -app.post('/api/accounts/:slug/members', requireAuth(), accounts.addMember,) -app.patch('/api/accounts/:slug/members/:userId', requireAuth(), accounts.updateMember,) -app.delete('/api/accounts/:slug/members/:userId', requireAuth(), accounts.removeMember,) -app.patch('/api/accounts/:slug', requireAuth(), accounts.updateOrg,) -app.post('/api/accounts/:slug/avatar', requireAuth(), accounts.uploadOrgAvatar,) -app.post('/api/accounts/:slug/invitations', requireAuth(), accounts.createInvitation,) -app.get('/api/accounts/:slug/invitations', requireAuth(), accounts.listInvitations,) -app.delete('/api/accounts/:slug/invitations/:id', requireAuth(), accounts.deleteInvitation,) -app.post('/api/accounts/invitations/accept', requireAuth(), accounts.acceptInvitation,) -app.delete('/api/accounts/:slug', requireAuth(), accounts.deleteOrg,) +app.get('/api/accounts/me', requireAuth(), accounts.getMe) +app.get('/api/accounts/available-kf-orgs', requireAuth(), accounts.availableKfOrgs) +app.get('/api/accounts/:slug', accounts.getBySlug) +app.patch('/api/accounts/me', requireAuth(), accounts.updateMe) +app.get('/api/accounts/me/sessions', requireAuth(), accounts.listSessions) +app.delete('/api/accounts/me/sessions/:sessionId', requireAuth(), accounts.deleteSession) +app.delete('/api/accounts/me', requireAuth(), accounts.deleteMe) +app.post('/api/accounts/keys', requireAuth(), accounts.createKey) +app.get('/api/accounts/keys', requireAuth(), accounts.listKeys) +app.delete('/api/accounts/keys/:id', requireAuth(), accounts.deleteKey) +app.post('/api/accounts/:slug/keys', requireAuth(), accounts.createOrgKey) +app.get('/api/accounts/:slug/keys', requireAuth(), accounts.listOrgKeys) +app.delete('/api/accounts/:slug/keys/:id', requireAuth(), accounts.deleteOrgKey) +app.post('/api/accounts/orgs', requireAuth(), accounts.createOrg) +app.get('/api/accounts/:slug/members', requireAuth(), accounts.listMembers) +app.post('/api/accounts/:slug/members', requireAuth(), accounts.addMember) +app.patch('/api/accounts/:slug/members/:userId', requireAuth(), accounts.updateMember) +app.delete('/api/accounts/:slug/members/:userId', requireAuth(), accounts.removeMember) +app.patch('/api/accounts/:slug', requireAuth(), accounts.updateOrg) +app.post('/api/accounts/:slug/avatar', requireAuth(), accounts.uploadOrgAvatar) +app.post('/api/accounts/:slug/invitations', requireAuth(), accounts.createInvitation) +app.get('/api/accounts/:slug/invitations', requireAuth(), accounts.listInvitations) +app.delete('/api/accounts/:slug/invitations/:id', requireAuth(), accounts.deleteInvitation) +app.post('/api/accounts/invitations/accept', requireAuth(), accounts.acceptInvitation) +app.delete('/api/accounts/:slug', requireAuth(), accounts.deleteOrg) // --- Blog content API (serves rendered markdown) --- -app.get('/api/blog/:slug', (c,) => { - const slug = c.req.param('slug',) - const mdPath = resolve('content/blog', `${slug}.md`,) - if (!existsSync(mdPath,)) { - return c.json({ error: 'Not found', }, 404,) +app.get('/api/blog/:slug', (c) => { + const slug = c.req.param('slug') + const mdPath = resolve('content/blog', `${slug}.md`) + if (!existsSync(mdPath)) { + return c.json({ error: 'Not found' }, 404) } - const raw = readFileSync(mdPath, 'utf-8',) + const raw = readFileSync(mdPath, 'utf-8') // Strip frontmatter - const fmEnd = raw.indexOf('---', 4,) - const body = fmEnd > 0 ? raw.slice(fmEnd + 3,).trim() : raw - const html = marked(body,) - return c.html(typeof html === 'string' ? html : '',) -},) + const fmEnd = raw.indexOf('---', 4) + const body = fmEnd > 0 ? raw.slice(fmEnd + 3).trim() : raw + const html = marked(body) + return c.html(typeof html === 'string' ? html : '') +}) // API 404 catch-all -app.all('/api/*', (c,) => { - return c.json({ error: 'API route not found', statusCode: 404, }, 404,) -},) +app.all('/api/*', (c) => { + return c.json({ error: 'API route not found', statusCode: 404 }, 404) +}) // --- SSR --- if (isProd) { // Verify SSR build artifacts exist at startup (fail fast, don't wait for first request) - const clientHtml = resolve('dist/client/index.html',) - const ssrBundle = resolve('dist/server/entry-server.js',) - if (!existsSync(clientHtml,)) throw new Error(`Missing ${clientHtml} — did 'pnpm build' run?`,) - if (!existsSync(ssrBundle,)) throw new Error(`Missing ${ssrBundle} — did 'pnpm build' run?`,) + const clientHtml = resolve('dist/client/index.html') + const ssrBundle = resolve('dist/server/entry-server.js') + if (!existsSync(clientHtml)) throw new Error(`Missing ${clientHtml} — did 'pnpm build' run?`) + if (!existsSync(ssrBundle)) throw new Error(`Missing ${ssrBundle} — did 'pnpm build' run?`) // Serve Vite build assets (hashed JS/CSS bundles) - app.use('/assets/*', serveStatic({ root: './dist/client', },),) + app.use('/assets/*', serveStatic({ root: './dist/client' })) // Serve public/ folder files (favicon, wasm, .well-known, etc.) - app.use('/*', serveStatic({ root: './public', },),) + app.use('/*', serveStatic({ root: './public' })) // Run migrations on startup - const { runMigrations, } = await import('~/db/migrate') + const { runMigrations } = await import('~/db/migrate') await runMigrations() - const template = readFileSync(clientHtml, 'utf-8',) - const { render, } = await import(ssrBundle as string) + const template = readFileSync(clientHtml, 'utf-8') + const { render } = await import(ssrBundle as string) - app.get('*', async (c,) => { - const { html, ssrData, redirect, statusCode, title, description, } = await render(c.req.raw,) + app.get('*', async (c) => { + const { html, ssrData, redirect, statusCode, title, description } = await render(c.req.raw) if (redirect) { - return c.redirect(redirect, 302,) + return c.redirect(redirect, 302) } let page = template - .replace('', html,) + .replace('', html) .replace( '', - ``, + ``, ) if (title) { - page = page.replace('Underlay', `${title}`,) + page = page.replace('Underlay', `${title}`) } if (description) { page = page.replace( '', - `\n`, + `\n`, ) } - return c.html(page, statusCode ?? 200,) - },) + return c.html(page, statusCode ?? 200) + }) } else { - const { createServer: createViteServer, } = await import('vite') + const { createServer: createViteServer } = await import('vite') const vite = await createViteServer({ - server: { middlewareMode: true, }, + server: { middlewareMode: true }, appType: 'custom', - },) + }) // Vite's Connect middleware for HMR and asset transforms - app.use('*', async (c, next,) => { + app.use('*', async (c, next) => { const nodeReq = (c.env as any).incoming const nodeRes = (c.env as any).outgoing if (!nodeReq || !nodeRes) return next() - return new Promise((resolve,) => { - vite.middlewares(nodeReq, nodeRes, () => resolve(next(),),) - },) - },) + return new Promise((resolve) => { + vite.middlewares(nodeReq, nodeRes, () => resolve(next())) + }) + }) - app.get('*', async (c,) => { + app.get('*', async (c) => { const url = c.req.url - let template = readFileSync(resolve('index.html',), 'utf-8',) - template = await vite.transformIndexHtml(url, template,) + let template = readFileSync(resolve('index.html'), 'utf-8') + template = await vite.transformIndexHtml(url, template) - const { render, } = await vite.ssrLoadModule('/src/entry-server.tsx',) - const { html, ssrData, redirect, statusCode, title, description, } = await render(c.req.raw,) + const { render } = await vite.ssrLoadModule('/src/entry-server.tsx') + const { html, ssrData, redirect, statusCode, title, description } = await render(c.req.raw) if (redirect) { - return c.redirect(redirect, 302,) + return c.redirect(redirect, 302) } let page = template - .replace('', html,) + .replace('', html) .replace( '', - ``, + ``, ) if (title) { - page = page.replace('Underlay', `${title}`,) + page = page.replace('Underlay', `${title}`) } if (description) { page = page.replace( '', - `\n`, + `\n`, ) } - return c.html(page, statusCode ?? 200,) - },) + return c.html(page, statusCode ?? 200) + }) } -const port = Number(process.env.PORT,) || 3000 -console.log(`Server running at http://localhost:${port}`,) -serve({ fetch: app.fetch, port, },) +const port = Number(process.env.PORT) || 3000 +console.log(`Server running at http://localhost:${port}`) +serve({ fetch: app.fetch, port }) diff --git a/src/App.tsx b/src/App.tsx index 4923fc0..db229fd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,29 +1,25 @@ -import { lazy, Suspense, } from 'react' -import { Route, Routes, } from 'react-router' -import { AppErrorBoundary, } from '~/components/NotFound' -import { buildRoutes, } from '~/route-gen' +import { lazy, Suspense } from 'react' +import { Route, Routes } from 'react-router' -const modules = import.meta.glob<{ default: React.ComponentType }>('./routes/**/[!_]*.tsx',) -const routes = buildRoutes(modules,) +import { AppErrorBoundary } from '~/components/NotFound' +import { buildRoutes } from '~/route-gen' -const componentMap = new Map( - routes.map((r,) => [ - r.path, - lazy(modules[r.filePath]!,), - ]), -) +const modules = import.meta.glob<{ default: React.ComponentType }>('./routes/**/[!_]*.tsx') +const routes = buildRoutes(modules) -export { routes, } +const componentMap = new Map(routes.map((r) => [r.path, lazy(modules[r.filePath]!)])) + +export { routes } export default function App() { return ( - {routes.map((r,) => { - const Page = componentMap.get(r.path,) + {routes.map((r) => { + const Page = componentMap.get(r.path) return Page ? } /> : null - },)} + })} diff --git a/src/api/accounts.ts b/src/api/accounts.ts index eab812e..03a231c 100644 --- a/src/api/accounts.ts +++ b/src/api/accounts.ts @@ -1,12 +1,13 @@ import bcrypt from 'bcrypt' -import { and, count, eq, } from 'drizzle-orm' -import type { Context, } from 'hono' -import { getCookie, } from 'hono/cookie' -import { v4 as uuidv4, } from 'uuid' -import { db, schema, } from '../db/client.server.js' -import { sendEmail, } from '../lib/email.js' -import { deleteS3Objects, listS3Objects, uploadToS3, } from '../lib/s3.js' -import { type AuthEnv, clearSessionCookie, setSessionCookie, } from './auth.server.js' +import { and, count, eq } from 'drizzle-orm' +import type { Context } from 'hono' +import { getCookie } from 'hono/cookie' +import { v4 as uuidv4 } from 'uuid' + +import { db, schema } from '../db/client.server.js' +import { sendEmail } from '../lib/email.js' +import { deleteS3Objects, listS3Objects, uploadToS3 } from '../lib/s3.js' +import { type AuthEnv, clearSessionCookie } from './auth.server.js' /** Base URL for public assets (avatars, etc.) */ const ASSETS_BASE_URL = process.env.ASSETS_BASE_URL ?? 'https://assets.underlay.org' @@ -32,120 +33,43 @@ const RESERVED_SLUGS = new Set([ 'delete', '404', '500', -],) +]) -// Signup -export async function signup(c: Context,) { - const { email, password, username, displayName, } = await c.req.json() +const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/ - if (RESERVED_SLUGS.has(username.toLowerCase(),)) { - return c.json({ error: 'That username is reserved', statusCode: 422, }, 422,) +/** Validate a slug and return an error message or null if valid. */ +function validateSlug(slug: string): string | null { + if (!slug || typeof slug !== 'string') return 'Slug is required' + if (slug.length < 2) return 'Slug must be at least 2 characters' + if (slug.length > 64) return 'Slug must be at most 64 characters' + if (!SLUG_RE.test(slug)) { + return 'Slug must be lowercase alphanumeric with hyphens, and cannot start or end with a hyphen' } - - const existing = await db - .select() - .from(schema.accounts,) - .where(eq(schema.accounts.slug, username,),) - .limit(1,) - - if (existing.length > 0) { - return c.json({ error: 'Username already taken', statusCode: 409, }, 409,) - } - - const passwordHash = await bcrypt.hash(password, 10,) - const id = uuidv4() - - await db.insert(schema.accounts,).values({ - id, - slug: username, - type: 'user', - displayName, - email, - passwordHash, - },) - - const sessionId = uuidv4() - const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000,) // 30 days - await db.insert(schema.sessions,).values({ - id: sessionId, - userId: id, - expiresAt, - userAgent: c.req.header('user-agent',) ?? null, - ipAddress: c.req.header('x-forwarded-for',) || 'unknown', - },) - - setSessionCookie(c, sessionId,) - - return c.json({ id, slug: username, displayName, }, 201,) -} - -// Login -export async function login(c: Context,) { - const { email, password, } = await c.req.json() - - const [account,] = await db - .select() - .from(schema.accounts,) - .where(eq(schema.accounts.email, email,),) - .limit(1,) - - if (!account?.passwordHash) { - return c.json({ error: 'Invalid credentials', statusCode: 401, }, 401,) - } - - const valid = await bcrypt.compare(password, account.passwordHash,) - if (!valid) { - return c.json({ error: 'Invalid credentials', statusCode: 401, }, 401,) - } - - const sessionId = uuidv4() - const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000,) - await db.insert(schema.sessions,).values({ - id: sessionId, - userId: account.id, - expiresAt, - userAgent: c.req.header('user-agent',) ?? null, - ipAddress: c.req.header('x-forwarded-for',) || 'unknown', - },) - - setSessionCookie(c, sessionId,) - - return c.json({ id: account.id, slug: account.slug, displayName: account.displayName, },) -} - -// Logout -export async function logout(c: Context,) { - const sessionId = getCookie(c, 'session',) - if (sessionId) { - await db.delete(schema.sessions,).where(eq(schema.sessions.id, sessionId,),) - } - clearSessionCookie(c,) - return c.json({ ok: true, },) + if (RESERVED_SLUGS.has(slug)) return 'That slug is reserved' + return null } // Get current user -export async function getMe(c: Context,) { - const [account,] = await db +export async function getMe(c: Context) { + const [account] = await db .select({ id: schema.accounts.id, slug: schema.accounts.slug, type: schema.accounts.type, displayName: schema.accounts.displayName, - email: schema.accounts.email, bio: schema.accounts.bio, website: schema.accounts.website, location: schema.accounts.location, avatarUrl: schema.accounts.avatarUrl, - emailVerified: schema.accounts.emailVerified, notificationPrefs: schema.accounts.notificationPrefs, createdAt: schema.accounts.createdAt, - },) - .from(schema.accounts,) - .where(eq(schema.accounts.id, c.get('accountId',)!,),) - .limit(1,) + }) + .from(schema.accounts) + .where(eq(schema.accounts.id, c.get('accountId')!)) + .limit(1) if (!account) { - return c.json({ error: 'Account not found', statusCode: 404, }, 404,) + return c.json({ error: 'Account not found', statusCode: 404 }, 404) } // Fetch org memberships @@ -155,18 +79,18 @@ export async function getMe(c: Context,) { role: schema.orgMemberships.role, slug: schema.accounts.slug, displayName: schema.accounts.displayName, - },) - .from(schema.orgMemberships,) - .innerJoin(schema.accounts, eq(schema.orgMemberships.orgId, schema.accounts.id,),) - .where(eq(schema.orgMemberships.userId, account.id,),) + }) + .from(schema.orgMemberships) + .innerJoin(schema.accounts, eq(schema.orgMemberships.orgId, schema.accounts.id)) + .where(eq(schema.orgMemberships.userId, account.id)) - return c.json({ ...account, orgs: memberships, },) + return c.json({ ...account, orgs: memberships }) } // Get account by slug (public) -export async function getBySlug(c: Context,) { - const slug = c.req.param('slug',)! - const [account,] = await db +export async function getBySlug(c: Context) { + const slug = c.req.param('slug')! + const [account] = await db .select({ id: schema.accounts.id, slug: schema.accounts.slug, @@ -177,148 +101,100 @@ export async function getBySlug(c: Context,) { location: schema.accounts.location, avatarUrl: schema.accounts.avatarUrl, arkNaan: schema.accounts.arkNaan, + kfOrgId: schema.accounts.kfOrgId, createdAt: schema.accounts.createdAt, - },) - .from(schema.accounts,) - .where(eq(schema.accounts.slug, slug,),) - .limit(1,) + }) + .from(schema.accounts) + .where(eq(schema.accounts.slug, slug)) + .limit(1) if (!account) { - return c.json({ error: 'Account not found', statusCode: 404, }, 404,) + return c.json({ error: 'Account not found', statusCode: 404 }, 404) } // Include ARK shoulder if minted - const [shoulderRow,] = await db - .select({ shoulder: schema.arkShoulders.shoulder, },) - .from(schema.arkShoulders,) - .where(eq(schema.arkShoulders.accountId, account.id,),) - .limit(1,) + const [shoulderRow] = await db + .select({ shoulder: schema.arkShoulders.shoulder }) + .from(schema.arkShoulders) + .where(eq(schema.arkShoulders.accountId, account.id)) + .limit(1) - return c.json({ ...account, arkShoulder: shoulderRow?.shoulder ?? null, },) + return c.json({ ...account, arkShoulder: shoulderRow?.shoulder ?? null }) } // Update own profile -export async function updateMe(c: Context,) { - const { displayName, bio, website, location, notificationPrefs, } = await c.req.json() +export async function updateMe(c: Context) { + // Name, email, and avatar are managed by KF Auth — only Underlay-specific fields are writable here. + const { slug, bio, website, location, notificationPrefs } = await c.req.json() - const updates: Record = {} - if (displayName !== undefined) updates.displayName = displayName - if (bio !== undefined) updates.bio = bio - if (website !== undefined) updates.website = website - if (location !== undefined) updates.location = location - if (notificationPrefs !== undefined) updates.notificationPrefs = notificationPrefs - - if (Object.keys(updates,).length > 0) { - await db.update(schema.accounts,).set(updates,).where(eq(schema.accounts.id, c.get('accountId',)!,),) - } - - return c.json({ ok: true, },) -} - -// Change email (requires current password) -export async function updateEmail(c: Context,) { - const { newEmail, password, } = await c.req.json() - - const [account,] = await db - .select() - .from(schema.accounts,) - .where(eq(schema.accounts.id, c.get('accountId',)!,),) - .limit(1,) - - if (!account?.passwordHash) { - return c.json({ error: 'Cannot change email for this account type', statusCode: 400, }, 400,) - } - - const valid = await bcrypt.compare(password, account.passwordHash,) - if (!valid) { - return c.json({ error: 'Invalid password', statusCode: 401, }, 401,) - } - - // Check email not taken - const [existing,] = await db - .select() - .from(schema.accounts,) - .where(eq(schema.accounts.email, newEmail,),) - .limit(1,) - - if (existing && existing.id !== account.id) { - return c.json({ error: 'Email already in use', statusCode: 409, }, 409,) - } + const accountId = c.get('accountId')! - await db - .update(schema.accounts,) - .set({ email: newEmail, emailVerified: false, },) - .where(eq(schema.accounts.id, c.get('accountId',)!,),) - - return c.json({ ok: true, },) -} + if (slug !== undefined) { + const slugErr = validateSlug(slug) + if (slugErr) return c.json({ error: slugErr, statusCode: 422 }, 422) -// Change password -export async function updatePassword(c: Context,) { - const { currentPassword, newPassword, } = await c.req.json() + const [existing] = await db + .select({ id: schema.accounts.id }) + .from(schema.accounts) + .where(eq(schema.accounts.slug, slug)) + .limit(1) - if (newPassword.length < 8) { - return c.json({ error: 'Password must be at least 8 characters', statusCode: 422, }, 422,) + if (existing && existing.id !== accountId) { + return c.json({ error: 'That slug is already taken', statusCode: 409 }, 409) + } } - const [account,] = await db - .select() - .from(schema.accounts,) - .where(eq(schema.accounts.id, c.get('accountId',)!,),) - .limit(1,) - - if (!account?.passwordHash) { - return c.json({ error: 'Cannot change password for this account type', statusCode: 400, }, 400,) - } + const updates: Record = {} + if (slug !== undefined) updates.slug = slug + if (bio !== undefined) updates.bio = bio + if (website !== undefined) updates.website = website + if (location !== undefined) updates.location = location + if (notificationPrefs !== undefined) updates.notificationPrefs = notificationPrefs - const valid = await bcrypt.compare(currentPassword, account.passwordHash,) - if (!valid) { - return c.json({ error: 'Current password is incorrect', statusCode: 401, }, 401,) + if (Object.keys(updates).length > 0) { + await db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, accountId)) } - const newHash = await bcrypt.hash(newPassword, 10,) - await db - .update(schema.accounts,) - .set({ passwordHash: newHash, },) - .where(eq(schema.accounts.id, c.get('accountId',)!,),) - - return c.json({ ok: true, },) + return c.json({ ok: true, slug: slug ?? undefined }) } // Upload avatar -export async function uploadAvatar(c: Context,) { +export async function uploadAvatar(c: Context) { const body = await c.req.parseBody() - const file = Object.values(body,).find((v,): v is File => v instanceof File) + const file = Object.values(body).find((v): v is File => v instanceof File) if (!file) { - return c.json({ error: 'No file uploaded', statusCode: 400, }, 400,) + return c.json({ error: 'No file uploaded', statusCode: 400 }, 400) } - const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp',] - if (!allowedTypes.includes(file.type,)) { - return c.json({ error: 'Only JPEG, PNG, GIF, and WebP images are allowed', statusCode: 422, }, 422,) + const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] + if (!allowedTypes.includes(file.type)) { + return c.json( + { error: 'Only JPEG, PNG, GIF, and WebP images are allowed', statusCode: 422 }, + 422, + ) } - const buffer = Buffer.from(await file.arrayBuffer(),) + const buffer = Buffer.from(await file.arrayBuffer()) if (buffer.length > 5 * 1024 * 1024) { - return c.json({ error: 'Image must be less than 5MB', statusCode: 422, }, 422,) + return c.json({ error: 'Image must be less than 5MB', statusCode: 422 }, 422) } - const ext = file.type.split('/',)[1] === 'jpeg' ? 'jpg' : file.type.split('/',)[1] - const accountId = c.get('accountId',)! + const ext = file.type.split('/')[1] === 'jpeg' ? 'jpg' : file.type.split('/')[1] + const accountId = c.get('accountId')! const key = `avatars/${accountId}/${Date.now()}.${ext}` - await uploadToS3(key, buffer, file.type,) + await uploadToS3(key, buffer, file.type) await db - .update(schema.accounts,) - .set({ avatarUrl: `${ASSETS_BASE_URL}/${key}`, },) - .where(eq(schema.accounts.id, accountId,),) + .update(schema.accounts) + .set({ avatarUrl: `${ASSETS_BASE_URL}/${key}` }) + .where(eq(schema.accounts.id, accountId)) - return c.json({ ok: true, avatarUrl: `${ASSETS_BASE_URL}/${key}`, },) + return c.json({ ok: true, avatarUrl: `${ASSETS_BASE_URL}/${key}` }) } // List sessions -export async function listSessions(c: Context,) { +export async function listSessions(c: Context) { const sessions = await db .select({ id: schema.sessions.id, @@ -326,217 +202,127 @@ export async function listSessions(c: Context,) { ipAddress: schema.sessions.ipAddress, createdAt: schema.sessions.createdAt, expiresAt: schema.sessions.expiresAt, - },) - .from(schema.sessions,) - .where(eq(schema.sessions.userId, c.get('accountId',)!,),) + }) + .from(schema.sessions) + .where(eq(schema.sessions.userId, c.get('accountId')!)) // Get current session ID to mark it - const currentSessionId = getCookie(c, 'session',) - return c.json(sessions.map((s,) => ({ - ...s, - current: s.id === currentSessionId, - })),) + const currentSessionId = getCookie(c, 'session') + return c.json( + sessions.map((s) => ({ + ...s, + current: s.id === currentSessionId, + })), + ) } // Revoke a session -export async function deleteSession(c: Context,) { - const sessionId = c.req.param('sessionId',)! +export async function deleteSession(c: Context) { + const sessionId = c.req.param('sessionId')! - const [session,] = await db + const [session] = await db .select() - .from(schema.sessions,) - .where(and(eq(schema.sessions.id, sessionId,), eq(schema.sessions.userId, c.get('accountId',)!,),),) - .limit(1,) + .from(schema.sessions) + .where(and(eq(schema.sessions.id, sessionId), eq(schema.sessions.userId, c.get('accountId')!))) + .limit(1) if (!session) { - return c.json({ error: 'Session not found', statusCode: 404, }, 404,) + return c.json({ error: 'Session not found', statusCode: 404 }, 404) } - await db.delete(schema.sessions,).where(eq(schema.sessions.id, sessionId,),) - return c.json({ ok: true, },) + await db.delete(schema.sessions).where(eq(schema.sessions.id, sessionId)) + return c.json({ ok: true }) } // Delete own account -export async function deleteMe(c: Context,) { - const { password, confirmSlug, } = await c.req.json() +export async function deleteMe(c: Context) { + const { confirmSlug } = await c.req.json() - const [account,] = await db + const [account] = await db .select() - .from(schema.accounts,) - .where(eq(schema.accounts.id, c.get('accountId',)!,),) - .limit(1,) + .from(schema.accounts) + .where(eq(schema.accounts.id, c.get('accountId')!)) + .limit(1) - if (!account?.passwordHash) { - return c.json({ error: 'Cannot delete this account type', statusCode: 400, }, 400,) + if (!account) { + return c.json({ error: 'Account not found', statusCode: 404 }, 404) } if (confirmSlug !== account.slug) { - return c.json({ error: 'Username confirmation does not match', statusCode: 422, }, 422,) - } - - const valid = await bcrypt.compare(password, account.passwordHash,) - if (!valid) { - return c.json({ error: 'Invalid password', statusCode: 401, }, 401,) + return c.json({ error: 'Username confirmation does not match', statusCode: 422 }, 422) } // Check for owned collections - const [collCount,] = await db - .select({ count: count(), },) - .from(schema.collections,) - .where(eq(schema.collections.accountId, account.id,),) + const [collCount] = await db + .select({ count: count() }) + .from(schema.collections) + .where(eq(schema.collections.accountId, account.id)) if (collCount && collCount.count > 0) { - return c.json({ - error: `You still own ${collCount.count} collection(s). Transfer or delete them before deleting your account.`, - statusCode: 422, - }, 422,) + return c.json( + { + error: `You still own ${collCount.count} collection(s). Transfer or delete them before deleting your account.`, + statusCode: 422, + }, + 422, + ) } // Clean up S3 avatars try { - const avatarKeys = await listS3Objects(`avatars/${account.id}/`,) + const avatarKeys = await listS3Objects(`avatars/${account.id}/`) if (avatarKeys.length > 0) { - await deleteS3Objects(avatarKeys,) + await deleteS3Objects(avatarKeys) } } catch { // Non-fatal: avatar cleanup failed } // Cascade will handle sessions, memberships, api keys - await db.delete(schema.accounts,).where(eq(schema.accounts.id, account.id,),) - clearSessionCookie(c,) - return c.json({ ok: true, },) -} - -// --- Forgot Password --- -export async function forgotPassword(c: Context,) { - const { email, } = await c.req.json() - - const [account,] = await db - .select() - .from(schema.accounts,) - .where(eq(schema.accounts.email, email,),) - .limit(1,) - - // Always return success to prevent email enumeration - if (!account) { - return c.json({ ok: true, },) - } - - const rawToken = uuidv4() - const tokenHash = await bcrypt.hash(rawToken, 10,) - const expiresAt = new Date(Date.now() + 60 * 60 * 1000,) // 1 hour - - await db.insert(schema.passwordResetTokens,).values({ - userId: account.id, - tokenHash, - expiresAt, - },) - - // Send email (no-op if SMTP not configured) - const origin = new URL(c.req.url,).origin - const resetUrl = `${origin}/reset-password?token=${rawToken}&email=${encodeURIComponent(email,)}` - await sendEmail({ - to: email, - subject: 'Reset your Underlay password', - text: - `Click here to reset your password: ${resetUrl}\n\nThis link expires in 1 hour. If you didn't request this, ignore this email.`, - html: - `

Click here to reset your password.

This link expires in 1 hour. If you didn't request this, ignore this email.

`, - },) - - return c.json({ ok: true, },) -} - -// --- Reset Password --- -export async function resetPassword(c: Context,) { - const { email, token, newPassword, } = await c.req.json() - - if (newPassword.length < 8) { - return c.json({ error: 'Password must be at least 8 characters', statusCode: 422, }, 422,) - } - - const [account,] = await db - .select() - .from(schema.accounts,) - .where(eq(schema.accounts.email, email,),) - .limit(1,) - - if (!account) { - return c.json({ error: 'Invalid or expired reset link', statusCode: 400, }, 400,) - } - - // Find valid unused tokens for this user - const tokens = await db - .select() - .from(schema.passwordResetTokens,) - .where(and( - eq(schema.passwordResetTokens.userId, account.id,), - ),) - - let validToken = null - for (const t of tokens) { - if (t.usedAt) continue - if (new Date(t.expiresAt,) < new Date()) continue - const match = await bcrypt.compare(token, t.tokenHash,) - if (match) { - validToken = t - break - } - } - - if (!validToken) { - return c.json({ error: 'Invalid or expired reset link', statusCode: 400, }, 400,) - } - - const newHash = await bcrypt.hash(newPassword, 10,) - await db.update(schema.accounts,).set({ passwordHash: newHash, },).where(eq(schema.accounts.id, account.id,),) - await db - .update(schema.passwordResetTokens,) - .set({ usedAt: new Date(), },) - .where(eq(schema.passwordResetTokens.id, validToken.id,),) - - return c.json({ ok: true, },) + await db.delete(schema.accounts).where(eq(schema.accounts.id, account.id)) + clearSessionCookie(c) + return c.json({ ok: true }) } // Create API key -export async function createKey(c: Context,) { - const { label, scope, collectionId, expiresIn, } = await c.req.json() +export async function createKey(c: Context) { + const { label, scope, collectionId, expiresIn } = await c.req.json() - const rawKey = `ul_${uuidv4().replace(/-/g, '',)}` - const keyHash = await bcrypt.hash(rawKey, 10,) - const keyPrefix = rawKey.slice(0, 12,) + const rawKey = `ul_${uuidv4().replace(/-/g, '')}` + const keyHash = await bcrypt.hash(rawKey, 10) + const keyPrefix = rawKey.slice(0, 12) - const expiresAt = expiresIn - ? new Date(Date.now() + expiresIn * 24 * 60 * 60 * 1000,) - : null + const expiresAt = expiresIn ? new Date(Date.now() + expiresIn * 24 * 60 * 60 * 1000) : null - const [key,] = await db - .insert(schema.apiKeys,) + const [key] = await db + .insert(schema.apiKeys) .values({ - accountId: c.get('accountId',)!, + accountId: c.get('accountId')!, scope, keyHash, keyPrefix, label, collectionId: collectionId ?? null, expiresAt, - },) + }) .returning() - return c.json({ - id: key!.id, - key: rawKey, // shown once - label, - scope, - keyPrefix, - collectionId: collectionId ?? null, - expiresAt, - }, 201,) + return c.json( + { + id: key!.id, + key: rawKey, // shown once + label, + scope, + keyPrefix, + collectionId: collectionId ?? null, + expiresAt, + }, + 201, + ) } // List API keys -export async function listKeys(c: Context,) { +export async function listKeys(c: Context) { const keys = await db .select({ id: schema.apiKeys.id, @@ -547,65 +333,67 @@ export async function listKeys(c: Context,) { expiresAt: schema.apiKeys.expiresAt, createdAt: schema.apiKeys.createdAt, lastUsedAt: schema.apiKeys.lastUsedAt, - },) - .from(schema.apiKeys,) - .where(eq(schema.apiKeys.accountId, c.get('accountId',)!,),) - return c.json(keys,) + }) + .from(schema.apiKeys) + .where(eq(schema.apiKeys.accountId, c.get('accountId')!)) + return c.json(keys) } // Delete API key -export async function deleteKey(c: Context,) { - const id = c.req.param('id',)! - const [key,] = await db - .select() - .from(schema.apiKeys,) - .where(eq(schema.apiKeys.id, id,),) - .limit(1,) +export async function deleteKey(c: Context) { + const id = c.req.param('id')! + const [key] = await db.select().from(schema.apiKeys).where(eq(schema.apiKeys.id, id)).limit(1) - if (!key || key.accountId !== c.get('accountId',)) { - return c.json({ error: 'Key not found', statusCode: 404, }, 404,) + if (!key || key.accountId !== c.get('accountId')) { + return c.json({ error: 'Key not found', statusCode: 404 }, 404) } - await db.delete(schema.apiKeys,).where(eq(schema.apiKeys.id, id,),) - return c.json({ ok: true, },) + await db.delete(schema.apiKeys).where(eq(schema.apiKeys.id, id)) + return c.json({ ok: true }) } // --- Org-scoped API Keys --- // Create API key for an org -export async function createOrgKey(c: Context,) { - const slug = c.req.param('slug',)! - const { label, scope, collectionId, expiresIn, } = await c.req.json() +export async function createOrgKey(c: Context) { + const slug = c.req.param('slug')! + const { label, scope, collectionId, expiresIn } = await c.req.json() - const [org,] = await db + const [org] = await db .select() - .from(schema.accounts,) - .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) - .limit(1,) + .from(schema.accounts) + .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, 'org'))) + .limit(1) - if (!org) return c.json({ error: 'Organization not found', statusCode: 404, }, 404,) + if (!org) return c.json({ error: 'Organization not found', statusCode: 404 }, 404) // Must be owner or admin - const [membership,] = await db + const [membership] = await db .select() - .from(schema.orgMemberships,) - .where(and(eq(schema.orgMemberships.orgId, org.id,), eq(schema.orgMemberships.userId, c.get('accountId',)!,),),) - .limit(1,) + .from(schema.orgMemberships) + .where( + and( + eq(schema.orgMemberships.orgId, org.id), + eq(schema.orgMemberships.userId, c.get('accountId')!), + ), + ) + .limit(1) if (!membership || membership.role === 'member') { - return c.json({ error: 'Must be an owner or admin to manage org API keys', statusCode: 403, }, 403,) + return c.json( + { error: 'Must be an owner or admin to manage org API keys', statusCode: 403 }, + 403, + ) } - const rawKey = `ul_${uuidv4().replace(/-/g, '',)}` - const keyHash = await bcrypt.hash(rawKey, 10,) - const keyPrefix = rawKey.slice(0, 12,) + const rawKey = `ul_${uuidv4().replace(/-/g, '')}` + const keyHash = await bcrypt.hash(rawKey, 10) + const keyPrefix = rawKey.slice(0, 12) - const expiresAt = expiresIn - ? new Date(Date.now() + expiresIn * 24 * 60 * 60 * 1000,) - : null + const expiresAt = expiresIn ? new Date(Date.now() + expiresIn * 24 * 60 * 60 * 1000) : null - const [key,] = await db - .insert(schema.apiKeys,) + const [key] = await db + .insert(schema.apiKeys) .values({ accountId: org.id, scope, @@ -614,40 +402,48 @@ export async function createOrgKey(c: Context,) { label, collectionId: collectionId ?? null, expiresAt, - },) + }) .returning() - return c.json({ - id: key!.id, - key: rawKey, - label, - scope, - keyPrefix, - collectionId: collectionId ?? null, - expiresAt, - }, 201,) + return c.json( + { + id: key!.id, + key: rawKey, + label, + scope, + keyPrefix, + collectionId: collectionId ?? null, + expiresAt, + }, + 201, + ) } // List org API keys -export async function listOrgKeys(c: Context,) { - const slug = c.req.param('slug',)! +export async function listOrgKeys(c: Context) { + const slug = c.req.param('slug')! - const [org,] = await db + const [org] = await db .select() - .from(schema.accounts,) - .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) - .limit(1,) + .from(schema.accounts) + .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, 'org'))) + .limit(1) - if (!org) return c.json({ error: 'Organization not found', statusCode: 404, }, 404,) + if (!org) return c.json({ error: 'Organization not found', statusCode: 404 }, 404) // Must be a member - const [membership,] = await db + const [membership] = await db .select() - .from(schema.orgMemberships,) - .where(and(eq(schema.orgMemberships.orgId, org.id,), eq(schema.orgMemberships.userId, c.get('accountId',)!,),),) - .limit(1,) + .from(schema.orgMemberships) + .where( + and( + eq(schema.orgMemberships.orgId, org.id), + eq(schema.orgMemberships.userId, c.get('accountId')!), + ), + ) + .limit(1) - if (!membership) return c.json({ error: 'Forbidden', statusCode: 403, }, 403,) + if (!membership) return c.json({ error: 'Forbidden', statusCode: 403 }, 403) const keys = await db .select({ @@ -659,113 +455,152 @@ export async function listOrgKeys(c: Context,) { expiresAt: schema.apiKeys.expiresAt, createdAt: schema.apiKeys.createdAt, lastUsedAt: schema.apiKeys.lastUsedAt, - },) - .from(schema.apiKeys,) - .where(eq(schema.apiKeys.accountId, org.id,),) + }) + .from(schema.apiKeys) + .where(eq(schema.apiKeys.accountId, org.id)) - return c.json(keys,) + return c.json(keys) } // Delete org API key -export async function deleteOrgKey(c: Context,) { - const slug = c.req.param('slug',)! - const id = c.req.param('id',)! +export async function deleteOrgKey(c: Context) { + const slug = c.req.param('slug')! + const id = c.req.param('id')! - const [org,] = await db + const [org] = await db .select() - .from(schema.accounts,) - .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) - .limit(1,) + .from(schema.accounts) + .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, 'org'))) + .limit(1) - if (!org) return c.json({ error: 'Organization not found', statusCode: 404, }, 404,) + if (!org) return c.json({ error: 'Organization not found', statusCode: 404 }, 404) - const [membership,] = await db + const [membership] = await db .select() - .from(schema.orgMemberships,) - .where(and(eq(schema.orgMemberships.orgId, org.id,), eq(schema.orgMemberships.userId, c.get('accountId',)!,),),) - .limit(1,) + .from(schema.orgMemberships) + .where( + and( + eq(schema.orgMemberships.orgId, org.id), + eq(schema.orgMemberships.userId, c.get('accountId')!), + ), + ) + .limit(1) if (!membership || membership.role === 'member') { - return c.json({ error: 'Must be an owner or admin to manage org API keys', statusCode: 403, }, 403,) + return c.json( + { error: 'Must be an owner or admin to manage org API keys', statusCode: 403 }, + 403, + ) } - const [key,] = await db + const [key] = await db .select() - .from(schema.apiKeys,) - .where(and(eq(schema.apiKeys.id, id,), eq(schema.apiKeys.accountId, org.id,),),) - .limit(1,) + .from(schema.apiKeys) + .where(and(eq(schema.apiKeys.id, id), eq(schema.apiKeys.accountId, org.id))) + .limit(1) - if (!key) return c.json({ error: 'Key not found', statusCode: 404, }, 404,) + if (!key) return c.json({ error: 'Key not found', statusCode: 404 }, 404) - await db.delete(schema.apiKeys,).where(eq(schema.apiKeys.id, id,),) - return c.json({ ok: true, },) + await db.delete(schema.apiKeys).where(eq(schema.apiKeys.id, id)) + return c.json({ ok: true }) } // --- Org Management --- // Create organization -export async function createOrg(c: Context,) { - const { slug, displayName, } = await c.req.json() +export async function createOrg(c: Context) { + const { slug, displayName, kfOrgId } = await c.req.json() + + if (!kfOrgId || typeof kfOrgId !== 'string') { + return c.json( + { + error: 'kfOrgId is required — every Underlay org must be linked to a KF org', + statusCode: 422, + }, + 422, + ) + } - if (RESERVED_SLUGS.has(slug.toLowerCase(),)) { - return c.json({ error: 'That name is reserved', statusCode: 422, }, 422,) + if (RESERVED_SLUGS.has(slug.toLowerCase())) { + return c.json({ error: 'That name is reserved', statusCode: 422 }, 422) } - if (!/^[a-z0-9][a-z0-9\-]*[a-z0-9]$/.test(slug,) || slug.length < 2) { - return c.json({ - error: 'Slug must be lowercase alphanumeric with hyphens, at least 2 characters', - statusCode: 422, - }, 422,) + if (!/^[a-z0-9][a-z0-9\-]*[a-z0-9]$/.test(slug) || slug.length < 2) { + return c.json( + { + error: 'Slug must be lowercase alphanumeric with hyphens, at least 2 characters', + statusCode: 422, + }, + 422, + ) } const existing = await db .select() - .from(schema.accounts,) - .where(eq(schema.accounts.slug, slug,),) - .limit(1,) + .from(schema.accounts) + .where(eq(schema.accounts.slug, slug)) + .limit(1) if (existing.length > 0) { - return c.json({ error: 'Name already taken', statusCode: 409, }, 409,) + return c.json({ error: 'Name already taken', statusCode: 409 }, 409) } const id = uuidv4() - await db.insert(schema.accounts,).values({ + await db.insert(schema.accounts).values({ id, slug, type: 'org', displayName, - },) + kfOrgId, + }) // Add the creating user as owner - await db.insert(schema.orgMemberships,).values({ + await db.insert(schema.orgMemberships).values({ orgId: id, - userId: c.get('accountId',)!, + userId: c.get('accountId')!, role: 'owner', - },) + }) + + return c.json({ id, slug, displayName, type: 'org', kfOrgId }, 201) +} + +/** + * GET /api/accounts/available-kf-orgs + * Returns all KF orgs the current user belongs to. + */ +export async function availableKfOrgs(c: Context) { + const accountId = c.get('accountId')! - return c.json({ id, slug, displayName, type: 'org', }, 201,) + // Fetch user's KF orgs on demand from KF Auth internal API + const { fetchKfOrgs } = await import('../lib/kf-orgs.server.js') + return c.json(await fetchKfOrgs(accountId)) } // List org members -export async function listMembers(c: Context,) { - const slug = c.req.param('slug',)! +export async function listMembers(c: Context) { + const slug = c.req.param('slug')! - const [org,] = await db + const [org] = await db .select() - .from(schema.accounts,) - .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) - .limit(1,) + .from(schema.accounts) + .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, 'org'))) + .limit(1) - if (!org) return c.json({ error: 'Organization not found', statusCode: 404, }, 404,) + if (!org) return c.json({ error: 'Organization not found', statusCode: 404 }, 404) // Must be a member to view - const [membership,] = await db + const [membership] = await db .select() - .from(schema.orgMemberships,) - .where(and(eq(schema.orgMemberships.orgId, org.id,), eq(schema.orgMemberships.userId, c.get('accountId',)!,),),) - .limit(1,) + .from(schema.orgMemberships) + .where( + and( + eq(schema.orgMemberships.orgId, org.id), + eq(schema.orgMemberships.userId, c.get('accountId')!), + ), + ) + .limit(1) - if (!membership) return c.json({ error: 'Forbidden', statusCode: 403, }, 403,) + if (!membership) return c.json({ error: 'Forbidden', statusCode: 403 }, 403) const members = await db .select({ @@ -773,307 +608,383 @@ export async function listMembers(c: Context,) { role: schema.orgMemberships.role, slug: schema.accounts.slug, displayName: schema.accounts.displayName, - },) - .from(schema.orgMemberships,) - .innerJoin(schema.accounts, eq(schema.orgMemberships.userId, schema.accounts.id,),) - .where(eq(schema.orgMemberships.orgId, org.id,),) + }) + .from(schema.orgMemberships) + .innerJoin(schema.accounts, eq(schema.orgMemberships.userId, schema.accounts.id)) + .where(eq(schema.orgMemberships.orgId, org.id)) - return c.json(members,) + return c.json(members) } // Add org member -export async function addMember(c: Context,) { - const slug = c.req.param('slug',)! - const { username, role, } = await c.req.json() +export async function addMember(c: Context) { + const slug = c.req.param('slug')! + const { username, role } = await c.req.json() - const [org,] = await db + const [org] = await db .select() - .from(schema.accounts,) - .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) - .limit(1,) + .from(schema.accounts) + .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, 'org'))) + .limit(1) - if (!org) return c.json({ error: 'Organization not found', statusCode: 404, }, 404,) + if (!org) return c.json({ error: 'Organization not found', statusCode: 404 }, 404) // Must be owner or admin - const [callerMembership,] = await db + const [callerMembership] = await db .select() - .from(schema.orgMemberships,) - .where(and(eq(schema.orgMemberships.orgId, org.id,), eq(schema.orgMemberships.userId, c.get('accountId',)!,),),) - .limit(1,) + .from(schema.orgMemberships) + .where( + and( + eq(schema.orgMemberships.orgId, org.id), + eq(schema.orgMemberships.userId, c.get('accountId')!), + ), + ) + .limit(1) if (!callerMembership || callerMembership.role === 'member') { - return c.json({ error: 'Must be an owner or admin to add members', statusCode: 403, }, 403,) + return c.json({ error: 'Must be an owner or admin to add members', statusCode: 403 }, 403) } // Find user to add - const [user,] = await db + const [user] = await db .select() - .from(schema.accounts,) - .where(and(eq(schema.accounts.slug, username,), eq(schema.accounts.type, 'user',),),) - .limit(1,) + .from(schema.accounts) + .where(and(eq(schema.accounts.slug, username), eq(schema.accounts.type, 'user'))) + .limit(1) - if (!user) return c.json({ error: 'User not found', statusCode: 404, }, 404,) + if (!user) return c.json({ error: 'User not found', statusCode: 404 }, 404) // Check not already a member - const [existing,] = await db + const [existing] = await db .select() - .from(schema.orgMemberships,) - .where(and(eq(schema.orgMemberships.orgId, org.id,), eq(schema.orgMemberships.userId, user.id,),),) - .limit(1,) + .from(schema.orgMemberships) + .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, user.id))) + .limit(1) - if (existing) return c.json({ error: 'Already a member', statusCode: 409, }, 409,) + if (existing) return c.json({ error: 'Already a member', statusCode: 409 }, 409) - await db.insert(schema.orgMemberships,).values({ + await db.insert(schema.orgMemberships).values({ orgId: org.id, userId: user.id, role: role ?? 'member', - },) + }) - return c.json({ ok: true, username, role, }, 201,) + return c.json({ ok: true, username, role }, 201) } // Update member role -export async function updateMember(c: Context,) { - const slug = c.req.param('slug',)! - const userId = c.req.param('userId',)! - const { role, } = await c.req.json() +export async function updateMember(c: Context) { + const slug = c.req.param('slug')! + const userId = c.req.param('userId')! + const { role } = await c.req.json() - const [org,] = await db + const [org] = await db .select() - .from(schema.accounts,) - .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) - .limit(1,) + .from(schema.accounts) + .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, 'org'))) + .limit(1) - if (!org) return c.json({ error: 'Organization not found', statusCode: 404, }, 404,) + if (!org) return c.json({ error: 'Organization not found', statusCode: 404 }, 404) // Must be owner - const [callerMembership,] = await db + const [callerMembership] = await db .select() - .from(schema.orgMemberships,) - .where(and(eq(schema.orgMemberships.orgId, org.id,), eq(schema.orgMemberships.userId, c.get('accountId',)!,),),) - .limit(1,) + .from(schema.orgMemberships) + .where( + and( + eq(schema.orgMemberships.orgId, org.id), + eq(schema.orgMemberships.userId, c.get('accountId')!), + ), + ) + .limit(1) if (!callerMembership || callerMembership.role !== 'owner') { - return c.json({ error: 'Must be an owner to change roles', statusCode: 403, }, 403,) + return c.json({ error: 'Must be an owner to change roles', statusCode: 403 }, 403) } await db - .update(schema.orgMemberships,) - .set({ role, },) - .where(and(eq(schema.orgMemberships.orgId, org.id,), eq(schema.orgMemberships.userId, userId,),),) + .update(schema.orgMemberships) + .set({ role }) + .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, userId))) - return c.json({ ok: true, },) + return c.json({ ok: true }) } // Remove member -export async function removeMember(c: Context,) { - const slug = c.req.param('slug',)! - const userId = c.req.param('userId',)! +export async function removeMember(c: Context) { + const slug = c.req.param('slug')! + const userId = c.req.param('userId')! - const [org,] = await db + const [org] = await db .select() - .from(schema.accounts,) - .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) - .limit(1,) + .from(schema.accounts) + .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, 'org'))) + .limit(1) - if (!org) return c.json({ error: 'Organization not found', statusCode: 404, }, 404,) + if (!org) return c.json({ error: 'Organization not found', statusCode: 404 }, 404) // Must be owner or admin (or removing yourself) - const [callerMembership,] = await db + const [callerMembership] = await db .select() - .from(schema.orgMemberships,) - .where(and(eq(schema.orgMemberships.orgId, org.id,), eq(schema.orgMemberships.userId, c.get('accountId',)!,),),) - .limit(1,) - - const isSelf = c.get('accountId',) === userId + .from(schema.orgMemberships) + .where( + and( + eq(schema.orgMemberships.orgId, org.id), + eq(schema.orgMemberships.userId, c.get('accountId')!), + ), + ) + .limit(1) + + const isSelf = c.get('accountId') === userId if (!callerMembership || (callerMembership.role === 'member' && !isSelf)) { - return c.json({ error: 'Forbidden', statusCode: 403, }, 403,) + return c.json({ error: 'Forbidden', statusCode: 403 }, 403) } await db - .delete(schema.orgMemberships,) - .where(and(eq(schema.orgMemberships.orgId, org.id,), eq(schema.orgMemberships.userId, userId,),),) + .delete(schema.orgMemberships) + .where(and(eq(schema.orgMemberships.orgId, org.id), eq(schema.orgMemberships.userId, userId))) - return c.json({ ok: true, },) + return c.json({ ok: true }) } // Update org profile -export async function updateOrg(c: Context,) { - const slug = c.req.param('slug',)! - const { displayName, bio, website, location, } = await c.req.json() +export async function updateOrg(c: Context) { + const slug = c.req.param('slug')! + const { slug: newSlug, displayName, bio, website, location, kfOrgId } = await c.req.json() - const [org,] = await db + const [org] = await db .select() - .from(schema.accounts,) - .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) - .limit(1,) + .from(schema.accounts) + .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, 'org'))) + .limit(1) - if (!org) return c.json({ error: 'Organization not found', statusCode: 404, }, 404,) + if (!org) return c.json({ error: 'Organization not found', statusCode: 404 }, 404) // Must be owner - const [callerMembership,] = await db + const [callerMembership] = await db .select() - .from(schema.orgMemberships,) - .where(and(eq(schema.orgMemberships.orgId, org.id,), eq(schema.orgMemberships.userId, c.get('accountId',)!,),),) - .limit(1,) + .from(schema.orgMemberships) + .where( + and( + eq(schema.orgMemberships.orgId, org.id), + eq(schema.orgMemberships.userId, c.get('accountId')!), + ), + ) + .limit(1) if (!callerMembership || callerMembership.role !== 'owner') { - return c.json({ error: 'Must be an owner to update the organization', statusCode: 403, }, 403,) + return c.json({ error: 'Must be an owner to update the organization', statusCode: 403 }, 403) + } + + // Validate slug change if provided + if (newSlug !== undefined) { + const slugErr = validateSlug(newSlug) + if (slugErr) return c.json({ error: slugErr, statusCode: 422 }, 422) + + const [existing] = await db + .select({ id: schema.accounts.id }) + .from(schema.accounts) + .where(eq(schema.accounts.slug, newSlug)) + .limit(1) + + if (existing && existing.id !== org.id) { + return c.json({ error: 'That slug is already taken', statusCode: 409 }, 409) + } + } + + // Validate kfOrgId change if provided + if (kfOrgId !== undefined) { + if (!kfOrgId || typeof kfOrgId !== 'string') { + return c.json({ error: 'kfOrgId must be a non-empty string', statusCode: 422 }, 422) + } + // Check it's not already linked to another UL org + const [alreadyLinked] = await db + .select({ id: schema.accounts.id }) + .from(schema.accounts) + .where(and(eq(schema.accounts.kfOrgId, kfOrgId), eq(schema.accounts.type, 'org'))) + .limit(1) + + if (alreadyLinked && alreadyLinked.id !== org.id) { + return c.json( + { + error: 'This KF organization is already linked to another Underlay org', + statusCode: 409, + }, + 409, + ) + } } const updates: Record = {} + if (newSlug !== undefined) updates.slug = newSlug if (displayName !== undefined) updates.displayName = displayName if (bio !== undefined) updates.bio = bio if (website !== undefined) updates.website = website if (location !== undefined) updates.location = location + if (kfOrgId !== undefined) updates.kfOrgId = kfOrgId - if (Object.keys(updates,).length > 0) { - await db.update(schema.accounts,).set(updates,).where(eq(schema.accounts.id, org.id,),) + if (Object.keys(updates).length > 0) { + await db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, org.id)) } - return c.json({ ok: true, },) + return c.json({ ok: true, slug: newSlug ?? slug }) } // Upload org avatar -export async function uploadOrgAvatar(c: Context,) { - const slug = c.req.param('slug',)! +export async function uploadOrgAvatar(c: Context) { + const slug = c.req.param('slug')! - const [org,] = await db + const [org] = await db .select() - .from(schema.accounts,) - .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) - .limit(1,) + .from(schema.accounts) + .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, 'org'))) + .limit(1) - if (!org) return c.json({ error: 'Organization not found', statusCode: 404, }, 404,) + if (!org) return c.json({ error: 'Organization not found', statusCode: 404 }, 404) - const [membership,] = await db + const [membership] = await db .select() - .from(schema.orgMemberships,) - .where(and(eq(schema.orgMemberships.orgId, org.id,), eq(schema.orgMemberships.userId, c.get('accountId',)!,),),) - .limit(1,) + .from(schema.orgMemberships) + .where( + and( + eq(schema.orgMemberships.orgId, org.id), + eq(schema.orgMemberships.userId, c.get('accountId')!), + ), + ) + .limit(1) if (!membership || membership.role !== 'owner') { - return c.json({ error: 'Must be an owner to update the organization avatar', statusCode: 403, }, 403,) + return c.json( + { error: 'Must be an owner to update the organization avatar', statusCode: 403 }, + 403, + ) } const body = await c.req.parseBody() - const file = Object.values(body,).find((v,): v is File => v instanceof File) + const file = Object.values(body).find((v): v is File => v instanceof File) if (!file) { - return c.json({ error: 'No file uploaded', statusCode: 400, }, 400,) + return c.json({ error: 'No file uploaded', statusCode: 400 }, 400) } - const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp',] - if (!allowedTypes.includes(file.type,)) { - return c.json({ error: 'Only JPEG, PNG, GIF, and WebP images are allowed', statusCode: 422, }, 422,) + const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] + if (!allowedTypes.includes(file.type)) { + return c.json( + { error: 'Only JPEG, PNG, GIF, and WebP images are allowed', statusCode: 422 }, + 422, + ) } - const buffer = Buffer.from(await file.arrayBuffer(),) + const buffer = Buffer.from(await file.arrayBuffer()) if (buffer.length > 5 * 1024 * 1024) { - return c.json({ error: 'Image must be less than 5MB', statusCode: 422, }, 422,) + return c.json({ error: 'Image must be less than 5MB', statusCode: 422 }, 422) } - const ext = file.type.split('/',)[1] === 'jpeg' ? 'jpg' : file.type.split('/',)[1] + const ext = file.type.split('/')[1] === 'jpeg' ? 'jpg' : file.type.split('/')[1] const key = `avatars/${org.id}/${Date.now()}.${ext}` - await uploadToS3(key, buffer, file.type,) + await uploadToS3(key, buffer, file.type) - await db.update(schema.accounts,).set({ avatarUrl: `${ASSETS_BASE_URL}/${key}`, },).where( - eq(schema.accounts.id, org.id,), - ) + await db + .update(schema.accounts) + .set({ avatarUrl: `${ASSETS_BASE_URL}/${key}` }) + .where(eq(schema.accounts.id, org.id)) - return c.json({ ok: true, avatarUrl: `${ASSETS_BASE_URL}/${key}`, },) + return c.json({ ok: true, avatarUrl: `${ASSETS_BASE_URL}/${key}` }) } // --- Org Invitations --- // Invite user to org -export async function createInvitation(c: Context,) { - const slug = c.req.param('slug',)! - const { email, role, } = await c.req.json() +export async function createInvitation(c: Context) { + const slug = c.req.param('slug')! + const { email, role } = await c.req.json() - const [org,] = await db + const [org] = await db .select() - .from(schema.accounts,) - .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) - .limit(1,) + .from(schema.accounts) + .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, 'org'))) + .limit(1) - if (!org) return c.json({ error: 'Organization not found', statusCode: 404, }, 404,) + if (!org) return c.json({ error: 'Organization not found', statusCode: 404 }, 404) - const [callerMembership,] = await db + const [callerMembership] = await db .select() - .from(schema.orgMemberships,) - .where(and(eq(schema.orgMemberships.orgId, org.id,), eq(schema.orgMemberships.userId, c.get('accountId',)!,),),) - .limit(1,) + .from(schema.orgMemberships) + .where( + and( + eq(schema.orgMemberships.orgId, org.id), + eq(schema.orgMemberships.userId, c.get('accountId')!), + ), + ) + .limit(1) if (!callerMembership || callerMembership.role === 'member') { - return c.json({ error: 'Must be an owner or admin to invite members', statusCode: 403, }, 403,) + return c.json({ error: 'Must be an owner or admin to invite members', statusCode: 403 }, 403) } - // Check if already a member (by email) - const [existingUser,] = await db + // Check if there's already a pending invitation for this email + const [existingInvite] = await db .select() - .from(schema.accounts,) - .where(eq(schema.accounts.email, email,),) - .limit(1,) - - if (existingUser) { - const [existingMembership,] = await db - .select() - .from(schema.orgMemberships,) - .where(and(eq(schema.orgMemberships.orgId, org.id,), eq(schema.orgMemberships.userId, existingUser.id,),),) - .limit(1,) - - if (existingMembership) { - return c.json({ error: 'User is already a member', statusCode: 409, }, 409,) - } + .from(schema.orgInvitations) + .where(and(eq(schema.orgInvitations.orgId, org.id), eq(schema.orgInvitations.email, email))) + .limit(1) + + if (existingInvite && !existingInvite.acceptedAt) { + return c.json( + { error: 'An invitation is already pending for this email', statusCode: 409 }, + 409, + ) } const token = uuidv4() - const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000,) // 7 days + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days - await db.insert(schema.orgInvitations,).values({ + await db.insert(schema.orgInvitations).values({ orgId: org.id, email, role, - invitedBy: c.get('accountId',)!, + invitedBy: c.get('accountId')!, token, expiresAt, - },) + }) // Send invitation email - const origin = new URL(c.req.url,).origin + const origin = new URL(c.req.url).origin const inviteUrl = `${origin}/invitations/accept?token=${token}` await sendEmail({ to: email, subject: `You've been invited to join ${org.displayName} on Underlay`, - text: - `You've been invited to join ${org.displayName} as a ${role}.\n\nAccept: ${inviteUrl}\n\nThis invitation expires in 7 days.`, - html: - `

You've been invited to join ${org.displayName} as a ${role}.

Accept invitation

This invitation expires in 7 days.

`, - },) + text: `You've been invited to join ${org.displayName} as a ${role}.\n\nAccept: ${inviteUrl}\n\nThis invitation expires in 7 days.`, + html: `

You've been invited to join ${org.displayName} as a ${role}.

Accept invitation

This invitation expires in 7 days.

`, + }) - return c.json({ ok: true, }, 201,) + return c.json({ ok: true }, 201) } // List pending invitations for an org -export async function listInvitations(c: Context,) { - const slug = c.req.param('slug',)! +export async function listInvitations(c: Context) { + const slug = c.req.param('slug')! - const [org,] = await db + const [org] = await db .select() - .from(schema.accounts,) - .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) - .limit(1,) + .from(schema.accounts) + .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, 'org'))) + .limit(1) - if (!org) return c.json({ error: 'Organization not found', statusCode: 404, }, 404,) + if (!org) return c.json({ error: 'Organization not found', statusCode: 404 }, 404) - const [membership,] = await db + const [membership] = await db .select() - .from(schema.orgMemberships,) - .where(and(eq(schema.orgMemberships.orgId, org.id,), eq(schema.orgMemberships.userId, c.get('accountId',)!,),),) - .limit(1,) + .from(schema.orgMemberships) + .where( + and( + eq(schema.orgMemberships.orgId, org.id), + eq(schema.orgMemberships.userId, c.get('accountId')!), + ), + ) + .limit(1) - if (!membership) return c.json({ error: 'Forbidden', statusCode: 403, }, 403,) + if (!membership) return c.json({ error: 'Forbidden', statusCode: 403 }, 403) const invitations = await db .select({ @@ -1083,120 +994,148 @@ export async function listInvitations(c: Context,) { expiresAt: schema.orgInvitations.expiresAt, acceptedAt: schema.orgInvitations.acceptedAt, createdAt: schema.orgInvitations.createdAt, - },) - .from(schema.orgInvitations,) - .where(eq(schema.orgInvitations.orgId, org.id,),) + }) + .from(schema.orgInvitations) + .where(eq(schema.orgInvitations.orgId, org.id)) - return c.json(invitations,) + return c.json(invitations) } // Cancel an invitation -export async function deleteInvitation(c: Context,) { - const slug = c.req.param('slug',)! - const id = c.req.param('id',)! +export async function deleteInvitation(c: Context) { + const slug = c.req.param('slug')! + const id = c.req.param('id')! - const [org,] = await db + const [org] = await db .select() - .from(schema.accounts,) - .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) - .limit(1,) + .from(schema.accounts) + .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, 'org'))) + .limit(1) - if (!org) return c.json({ error: 'Organization not found', statusCode: 404, }, 404,) + if (!org) return c.json({ error: 'Organization not found', statusCode: 404 }, 404) - const [membership,] = await db + const [membership] = await db .select() - .from(schema.orgMemberships,) - .where(and(eq(schema.orgMemberships.orgId, org.id,), eq(schema.orgMemberships.userId, c.get('accountId',)!,),),) - .limit(1,) + .from(schema.orgMemberships) + .where( + and( + eq(schema.orgMemberships.orgId, org.id), + eq(schema.orgMemberships.userId, c.get('accountId')!), + ), + ) + .limit(1) if (!membership || membership.role === 'member') { - return c.json({ error: 'Must be an owner or admin to cancel invitations', statusCode: 403, }, 403,) + return c.json( + { error: 'Must be an owner or admin to cancel invitations', statusCode: 403 }, + 403, + ) } - await db.delete(schema.orgInvitations,).where(eq(schema.orgInvitations.id, id,),) - return c.json({ ok: true, },) + await db.delete(schema.orgInvitations).where(eq(schema.orgInvitations.id, id)) + return c.json({ ok: true }) } // Accept an invitation (public, token-based) -export async function acceptInvitation(c: Context,) { - const { token, } = await c.req.json() +export async function acceptInvitation(c: Context) { + const { token } = await c.req.json() - const [invitation,] = await db + const [invitation] = await db .select() - .from(schema.orgInvitations,) - .where(eq(schema.orgInvitations.token, token,),) - .limit(1,) + .from(schema.orgInvitations) + .where(eq(schema.orgInvitations.token, token)) + .limit(1) if (!invitation) { - return c.json({ error: 'Invitation not found', statusCode: 404, }, 404,) + return c.json({ error: 'Invitation not found', statusCode: 404 }, 404) } if (invitation.acceptedAt) { - return c.json({ error: 'Invitation already accepted', statusCode: 409, }, 409,) + return c.json({ error: 'Invitation already accepted', statusCode: 409 }, 409) } - if (new Date(invitation.expiresAt,) < new Date()) { - return c.json({ error: 'Invitation has expired', statusCode: 410, }, 410,) + if (new Date(invitation.expiresAt) < new Date()) { + return c.json({ error: 'Invitation has expired', statusCode: 410 }, 410) } - // Verify the logged-in user's email matches the invitation - const [account,] = await db - .select() - .from(schema.accounts,) - .where(eq(schema.accounts.id, c.get('accountId',)!,),) - .limit(1,) + // Verify the logged-in user's email matches the invitation. + // Email is fetched from KF Auth since we don't store it locally. + const { getKfProfile } = await import('../lib/kf-profile-cache.server.js') + const accountId = c.get('accountId')! + + // Fetch email from KF Auth internal API directly (profile cache doesn't include email) + const KF_AUTH_URL = process.env.KF_AUTH_URL ?? 'http://localhost:3000' + const KF_INTERNAL_API_KEY = process.env.KF_INTERNAL_API_KEY ?? '' + let userEmail: string | null = null + try { + const res = await fetch(`${KF_AUTH_URL}/api/internal/users/${accountId}`, { + headers: { Authorization: `Bearer ${KF_INTERNAL_API_KEY}` }, + }) + if (res.ok) { + const data = (await res.json()) as { email: string } + userEmail = data.email + } + } catch {} - if (!account || account.email !== invitation.email) { - return c.json({ error: 'This invitation was sent to a different email address', statusCode: 403, }, 403,) + if (!userEmail || userEmail !== invitation.email) { + return c.json( + { error: 'This invitation was sent to a different email address', statusCode: 403 }, + 403, + ) } // Add to org - await db.insert(schema.orgMemberships,).values({ + await db.insert(schema.orgMemberships).values({ orgId: invitation.orgId, - userId: c.get('accountId',)!, + userId: c.get('accountId')!, role: invitation.role as 'owner' | 'admin' | 'member', - },) + }) // Mark invitation as accepted await db - .update(schema.orgInvitations,) - .set({ acceptedAt: new Date(), },) - .where(eq(schema.orgInvitations.id, invitation.id,),) + .update(schema.orgInvitations) + .set({ acceptedAt: new Date() }) + .where(eq(schema.orgInvitations.id, invitation.id)) // Get org slug for redirect - const [org,] = await db - .select({ slug: schema.accounts.slug, },) - .from(schema.accounts,) - .where(eq(schema.accounts.id, invitation.orgId,),) - .limit(1,) + const [org] = await db + .select({ slug: schema.accounts.slug }) + .from(schema.accounts) + .where(eq(schema.accounts.id, invitation.orgId)) + .limit(1) - return c.json({ ok: true, orgSlug: org?.slug ?? '', },) + return c.json({ ok: true, orgSlug: org?.slug ?? '' }) } // Delete org -export async function deleteOrg(c: Context,) { - const slug = c.req.param('slug',)! +export async function deleteOrg(c: Context) { + const slug = c.req.param('slug')! - const [org,] = await db + const [org] = await db .select() - .from(schema.accounts,) - .where(and(eq(schema.accounts.slug, slug,), eq(schema.accounts.type, 'org',),),) - .limit(1,) + .from(schema.accounts) + .where(and(eq(schema.accounts.slug, slug), eq(schema.accounts.type, 'org'))) + .limit(1) - if (!org) return c.json({ error: 'Organization not found', statusCode: 404, }, 404,) + if (!org) return c.json({ error: 'Organization not found', statusCode: 404 }, 404) // Must be owner - const [callerMembership,] = await db + const [callerMembership] = await db .select() - .from(schema.orgMemberships,) - .where(and(eq(schema.orgMemberships.orgId, org.id,), eq(schema.orgMemberships.userId, c.get('accountId',)!,),),) - .limit(1,) + .from(schema.orgMemberships) + .where( + and( + eq(schema.orgMemberships.orgId, org.id), + eq(schema.orgMemberships.userId, c.get('accountId')!), + ), + ) + .limit(1) if (!callerMembership || callerMembership.role !== 'owner') { - return c.json({ error: 'Must be an owner to delete the organization', statusCode: 403, }, 403,) + return c.json({ error: 'Must be an owner to delete the organization', statusCode: 403 }, 403) } // Cascade will handle memberships, collections, etc. - await db.delete(schema.accounts,).where(eq(schema.accounts.id, org.id,),) - return c.json({ ok: true, },) + await db.delete(schema.accounts).where(eq(schema.accounts.id, org.id)) + return c.json({ ok: true }) } diff --git a/src/api/admin.ts b/src/api/admin.ts index beefc32..0060012 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -1,6 +1,7 @@ -import type { Context, } from 'hono' -import { streamSSE, } from 'hono/streaming' -import { getMirrorConfig, } from '../lib/mirror-config.js' +import type { Context } from 'hono' +import { streamSSE } from 'hono/streaming' + +import { getMirrorConfig } from '../lib/mirror-config.js' import { cleanupStaleRuns, getActiveRunId, @@ -14,47 +15,47 @@ import { type SyncProgressEvent, testUpstreamConnection, } from '../lib/mirror-sync.js' -import { type AuthEnv, } from './auth.server.js' +import { type AuthEnv } from './auth.server.js' // Get mirror status -export async function mirrorStatus(c: Context,) { +export async function mirrorStatus(c: Context) { const status = await getMirrorStatus() - return c.json(status,) + return c.json(status) } // Test upstream connection -export async function mirrorTest(c: Context,) { +export async function mirrorTest(c: Context) { const config = getMirrorConfig() - const result = await testUpstreamConnection(config.upstream,) - return c.json(result,) + const result = await testUpstreamConnection(config.upstream) + return c.json(result) } // Trigger a sync manually (fire-and-forget, client uses SSE for progress) -export async function mirrorSync(c: Context,) { +export async function mirrorSync(c: Context) { if (isSyncRunning()) { - return c.json({ started: false, error: 'A sync is already running', },) + return c.json({ started: false, error: 'A sync is already running' }) } // Start sync in background — don't await - runMirrorSync('manual',).catch((err,) => { - console.error('[mirror-sync] Unhandled sync error:', err,) - },) - return c.json({ started: true, },) + runMirrorSync('manual').catch((err) => { + console.error('[mirror-sync] Unhandled sync error:', err) + }) + return c.json({ started: true }) } // Stop a running sync (also cleans up stale DB rows from crashed processes) -export async function mirrorSyncStop(c: Context,) { +export async function mirrorSyncStop(c: Context) { const stopped = stopSync() if (!stopped) { // No active sync in this process — clean up stale DB rows const cleaned = await cleanupStaleRuns() - return c.json({ stopped: false, cleaned, },) + return c.json({ stopped: false, cleaned }) } - return c.json({ stopped: true, },) + return c.json({ stopped: true }) } // SSE endpoint for live sync progress (replays buffered logs on connect) -export async function mirrorSyncProgress(c: Context,) { - return streamSSE(c, async (stream,) => { +export async function mirrorSyncProgress(c: Context) { + return streamSSE(c, async (stream) => { // Replay buffered logs so reconnects/refreshes don't lose history const buffered = getActiveRunLogs() if (buffered.length > 0) { @@ -71,7 +72,7 @@ export async function mirrorSyncProgress(c: Context,) { errors: 0, }, } - await stream.writeSSE({ data: JSON.stringify(replayEvent,), },) + await stream.writeSSE({ data: JSON.stringify(replayEvent) }) } } @@ -80,40 +81,37 @@ export async function mirrorSyncProgress(c: Context,) { return } - const onProgress = async (event: SyncProgressEvent,) => { - await stream.writeSSE({ data: JSON.stringify(event,), },) + const onProgress = async (event: SyncProgressEvent) => { + await stream.writeSSE({ data: JSON.stringify(event) }) if (event.type === 'done') { - setTimeout(() => stream.close(), 100,) + setTimeout(() => stream.close(), 100) } } - syncEvents.on('progress', onProgress,) + syncEvents.on('progress', onProgress) stream.onAbort(() => { - syncEvents.off('progress', onProgress,) - },) + syncEvents.off('progress', onProgress) + }) // Keep the stream open until aborted or done - await new Promise((resolve,) => { + await new Promise((resolve) => { stream.onAbort(() => resolve()) - },) - },) + }) + }) } // Get current sync running state (for page refresh reconnection) -export async function mirrorSyncActive(c: Context,) { +export async function mirrorSyncActive(c: Context) { return c.json({ running: isSyncRunning(), runId: getActiveRunId(), logs: getActiveRunLogs(), - },) + }) } // Sync history -export async function mirrorHistory(c: Context,) { - const limit = Math.min( - Number(c.req.query('limit',),) || 20, - 100, - ) - return c.json(await getSyncHistory(limit,),) +export async function mirrorHistory(c: Context) { + const limit = Math.min(Number(c.req.query('limit')) || 20, 100) + return c.json(await getSyncHistory(limit)) } diff --git a/src/api/ark-middleware.server.ts b/src/api/ark-middleware.server.ts index cd9da47..ade9923 100644 --- a/src/api/ark-middleware.server.ts +++ b/src/api/ark-middleware.server.ts @@ -1,27 +1,28 @@ -import type { MiddlewareHandler, } from 'hono' -import { buildErc, DEFAULT_NAAN, } from '../lib/ark.js' +import type { MiddlewareHandler } from 'hono' + +import { buildErc, DEFAULT_NAAN } from '../lib/ark.js' /** * Hono middleware that intercepts /ark:NAAN/... URLs and resolves them. * In the new single-server architecture, we call the API route handler internally * via a local fetch to localhost (same process). */ -export const arkMiddleware: MiddlewareHandler = async (c, _next,) => { - const url = new URL(c.req.url,) +export const arkMiddleware: MiddlewareHandler = async (c, _next) => { + const url = new URL(c.req.url) const pathname = url.pathname const search = url.search - if (!pathname.startsWith('/ark:',)) { + if (!pathname.startsWith('/ark:')) { return _next() } - const fullPath = pathname.slice(1,) // strip leading / + const fullPath = pathname.slice(1) // strip leading / // Check if this is a root NAAN path - const afterLabel = fullPath.slice(4,) // strip "ark:" - const slashIdx = afterLabel.indexOf('/',) - const naan = slashIdx === -1 ? afterLabel : afterLabel.slice(0, slashIdx,) - const afterNaan = slashIdx === -1 ? '' : afterLabel.slice(slashIdx + 1,) + const afterLabel = fullPath.slice(4) // strip "ark:" + const slashIdx = afterLabel.indexOf('/') + const naan = slashIdx === -1 ? afterLabel : afterLabel.slice(0, slashIdx) + const afterNaan = slashIdx === -1 ? '' : afterLabel.slice(slashIdx + 1) if (!afterNaan.trim()) { return new Response( @@ -37,37 +38,37 @@ export const arkMiddleware: MiddlewareHandler = async (c, _next,) => { '4. Scope: Underlay ARKs primarily identify versioned data collections and the records within them. Collection ARKs redirect to the collection overview; version-qualified ARKs redirect to specific version pages; record ARKs redirect to the canonical URL of the identified record.', '', `For more information, see: https://underlay.org/ark:${naan}/`, - ].join('\n',), - { headers: { 'Content-Type': 'text/plain; charset=utf-8', }, }, + ].join('\n'), + { headers: { 'Content-Type': 'text/plain; charset=utf-8' } }, ) } // Resolve the ARK via internal API - const port = Number(process.env.PORT,) || 3000 + const port = Number(process.env.PORT) || 3000 const apiBase = `http://localhost:${port}` - const params = new URLSearchParams({ path: fullPath, },) + const params = new URLSearchParams({ path: fullPath }) let resolveRes: Response try { - resolveRes = await fetch(`${apiBase}/api/ark/resolve?${params}`,) + resolveRes = await fetch(`${apiBase}/api/ark/resolve?${params}`) } catch { - return new Response('ARK resolver unavailable', { status: 503, },) + return new Response('ARK resolver unavailable', { status: 503 }) } if (!resolveRes.ok) { const body = await resolveRes.json().catch(() => ({})) if (body?.type === 'not_found') { - return new Response('ARK not found', { status: 404, },) + return new Response('ARK not found', { status: 404 }) } - return new Response('ARK resolution error', { status: 502, },) + return new Response('ARK resolution error', { status: 502 }) } const data = await resolveRes.json() if (data.type === 'not_found') { - return new Response('ARK not found', { status: 404, },) + return new Response('ARK not found', { status: 404 }) } - const { metadata, } = data + const { metadata } = data const resolvedNaan = metadata?.naan ?? DEFAULT_NAAN // Handle inflections @@ -79,23 +80,21 @@ export const arkMiddleware: MiddlewareHandler = async (c, _next,) => { when: metadata.when ?? '(:unkn)', where: metadata.where ?? metadata.arkUrl ?? '(:unkn)', naan: resolvedNaan, - },) + }) return new Response(erc, { - headers: { 'Content-Type': 'text/plain; charset=utf-8', }, - },) + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, + }) } if (search === '?json') { - return new Response(JSON.stringify(metadata, null, 2,), { - headers: { 'Content-Type': 'application/json', }, - },) + return new Response(JSON.stringify(metadata, null, 2), { + headers: { 'Content-Type': 'application/json' }, + }) } // Regular resolution — redirect const targetUrl = data.url - const redirectTarget = targetUrl.startsWith('/',) - ? `${url.origin}${targetUrl}` - : targetUrl + const redirectTarget = targetUrl.startsWith('/') ? `${url.origin}${targetUrl}` : targetUrl - return c.redirect(redirectTarget, 302,) + return c.redirect(redirectTarget, 302) } diff --git a/src/api/ark.ts b/src/api/ark.ts index aefa1e5..424d0e7 100644 --- a/src/api/ark.ts +++ b/src/api/ark.ts @@ -1,6 +1,7 @@ -import { and, desc, eq, } from 'drizzle-orm' -import type { Context, } from 'hono' -import { db, schema, } from '../db/client.server.js' +import { and, desc, eq } from 'drizzle-orm' +import type { Context } from 'hono' + +import { db, schema } from '../db/client.server.js' import { buildArkUrl, buildErc, @@ -10,43 +11,43 @@ import { getOrMintShoulder, parseArkPath, } from '../lib/ark.js' -import { type AuthEnv, } from './auth.server.js' +import { type AuthEnv } from './auth.server.js' // --- Resolution --- -export async function resolve(c: Context,) { - const path = c.req.query('path',) - if (!path) return c.json({ error: 'Missing path', }, 400,) +export async function resolve(c: Context) { + const path = c.req.query('path') + if (!path) return c.json({ error: 'Missing path' }, 400) // path = "ark:NAAN/shoulder+collection..." - const arkLabelIdx = path.indexOf('ark:',) - if (arkLabelIdx === -1) return c.json({ error: 'Invalid ARK path', }, 400,) + const arkLabelIdx = path.indexOf('ark:') + if (arkLabelIdx === -1) return c.json({ error: 'Invalid ARK path' }, 400) - const afterLabel = path.slice(arkLabelIdx + 4,) // strip "ark:" - const slashIdx = afterLabel.indexOf('/',) - if (slashIdx === -1) return c.json({ type: 'not_found', }, 404,) + const afterLabel = path.slice(arkLabelIdx + 4) // strip "ark:" + const slashIdx = afterLabel.indexOf('/') + if (slashIdx === -1) return c.json({ type: 'not_found' }, 404) - const naan = afterLabel.slice(0, slashIdx,) - const pathAfterNaan = afterLabel.slice(slashIdx + 1,) + const naan = afterLabel.slice(0, slashIdx) + const pathAfterNaan = afterLabel.slice(slashIdx + 1) // Root NAAN path (no name part) — handled in middleware; shouldn't reach here - if (!pathAfterNaan) return c.json({ type: 'not_found', }, 404,) + if (!pathAfterNaan) return c.json({ type: 'not_found' }, 404) - const components = parseArkPath(pathAfterNaan,) - if (!components) return c.json({ type: 'not_found', }, 404,) + const components = parseArkPath(pathAfterNaan) + if (!components) return c.json({ type: 'not_found' }, 404) - const { shoulder, collectionArkId, version, recordType, recordId, } = components + const { shoulder, collectionArkId, version, recordType, recordId } = components // Lookup shoulder → account - const [shoulderRow,] = await db - .select({ accountId: schema.arkShoulders.accountId, },) - .from(schema.arkShoulders,) - .where(eq(schema.arkShoulders.shoulder, shoulder,),) - .limit(1,) - if (!shoulderRow) return c.json({ type: 'not_found', }, 404,) + const [shoulderRow] = await db + .select({ accountId: schema.arkShoulders.accountId }) + .from(schema.arkShoulders) + .where(eq(schema.arkShoulders.shoulder, shoulder)) + .limit(1) + if (!shoulderRow) return c.json({ type: 'not_found' }, 404) // Lookup collectionArkId → collection + owner - const [collRow,] = await db + const [collRow] = await db .select({ collectionId: schema.arkCollections.collectionId, enabled: schema.arkCollections.enabled, @@ -57,22 +58,22 @@ export async function resolve(c: Context,) { ownerName: schema.accounts.displayName, ownerNaan: schema.accounts.arkNaan, collectionAccountId: schema.collections.accountId, - },) - .from(schema.arkCollections,) - .innerJoin(schema.collections, eq(schema.arkCollections.collectionId, schema.collections.id,),) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id,),) - .where(eq(schema.arkCollections.arkId, collectionArkId,),) - .limit(1,) + }) + .from(schema.arkCollections) + .innerJoin(schema.collections, eq(schema.arkCollections.collectionId, schema.collections.id)) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) + .where(eq(schema.arkCollections.arkId, collectionArkId)) + .limit(1) - if (!collRow || !collRow.enabled) return c.json({ type: 'not_found', }, 404,) + if (!collRow || !collRow.enabled) return c.json({ type: 'not_found' }, 404) // Verify the shoulder belongs to the collection's owner if (shoulderRow.accountId !== collRow.collectionAccountId) { - return c.json({ type: 'not_found', }, 404,) + return c.json({ type: 'not_found' }, 404) } const resolvedNaan = collRow.ownerNaan ?? naan - const { collectionId, collectionSlug, collectionName, ownerSlug, ownerName, } = collRow + const { collectionId, collectionSlug, collectionName, ownerSlug, ownerName } = collRow // --- Resolve version --- let versionRow: { @@ -88,7 +89,7 @@ export async function resolve(c: Context,) { } | null = null if (version !== undefined) { - const [row,] = await db + const [row] = await db .select({ id: schema.versions.id, number: schema.versions.number, @@ -99,14 +100,16 @@ export async function resolve(c: Context,) { appId: schema.versions.appId, actorId: schema.versions.actorId, createdAt: schema.versions.createdAt, - },) - .from(schema.versions,) - .where(and(eq(schema.versions.collectionId, collectionId,), eq(schema.versions.number, version,),),) - .limit(1,) - if (!row) return c.json({ type: 'not_found', }, 404,) + }) + .from(schema.versions) + .where( + and(eq(schema.versions.collectionId, collectionId), eq(schema.versions.number, version)), + ) + .limit(1) + if (!row) return c.json({ type: 'not_found' }, 404) versionRow = row } else { - const [row,] = await db + const [row] = await db .select({ id: schema.versions.id, number: schema.versions.number, @@ -117,65 +120,65 @@ export async function resolve(c: Context,) { appId: schema.versions.appId, actorId: schema.versions.actorId, createdAt: schema.versions.createdAt, - },) - .from(schema.versions,) - .where(eq(schema.versions.collectionId, collectionId,),) - .orderBy(desc(schema.versions.number,),) - .limit(1,) + }) + .from(schema.versions) + .where(eq(schema.versions.collectionId, collectionId)) + .orderBy(desc(schema.versions.number)) + .limit(1) versionRow = row ?? null } - const arkUrl = buildArkUrl(resolvedNaan, shoulder, collectionArkId, version, recordType, recordId,) + const arkUrl = buildArkUrl(resolvedNaan, shoulder, collectionArkId, version, recordType, recordId) // --- Record resolution --- if (recordType && recordId) { - const [artRow,] = await db - .select({ redirectUrlField: schema.arkRecordTypes.redirectUrlField, },) - .from(schema.arkRecordTypes,) + const [artRow] = await db + .select({ redirectUrlField: schema.arkRecordTypes.redirectUrlField }) + .from(schema.arkRecordTypes) .where( and( - eq(schema.arkRecordTypes.collectionId, collectionId,), - eq(schema.arkRecordTypes.recordType, recordType,), + eq(schema.arkRecordTypes.collectionId, collectionId), + eq(schema.arkRecordTypes.recordType, recordType), ), ) - .limit(1,) + .limit(1) - if (!artRow) return c.json({ type: 'not_found', }, 404,) + if (!artRow) return c.json({ type: 'not_found' }, 404) - if (!versionRow) return c.json({ type: 'not_found', }, 404,) + if (!versionRow) return c.json({ type: 'not_found' }, 404) - const [recordRow,] = await db - .select({ data: schema.records.data, },) - .from(schema.records,) + const [recordRow] = await db + .select({ data: schema.records.data }) + .from(schema.records) .where( and( - eq(schema.records.versionId, versionRow.id,), - eq(schema.records.recordId, recordId,), - eq(schema.records.type, recordType,), + eq(schema.records.versionId, versionRow.id), + eq(schema.records.recordId, recordId), + eq(schema.records.type, recordType), ), ) - .limit(1,) + .limit(1) - if (!recordRow) return c.json({ type: 'not_found', }, 404,) + if (!recordRow) return c.json({ type: 'not_found' }, 404) const data = recordRow.data as Record const redirectUrl = data[artRow.redirectUrlField] if (typeof redirectUrl !== 'string') { - return c.json({ type: 'not_found', error: 'No URL found for this record', }, 404,) + return c.json({ type: 'not_found', error: 'No URL found for this record' }, 404) } // Fetch the type schema for metadata - const [vs,] = await db - .select({ schema: schema.schemas.schema, },) - .from(schema.versionSchemas,) - .innerJoin(schema.schemas, eq(schema.versionSchemas.schemaId, schema.schemas.id,),) + const [vs] = await db + .select({ schema: schema.schemas.schema }) + .from(schema.versionSchemas) + .innerJoin(schema.schemas, eq(schema.versionSchemas.schemaId, schema.schemas.id)) .where( and( - eq(schema.versionSchemas.versionId, versionRow.id,), - eq(schema.versionSchemas.slug, recordType,), + eq(schema.versionSchemas.versionId, versionRow.id), + eq(schema.versionSchemas.slug, recordType), ), ) - .limit(1,) + .limit(1) return c.json({ type: 'redirect' as const, @@ -184,7 +187,7 @@ export async function resolve(c: Context,) { type: 'record', who: ownerName, what: `${recordType} ${recordId} in ${collectionName}`, - when: formatErcDate(versionRow.createdAt,), + when: formatErcDate(versionRow.createdAt), where: arkUrl, naan: resolvedNaan, collectionName, @@ -198,17 +201,13 @@ export async function resolve(c: Context,) { createdAt: versionRow.createdAt, arkUrl, }, - },) + }) } // --- Collection / version resolution --- if (collRow.customUrl) { - const what = versionRow - ? `${collectionName} ${versionRow.semver}` - : collectionName - const when = versionRow - ? formatErcDate(versionRow.createdAt,) - : '(:unkn)' + const what = versionRow ? `${collectionName} ${versionRow.semver}` : collectionName + const when = versionRow ? formatErcDate(versionRow.createdAt) : '(:unkn)' return c.json({ type: 'redirect' as const, url: collRow.customUrl, @@ -230,7 +229,7 @@ export async function resolve(c: Context,) { createdAt: versionRow?.createdAt, arkUrl, }, - },) + }) } if (version !== undefined && versionRow) { @@ -242,7 +241,7 @@ export async function resolve(c: Context,) { type: 'version', who: ownerName, what: `${collectionName} ${versionRow.semver}`, - when: formatErcDate(versionRow.createdAt,), + when: formatErcDate(versionRow.createdAt), where: arkUrl, naan: resolvedNaan, collectionName, @@ -256,7 +255,7 @@ export async function resolve(c: Context,) { createdAt: versionRow.createdAt, arkUrl, }, - },) + }) } // Default: redirect to collection overview @@ -268,7 +267,7 @@ export async function resolve(c: Context,) { type: 'collection', who: ownerName, what: collectionName, - when: versionRow ? formatErcDate(versionRow.createdAt,) : '(:unkn)', + when: versionRow ? formatErcDate(versionRow.createdAt) : '(:unkn)', where: arkUrl, naan: resolvedNaan, collectionName, @@ -278,238 +277,238 @@ export async function resolve(c: Context,) { createdAt: versionRow?.createdAt, arkUrl, }, - },) + }) } // --- Collection ARK settings --- -export async function getArk(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! +export async function getArk(c: Context) { + const owner = c.req.param('owner')! + const slug = c.req.param('slug')! - const [coll,] = await db + const [coll] = await db .select({ id: schema.collections.id, accountId: schema.collections.accountId, ownerNaan: schema.accounts.arkNaan, - },) - .from(schema.collections,) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id,),) - .where(and(eq(schema.accounts.slug, owner,), eq(schema.collections.slug, slug,),),) - .limit(1,) - if (!coll) return c.json({ error: 'Collection not found', }, 404,) + }) + .from(schema.collections) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) + .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) + .limit(1) + if (!coll) return c.json({ error: 'Collection not found' }, 404) // Must be owner/member - const hasAccess = await checkCollectionAccess(coll.accountId, c.get('accountId',)!,) - if (!hasAccess) return c.json({ error: 'Forbidden', }, 403,) + const hasAccess = await checkCollectionAccess(coll.accountId, c.get('accountId')!) + if (!hasAccess) return c.json({ error: 'Forbidden' }, 403) const naan = coll.ownerNaan ?? DEFAULT_NAAN - const [arkRow,] = await db + const [arkRow] = await db .select({ arkId: schema.arkCollections.arkId, enabled: schema.arkCollections.enabled, customUrl: schema.arkCollections.customUrl, shoulder: schema.arkShoulders.shoulder, - },) - .from(schema.arkCollections,) - .innerJoin( - schema.arkShoulders, - eq(schema.arkShoulders.accountId, coll.accountId,), - ) - .where(eq(schema.arkCollections.collectionId, coll.id,),) - .limit(1,) + }) + .from(schema.arkCollections) + .innerJoin(schema.arkShoulders, eq(schema.arkShoulders.accountId, coll.accountId)) + .where(eq(schema.arkCollections.collectionId, coll.id)) + .limit(1) if (!arkRow) { - return c.json({ enabled: false, customUrl: null, arkUrl: null, shoulder: null, arkId: null, },) + return c.json({ enabled: false, customUrl: null, arkUrl: null, shoulder: null, arkId: null }) } - const arkUrl = buildArkUrl(naan, arkRow.shoulder, arkRow.arkId,) + const arkUrl = buildArkUrl(naan, arkRow.shoulder, arkRow.arkId) return c.json({ enabled: arkRow.enabled, customUrl: arkRow.customUrl, arkUrl, shoulder: arkRow.shoulder, arkId: arkRow.arkId, - },) + }) } -export async function updateArk(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! - const { enabled, customUrl, } = await c.req.json() +export async function updateArk(c: Context) { + const owner = c.req.param('owner')! + const slug = c.req.param('slug')! + const { enabled, customUrl } = await c.req.json() - const [coll,] = await db - .select({ id: schema.collections.id, accountId: schema.collections.accountId, },) - .from(schema.collections,) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id,),) - .where(and(eq(schema.accounts.slug, owner,), eq(schema.collections.slug, slug,),),) - .limit(1,) - if (!coll) return c.json({ error: 'Collection not found', }, 404,) + const [coll] = await db + .select({ id: schema.collections.id, accountId: schema.collections.accountId }) + .from(schema.collections) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) + .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) + .limit(1) + if (!coll) return c.json({ error: 'Collection not found' }, 404) - const hasAccess = await checkCollectionAccess(coll.accountId, c.get('accountId',)!,) - if (!hasAccess) return c.json({ error: 'Forbidden', }, 403,) + const hasAccess = await checkCollectionAccess(coll.accountId, c.get('accountId')!) + if (!hasAccess) return c.json({ error: 'Forbidden' }, 403) - const [existing,] = await db - .select({ collectionId: schema.arkCollections.collectionId, },) - .from(schema.arkCollections,) - .where(eq(schema.arkCollections.collectionId, coll.id,),) - .limit(1,) + const [existing] = await db + .select({ collectionId: schema.arkCollections.collectionId }) + .from(schema.arkCollections) + .where(eq(schema.arkCollections.collectionId, coll.id)) + .limit(1) if (!existing) { // Collection predates ARK tables — mint now - await getOrMintShoulder(coll.accountId,) - const arkId = collectionToArkId(coll.id,) - await db.insert(schema.arkCollections,).values({ + await getOrMintShoulder(coll.accountId) + const arkId = collectionToArkId(coll.id) + await db.insert(schema.arkCollections).values({ collectionId: coll.id, arkId, enabled: enabled ?? true, customUrl: customUrl ?? null, - },) + }) } else { const updates: Record = {} if (enabled !== undefined) updates.enabled = enabled if (customUrl !== undefined) updates.customUrl = customUrl ?? null - if (Object.keys(updates,).length > 0) { + if (Object.keys(updates).length > 0) { await db - .update(schema.arkCollections,) - .set(updates,) - .where(eq(schema.arkCollections.collectionId, coll.id,),) + .update(schema.arkCollections) + .set(updates) + .where(eq(schema.arkCollections.collectionId, coll.id)) } } - return c.json({ ok: true, },) + return c.json({ ok: true }) } // --- Record type ARK settings --- -export async function getArkRecordTypes(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! +export async function getArkRecordTypes(c: Context) { + const owner = c.req.param('owner')! + const slug = c.req.param('slug')! - const [coll,] = await db - .select({ id: schema.collections.id, accountId: schema.collections.accountId, },) - .from(schema.collections,) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id,),) - .where(and(eq(schema.accounts.slug, owner,), eq(schema.collections.slug, slug,),),) - .limit(1,) - if (!coll) return c.json({ error: 'Collection not found', }, 404,) + const [coll] = await db + .select({ id: schema.collections.id, accountId: schema.collections.accountId }) + .from(schema.collections) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) + .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) + .limit(1) + if (!coll) return c.json({ error: 'Collection not found' }, 404) - const hasAccess = await checkCollectionAccess(coll.accountId, c.get('accountId',)!,) - if (!hasAccess) return c.json({ error: 'Forbidden', }, 403,) + const hasAccess = await checkCollectionAccess(coll.accountId, c.get('accountId')!) + if (!hasAccess) return c.json({ error: 'Forbidden' }, 403) const rows = await db .select({ recordType: schema.arkRecordTypes.recordType, redirectUrlField: schema.arkRecordTypes.redirectUrlField, - },) - .from(schema.arkRecordTypes,) - .where(eq(schema.arkRecordTypes.collectionId, coll.id,),) + }) + .from(schema.arkRecordTypes) + .where(eq(schema.arkRecordTypes.collectionId, coll.id)) - return c.json(rows,) + return c.json(rows) } -export async function updateArkRecordTypes(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! - const { recordType, redirectUrlField, } = await c.req.json() +export async function updateArkRecordTypes(c: Context) { + const owner = c.req.param('owner')! + const slug = c.req.param('slug')! + const { recordType, redirectUrlField } = await c.req.json() - if (!recordType) return c.json({ error: 'recordType required', }, 400,) + if (!recordType) return c.json({ error: 'recordType required' }, 400) - const [coll,] = await db - .select({ id: schema.collections.id, accountId: schema.collections.accountId, },) - .from(schema.collections,) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id,),) - .where(and(eq(schema.accounts.slug, owner,), eq(schema.collections.slug, slug,),),) - .limit(1,) - if (!coll) return c.json({ error: 'Collection not found', }, 404,) + const [coll] = await db + .select({ id: schema.collections.id, accountId: schema.collections.accountId }) + .from(schema.collections) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) + .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) + .limit(1) + if (!coll) return c.json({ error: 'Collection not found' }, 404) - const hasAccess = await checkCollectionAccess(coll.accountId, c.get('accountId',)!,) - if (!hasAccess) return c.json({ error: 'Forbidden', }, 403,) + const hasAccess = await checkCollectionAccess(coll.accountId, c.get('accountId')!) + if (!hasAccess) return c.json({ error: 'Forbidden' }, 403) if (redirectUrlField === null) { await db - .delete(schema.arkRecordTypes,) + .delete(schema.arkRecordTypes) .where( and( - eq(schema.arkRecordTypes.collectionId, coll.id,), - eq(schema.arkRecordTypes.recordType, recordType,), + eq(schema.arkRecordTypes.collectionId, coll.id), + eq(schema.arkRecordTypes.recordType, recordType), ), ) } else { await db - .insert(schema.arkRecordTypes,) - .values({ collectionId: coll.id, recordType, redirectUrlField, },) + .insert(schema.arkRecordTypes) + .values({ collectionId: coll.id, recordType, redirectUrlField }) .onConflictDoUpdate({ - target: [schema.arkRecordTypes.collectionId, schema.arkRecordTypes.recordType,], - set: { redirectUrlField, }, - },) + target: [schema.arkRecordTypes.collectionId, schema.arkRecordTypes.recordType], + set: { redirectUrlField }, + }) } - return c.json({ ok: true, },) + return c.json({ ok: true }) } // --- Org ARK NAAN --- -export async function updateAccountArk(c: Context,) { - const slug = c.req.param('slug',)! - const { naan, } = await c.req.json() +export async function updateAccountArk(c: Context) { + const slug = c.req.param('slug')! + const { naan } = await c.req.json() - if (naan !== null && !/^\d{1,16}$/.test(naan,)) { - return c.json({ error: 'NAAN must be numeric (up to 16 digits)', }, 400,) + if (naan !== null && !/^\d{1,16}$/.test(naan)) { + return c.json({ error: 'NAAN must be numeric (up to 16 digits)' }, 400) } - const [account,] = await db - .select({ id: schema.accounts.id, type: schema.accounts.type, },) - .from(schema.accounts,) - .where(eq(schema.accounts.slug, slug,),) - .limit(1,) - if (!account) return c.json({ error: 'Account not found', }, 404,) + const [account] = await db + .select({ id: schema.accounts.id, type: schema.accounts.type }) + .from(schema.accounts) + .where(eq(schema.accounts.slug, slug)) + .limit(1) + if (!account) return c.json({ error: 'Account not found' }, 404) // Must be owner/admin of the org (or the user themselves) if (account.type === 'org') { - const [membership,] = await db - .select({ role: schema.orgMemberships.role, },) - .from(schema.orgMemberships,) + const [membership] = await db + .select({ role: schema.orgMemberships.role }) + .from(schema.orgMemberships) .where( and( - eq(schema.orgMemberships.orgId, account.id,), - eq(schema.orgMemberships.userId, c.get('accountId',)!,), + eq(schema.orgMemberships.orgId, account.id), + eq(schema.orgMemberships.userId, c.get('accountId')!), ), ) - .limit(1,) + .limit(1) if (!membership || (membership.role !== 'owner' && membership.role !== 'admin')) { - return c.json({ error: 'Forbidden', }, 403,) + return c.json({ error: 'Forbidden' }, 403) } - } else if (account.id !== c.get('accountId',)) { - return c.json({ error: 'Forbidden', }, 403,) + } else if (account.id !== c.get('accountId')) { + return c.json({ error: 'Forbidden' }, 403) } - await db.update(schema.accounts,).set({ arkNaan: naan, },).where(eq(schema.accounts.id, account.id,),) - return c.json({ ok: true, },) + await db.update(schema.accounts).set({ arkNaan: naan }).where(eq(schema.accounts.id, account.id)) + return c.json({ ok: true }) } // --- Helpers --- -async function checkCollectionAccess(ownerAccountId: string, requestAccountId: string,): Promise { - const [account,] = await db - .select({ id: schema.accounts.id, type: schema.accounts.type, },) - .from(schema.accounts,) - .where(eq(schema.accounts.id, ownerAccountId,),) - .limit(1,) +async function checkCollectionAccess( + ownerAccountId: string, + requestAccountId: string, +): Promise { + const [account] = await db + .select({ id: schema.accounts.id, type: schema.accounts.type }) + .from(schema.accounts) + .where(eq(schema.accounts.id, ownerAccountId)) + .limit(1) if (!account) return false if (account.id === requestAccountId) return true if (account.type === 'org') { - const [membership,] = await db - .select({ role: schema.orgMemberships.role, },) - .from(schema.orgMemberships,) + const [membership] = await db + .select({ role: schema.orgMemberships.role }) + .from(schema.orgMemberships) .where( and( - eq(schema.orgMemberships.orgId, account.id,), - eq(schema.orgMemberships.userId, requestAccountId,), + eq(schema.orgMemberships.orgId, account.id), + eq(schema.orgMemberships.userId, requestAccountId), ), ) - .limit(1,) + .limit(1) return !!membership } return false diff --git a/src/api/auth.server.ts b/src/api/auth.server.ts index 6de4056..2fa3a38 100644 --- a/src/api/auth.server.ts +++ b/src/api/auth.server.ts @@ -1,9 +1,10 @@ import bcrypt from 'bcrypt' -import { eq, } from 'drizzle-orm' -import type { Context, MiddlewareHandler, } from 'hono' -import { deleteCookie, getCookie, setCookie, } from 'hono/cookie' -import { createMiddleware, } from 'hono/factory' -import { db, schema, } from '../db/client.server.js' +import { eq } from 'drizzle-orm' +import type { Context, MiddlewareHandler } from 'hono' +import { deleteCookie, getCookie, setCookie } from 'hono/cookie' +import { createMiddleware } from 'hono/factory' + +import { db, schema } from '../db/client.server.js' export type AuthEnv = { Variables: { @@ -14,72 +15,72 @@ export type AuthEnv = { } } -const publicPaths = new Set([ - '/api/health', - '/api/accounts/signup', - '/api/accounts/login', - '/api/accounts/forgot-password', - '/api/accounts/reset-password', - '/api/query/generate-sql', -],) +const publicPaths = new Set(['/api/health', '/api/query/generate-sql']) const internalToken = process.env.INTERNAL_API_TOKEN ?? 'internal-dev-token' +const kfInternalApiKey = process.env.KF_INTERNAL_API_KEY ?? '' const sessionSecret = process.env.SESSION_SECRET ?? 'dev-secret-change-me' -export const authMiddleware = createMiddleware(async (c, next,) => { - // Internal service calls - const internalHeader = c.req.header('x-internal-token',) +export const authMiddleware = createMiddleware(async (c, next) => { + // Internal service calls (legacy header) + const internalHeader = c.req.header('x-internal-token') if (internalHeader === internalToken) { - c.set('apiKeyScope', 'read',) + c.set('apiKeyScope', 'read') + return next() + } + + // KF Auth internal API key (used by /api/kf/* endpoints) + const auth = c.req.header('authorization') + if (kfInternalApiKey && auth === `Bearer ${kfInternalApiKey}`) { + c.set('apiKeyScope', 'admin') return next() } // API key auth via Bearer token - const auth = c.req.header('authorization',) - if (auth?.startsWith('Bearer ',)) { - const token = auth.slice(7,) - const keys = await db.select().from(schema.apiKeys,) + if (auth?.startsWith('Bearer ')) { + const token = auth.slice(7) + const keys = await db.select().from(schema.apiKeys) let matched = false for (const key of keys) { - const match = await bcrypt.compare(token, key.keyHash,) + const match = await bcrypt.compare(token, key.keyHash) if (match) { - c.set('accountId', key.accountId,) - c.set('apiKeyScope', key.scope as 'read' | 'write' | 'admin',) - c.set('apiKeyCollectionId', key.collectionId,) + c.set('accountId', key.accountId) + c.set('apiKeyScope', key.scope as 'read' | 'write' | 'admin') + c.set('apiKeyCollectionId', key.collectionId) await db - .update(schema.apiKeys,) - .set({ lastUsedAt: new Date(), },) - .where(eq(schema.apiKeys.id, key.id,),) + .update(schema.apiKeys) + .set({ lastUsedAt: new Date() }) + .where(eq(schema.apiKeys.id, key.id)) matched = true break } } if (!matched) { - return c.json({ error: 'Invalid API key', statusCode: 401, }, 401,) + return c.json({ error: 'Invalid API key', statusCode: 401 }, 401) } return next() } // Session cookie auth - const sessionCookie = getCookie(c, 'session',) + const sessionCookie = getCookie(c, 'session') if (sessionCookie) { try { // Try to parse as signed cookie (value.signature format) let sessionId = sessionCookie - const dotIdx = sessionCookie.lastIndexOf('.',) + const dotIdx = sessionCookie.lastIndexOf('.') if (dotIdx > 0) { - sessionId = sessionCookie.slice(0, dotIdx,) + sessionId = sessionCookie.slice(0, dotIdx) } if (sessionId) { - const [session,] = await db + const [session] = await db .select() - .from(schema.sessions,) - .where(eq(schema.sessions.id, sessionId,),) - .limit(1,) - if (session && new Date(session.expiresAt,) > new Date()) { - c.set('sessionUserId', session.userId,) - c.set('accountId', session.userId,) - c.set('apiKeyScope', 'admin',) + .from(schema.sessions) + .where(eq(schema.sessions.id, sessionId)) + .limit(1) + if (session && new Date(session.expiresAt) > new Date()) { + c.set('sessionUserId', session.userId) + c.set('accountId', session.userId) + c.set('apiKeyScope', 'admin') } } } catch { @@ -91,41 +92,41 @@ export const authMiddleware = createMiddleware(async (c, next,) => { if (c.req.method === 'GET') return next() // All writes require auth, except public paths - if (!c.get('accountId',)) { - const path = new URL(c.req.url,).pathname - if (publicPaths.has(path,)) return next() - return c.json({ error: 'Authentication required', statusCode: 401, }, 401,) + if (!c.get('accountId')) { + const path = new URL(c.req.url).pathname + if (publicPaths.has(path)) return next() + return c.json({ error: 'Authentication required', statusCode: 401 }, 401) } return next() -},) +}) -export function requireAuth(scope?: 'read' | 'write' | 'admin',): MiddlewareHandler { - return async (c, next,) => { - if (!c.get('accountId',)) { - return c.json({ error: 'Authentication required', statusCode: 401, }, 401,) +export function requireAuth(scope?: 'read' | 'write' | 'admin'): MiddlewareHandler { + return async (c, next) => { + if (!c.get('accountId')) { + return c.json({ error: 'Authentication required', statusCode: 401 }, 401) } - if (scope === 'admin' && c.get('apiKeyScope',) !== 'admin') { - return c.json({ error: 'Admin access required', statusCode: 403, }, 403,) + if (scope === 'admin' && c.get('apiKeyScope') !== 'admin') { + return c.json({ error: 'Admin access required', statusCode: 403 }, 403) } - if (scope === 'write' && c.get('apiKeyScope',) === 'read') { - return c.json({ error: 'Write access required', statusCode: 403, }, 403,) + if (scope === 'write' && c.get('apiKeyScope') === 'read') { + return c.json({ error: 'Write access required', statusCode: 403 }, 403) } return next() } } // Helper to set signed session cookie -export function setSessionCookie(c: Context, sessionId: string,) { +export function setSessionCookie(c: Context, sessionId: string) { setCookie(c, 'session', sessionId, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'Lax', path: '/', maxAge: 30 * 24 * 60 * 60, // 30 days - },) + }) } -export function clearSessionCookie(c: Context,) { - deleteCookie(c, 'session', { path: '/', },) +export function clearSessionCookie(c: Context) { + deleteCookie(c, 'session', { path: '/' }) } diff --git a/src/api/collections.ts b/src/api/collections.ts index b43f6b6..80b6a2e 100644 --- a/src/api/collections.ts +++ b/src/api/collections.ts @@ -1,28 +1,30 @@ -import { and, eq, ilike, or, sql, } from 'drizzle-orm' -import type { Context, } from 'hono' -import { stream, } from 'hono/streaming' -import { createGzip, } from 'node:zlib' -import { pack as tarPack, } from 'tar-stream' -import { v4 as uuidv4, } from 'uuid' -import { db, schema, } from '../db/client.server.js' -import { buildArkUrl, collectionToArkId, DEFAULT_NAAN, getOrMintShoulder, } from '../lib/ark.js' -import { downloadFromS3, } from '../lib/s3.js' -import { type AuthEnv, } from './auth.server.js' +import { createGzip } from 'node:zlib' + +import { and, eq, ilike, or, sql } from 'drizzle-orm' +import type { Context } from 'hono' +import { stream } from 'hono/streaming' +import { pack as tarPack } from 'tar-stream' +import { v4 as uuidv4 } from 'uuid' + +import { db, schema } from '../db/client.server.js' +import { buildArkUrl, collectionToArkId, DEFAULT_NAAN, getOrMintShoulder } from '../lib/ark.js' +import { downloadFromS3 } from '../lib/s3.js' +import { type AuthEnv } from './auth.server.js' // Browse public collections -export async function list(c: Context,) { - const q = c.req.query('q',) - const limit = c.req.query('limit',) - const offset = c.req.query('offset',) - const take = Math.min(parseInt(limit ?? '50', 10,), 100,) - const skip = parseInt(offset ?? '0', 10,) - - const conditions = [eq(schema.collections.public, true,),] +export async function list(c: Context) { + const q = c.req.query('q') + const limit = c.req.query('limit') + const offset = c.req.query('offset') + const take = Math.min(parseInt(limit ?? '50', 10), 100) + const skip = parseInt(offset ?? '0', 10) + + const conditions = [eq(schema.collections.public, true)] if (q) { conditions.push( or( - ilike(schema.collections.name, `%${q}%`,), - ilike(schema.collections.description, `%${q}%`,), + ilike(schema.collections.name, `%${q}%`), + ilike(schema.collections.description, `%${q}%`), )!, ) } @@ -37,21 +39,26 @@ export async function list(c: Context,) { ownerName: schema.accounts.displayName, createdAt: schema.collections.createdAt, updatedAt: schema.collections.updatedAt, - },) - .from(schema.collections,) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id,),) - .where(and(...conditions,),) - .limit(take,) - .offset(skip,) - .orderBy(schema.collections.updatedAt,) - - return c.json(results,) + }) + .from(schema.collections) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) + .where(and(...conditions)) + .limit(take) + .offset(skip) + .orderBy(schema.collections.updatedAt) + + return c.json(results) } // Create collection -export async function create(c: Context,) { - const owner = c.req.param('owner',)! - const { slug, name, description, public: isPublic, } = await c.req.json<{ +export async function create(c: Context) { + const owner = c.req.param('owner')! + const { + slug, + name, + description, + public: isPublic, + } = await c.req.json<{ slug: string name: string description?: string @@ -59,82 +66,77 @@ export async function create(c: Context,) { }>() // Resolve owner account - const [account,] = await db + const [account] = await db .select() - .from(schema.accounts,) - .where(eq(schema.accounts.slug, owner,),) - .limit(1,) + .from(schema.accounts) + .where(eq(schema.accounts.slug, owner)) + .limit(1) if (!account) { - return c.json({ error: 'Account not found', statusCode: 404, }, 404,) + return c.json({ error: 'Account not found', statusCode: 404 }, 404) } // Check permission: user must own the account or be a member of the org - if (account.type === 'user' && account.id !== c.get('accountId',)) { - return c.json({ error: 'Forbidden', statusCode: 403, }, 403,) + if (account.type === 'user' && account.id !== c.get('accountId')) { + return c.json({ error: 'Forbidden', statusCode: 403 }, 403) } if (account.type === 'org') { - const [membership,] = await db + const [membership] = await db .select() - .from(schema.orgMemberships,) + .from(schema.orgMemberships) .where( and( - eq(schema.orgMemberships.orgId, account.id,), - eq(schema.orgMemberships.userId, c.get('accountId',)!,), + eq(schema.orgMemberships.orgId, account.id), + eq(schema.orgMemberships.userId, c.get('accountId')!), ), ) - .limit(1,) + .limit(1) if (!membership) { - return c.json({ error: 'Forbidden', statusCode: 403, }, 403,) + return c.json({ error: 'Forbidden', statusCode: 403 }, 403) } } // Check for existing collection with same slug under this owner - const [existing,] = await db - .select({ id: schema.collections.id, },) - .from(schema.collections,) - .where( - and( - eq(schema.collections.accountId, account.id,), - eq(schema.collections.slug, slug,), - ), - ) - .limit(1,) + const [existing] = await db + .select({ id: schema.collections.id }) + .from(schema.collections) + .where(and(eq(schema.collections.accountId, account.id), eq(schema.collections.slug, slug))) + .limit(1) if (existing) { - return c.json({ error: 'Collection already exists', statusCode: 409, }, 409,) + return c.json({ error: 'Collection already exists', statusCode: 409 }, 409) } const id = uuidv4() - await db.insert(schema.collections,).values({ + await db.insert(schema.collections).values({ id, accountId: account.id, slug, name, description: description ?? null, public: isPublic ?? false, - },) + }) // Auto-mint ARK for the new collection try { - const shoulder = await getOrMintShoulder(account.id,) - const arkId = collectionToArkId(id,) - await db.insert(schema.arkCollections,).values({ collectionId: id, arkId, enabled: true, },) + const shoulder = await getOrMintShoulder(account.id) + const arkId = collectionToArkId(id) + await db.insert(schema.arkCollections).values({ collectionId: id, arkId, enabled: true }) const naan = account.arkNaan ?? DEFAULT_NAAN - const arkUrl = buildArkUrl(naan, shoulder, arkId,) - return c.json({ id, owner, slug, name, ark: arkUrl, }, 201,) + const arkUrl = buildArkUrl(naan, shoulder, arkId) + return c.json({ id, owner, slug, name, ark: arkUrl }, 201) } catch { // ARK minting failure is non-fatal - return c.json({ id, owner, slug, name, }, 201,) + return c.json({ id, owner, slug, name }, 201) } } // Get collection -export async function get(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! +export async function get(c: Context) { + const owner = c.req.param('owner')! + const slug = c.req.param('slug')! - const [result,] = await db + const [result] = await db .select({ id: schema.collections.id, slug: schema.collections.slug, @@ -146,50 +148,50 @@ export async function get(c: Context,) { ownerType: schema.accounts.type, createdAt: schema.collections.createdAt, updatedAt: schema.collections.updatedAt, - },) - .from(schema.collections,) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id,),) - .where(and(eq(schema.accounts.slug, owner,), eq(schema.collections.slug, slug,),),) - .limit(1,) + }) + .from(schema.collections) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) + .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) + .limit(1) if (!result) { - return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) + return c.json({ error: 'Collection not found', statusCode: 404 }, 404) } - if (!result.public && c.get('accountId',) !== result.id) { + if (!result.public && c.get('accountId') !== result.id) { // Check if user owns or is member of the owning account - const [account,] = await db + const [account] = await db .select() - .from(schema.accounts,) - .where(eq(schema.accounts.slug, owner,),) - .limit(1,) + .from(schema.accounts) + .where(eq(schema.accounts.slug, owner)) + .limit(1) if (!account) { - return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) + return c.json({ error: 'Collection not found', statusCode: 404 }, 404) } - let hasAccess = account.id === c.get('accountId',) + let hasAccess = account.id === c.get('accountId') if (!hasAccess && account.type === 'org') { - const [membership,] = await db + const [membership] = await db .select() - .from(schema.orgMemberships,) + .from(schema.orgMemberships) .where( and( - eq(schema.orgMemberships.orgId, account.id,), - eq(schema.orgMemberships.userId, c.get('accountId',)!,), + eq(schema.orgMemberships.orgId, account.id), + eq(schema.orgMemberships.userId, c.get('accountId')!), ), ) - .limit(1,) + .limit(1) hasAccess = !!membership } if (!hasAccess) { - return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) + return c.json({ error: 'Collection not found', statusCode: 404 }, 404) } } // Get latest version info - const [latestVersion,] = await db + const [latestVersion] = await db .select({ id: schema.versions.id, number: schema.versions.number, @@ -200,11 +202,11 @@ export async function get(c: Context,) { createdAt: schema.versions.createdAt, message: schema.versions.message, readme: schema.versions.readme, - },) - .from(schema.versions,) - .where(eq(schema.versions.collectionId, result.id,),) - .orderBy(sql`${schema.versions.number} desc`,) - .limit(1,) + }) + .from(schema.versions) + .where(eq(schema.versions.collectionId, result.id)) + .orderBy(sql`${schema.versions.number} desc`) + .limit(1) // Get per-type record counts for latest version let typeCounts: { type: string; count: number }[] = [] @@ -213,138 +215,283 @@ export async function get(c: Context,) { .select({ type: schema.records.type, count: sql`count(*)::int`, - },) - .from(schema.records,) - .where(eq(schema.records.versionId, latestVersion.id,),) - .groupBy(schema.records.type,) - typeCounts = rows.map((r,) => ({ type: r.type, count: r.count, })) + }) + .from(schema.records) + .where(eq(schema.records.versionId, latestVersion.id)) + .groupBy(schema.records.type) + typeCounts = rows.map((r) => ({ type: r.type, count: r.count })) } // Fetch ARK URL if enabled let ark: string | null = null try { - const [arkRow,] = await db + const [arkRow] = await db .select({ arkId: schema.arkCollections.arkId, enabled: schema.arkCollections.enabled, shoulder: schema.arkShoulders.shoulder, ownerNaan: schema.accounts.arkNaan, - },) - .from(schema.arkCollections,) - .innerJoin(schema.collections, eq(schema.arkCollections.collectionId, schema.collections.id,),) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id,),) - .innerJoin(schema.arkShoulders, eq(schema.arkShoulders.accountId, schema.accounts.id,),) - .where(eq(schema.arkCollections.collectionId, result.id,),) - .limit(1,) + }) + .from(schema.arkCollections) + .innerJoin(schema.collections, eq(schema.arkCollections.collectionId, schema.collections.id)) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) + .innerJoin(schema.arkShoulders, eq(schema.arkShoulders.accountId, schema.accounts.id)) + .where(eq(schema.arkCollections.collectionId, result.id)) + .limit(1) if (arkRow?.enabled) { - ark = buildArkUrl(arkRow.ownerNaan ?? DEFAULT_NAAN, arkRow.shoulder, arkRow.arkId,) + ark = buildArkUrl(arkRow.ownerNaan ?? DEFAULT_NAAN, arkRow.shoulder, arkRow.arkId) } } catch { // Non-fatal } - const { id: _vid, ...latestVersionData } = latestVersion ?? { id: undefined, } - return c.json({ ...result, ark, latestVersion: latestVersion ? { ...latestVersionData, typeCounts, } : null, },) + const { id: _vid, ...latestVersionData } = latestVersion ?? { id: undefined } + return c.json({ + ...result, + ark, + latestVersion: latestVersion ? { ...latestVersionData, typeCounts } : null, + }) } // Update collection -export async function update(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! +export async function update(c: Context) { + const owner = c.req.param('owner')! + const slug = c.req.param('slug')! const updates = await c.req.json<{ name?: string + slug?: string description?: string public?: boolean }>() - const [account,] = await db + const [account] = await db .select() - .from(schema.accounts,) - .where(eq(schema.accounts.slug, owner,),) - .limit(1,) + .from(schema.accounts) + .where(eq(schema.accounts.slug, owner)) + .limit(1) if (!account) { - return c.json({ error: 'Not found', statusCode: 404, }, 404,) + return c.json({ error: 'Not found', statusCode: 404 }, 404) } - const [collection,] = await db + const [collection] = await db .select() - .from(schema.collections,) - .where(and(eq(schema.collections.accountId, account.id,), eq(schema.collections.slug, slug,),),) - .limit(1,) + .from(schema.collections) + .where(and(eq(schema.collections.accountId, account.id), eq(schema.collections.slug, slug))) + .limit(1) if (!collection) { - return c.json({ error: 'Not found', statusCode: 404, }, 404,) + return c.json({ error: 'Not found', statusCode: 404 }, 404) + } + + // Validate new slug if provided + if (updates.slug !== undefined) { + const newSlug = updates.slug + if (!newSlug || typeof newSlug !== 'string') { + return c.json({ error: 'Slug is required', statusCode: 422 }, 422) + } + if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(newSlug)) { + return c.json( + { error: 'Slug must be lowercase alphanumeric with hyphens', statusCode: 422 }, + 422, + ) + } + // Check uniqueness within same account + const [existing] = await db + .select({ id: schema.collections.id }) + .from(schema.collections) + .where( + and(eq(schema.collections.accountId, account.id), eq(schema.collections.slug, newSlug)), + ) + .limit(1) + + if (existing && existing.id !== collection.id) { + return c.json({ error: 'A collection with that slug already exists', statusCode: 409 }, 409) + } } await db - .update(schema.collections,) - .set({ ...updates, updatedAt: new Date(), },) - .where(eq(schema.collections.id, collection.id,),) + .update(schema.collections) + .set({ ...updates, updatedAt: new Date() }) + .where(eq(schema.collections.id, collection.id)) - return c.json({ ok: true, },) + return c.json({ ok: true, slug: updates.slug ?? slug }) } // Delete collection -export async function remove(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! +export async function remove(c: Context) { + const owner = c.req.param('owner')! + const slug = c.req.param('slug')! - const [account,] = await db + const [account] = await db .select() - .from(schema.accounts,) - .where(eq(schema.accounts.slug, owner,),) - .limit(1,) + .from(schema.accounts) + .where(eq(schema.accounts.slug, owner)) + .limit(1) if (!account) { - return c.json({ error: 'Not found', statusCode: 404, }, 404,) + return c.json({ error: 'Not found', statusCode: 404 }, 404) } - const [collection,] = await db + const [collection] = await db .select() - .from(schema.collections,) - .where(and(eq(schema.collections.accountId, account.id,), eq(schema.collections.slug, slug,),),) - .limit(1,) + .from(schema.collections) + .where(and(eq(schema.collections.accountId, account.id), eq(schema.collections.slug, slug))) + .limit(1) if (!collection) { - return c.json({ error: 'Not found', statusCode: 404, }, 404,) + return c.json({ error: 'Not found', statusCode: 404 }, 404) + } + + await db.delete(schema.collections).where(eq(schema.collections.id, collection.id)) + return c.json({ ok: true }) +} + +// Transfer collection to another account +export async function transfer(c: Context) { + const owner = c.req.param('owner')! + const slug = c.req.param('slug')! + const { targetAccountSlug } = await c.req.json() + + if (!targetAccountSlug || typeof targetAccountSlug !== 'string') { + return c.json({ error: 'targetAccountSlug is required', statusCode: 422 }, 422) + } + + const callerId = c.get('accountId')! + + // Find source account + const [sourceAccount] = await db + .select() + .from(schema.accounts) + .where(eq(schema.accounts.slug, owner)) + .limit(1) + + if (!sourceAccount) return c.json({ error: 'Source account not found', statusCode: 404 }, 404) + + // Verify caller has access to source account + const callerIsSource = sourceAccount.id === callerId + let callerHasSourceAccess = callerIsSource + if (!callerIsSource && sourceAccount.type === 'org') { + const [membership] = await db + .select() + .from(schema.orgMemberships) + .where( + and( + eq(schema.orgMemberships.orgId, sourceAccount.id), + eq(schema.orgMemberships.userId, callerId), + ), + ) + .limit(1) + callerHasSourceAccess = + !!membership && (membership.role === 'owner' || membership.role === 'admin') + } + if (!callerHasSourceAccess) { + return c.json( + { error: 'You must be an owner or admin of the source account', statusCode: 403 }, + 403, + ) + } + + // Find target account + const [targetAccount] = await db + .select() + .from(schema.accounts) + .where(eq(schema.accounts.slug, targetAccountSlug)) + .limit(1) + + if (!targetAccount) return c.json({ error: 'Target account not found', statusCode: 404 }, 404) + + // Verify caller has access to target account + const callerIsTarget = targetAccount.id === callerId + let callerHasTargetAccess = callerIsTarget + if (!callerIsTarget && targetAccount.type === 'org') { + const [membership] = await db + .select() + .from(schema.orgMemberships) + .where( + and( + eq(schema.orgMemberships.orgId, targetAccount.id), + eq(schema.orgMemberships.userId, callerId), + ), + ) + .limit(1) + callerHasTargetAccess = + !!membership && (membership.role === 'owner' || membership.role === 'admin') + } + if (!callerHasTargetAccess) { + return c.json( + { error: 'You must be an owner or admin of the target account', statusCode: 403 }, + 403, + ) } - await db.delete(schema.collections,).where(eq(schema.collections.id, collection.id,),) - return c.json({ ok: true, },) + // Find collection + const [collection] = await db + .select() + .from(schema.collections) + .where( + and(eq(schema.collections.accountId, sourceAccount.id), eq(schema.collections.slug, slug)), + ) + .limit(1) + + if (!collection) return c.json({ error: 'Collection not found', statusCode: 404 }, 404) + + // Check slug uniqueness in target account + const [existing] = await db + .select({ id: schema.collections.id }) + .from(schema.collections) + .where( + and(eq(schema.collections.accountId, targetAccount.id), eq(schema.collections.slug, slug)), + ) + .limit(1) + + if (existing) { + return c.json( + { error: `Target account already has a collection with slug "${slug}"`, statusCode: 409 }, + 409, + ) + } + + // Transfer + await db + .update(schema.collections) + .set({ accountId: targetAccount.id, updatedAt: new Date() }) + .where(eq(schema.collections.id, collection.id)) + + return c.json({ ok: true, newOwner: targetAccountSlug }) } // List collections for an account -export async function listByOwner(c: Context,) { - const owner = c.req.param('owner',)! +export async function listByOwner(c: Context) { + const owner = c.req.param('owner')! - const [account,] = await db + const [account] = await db .select() - .from(schema.accounts,) - .where(eq(schema.accounts.slug, owner,),) - .limit(1,) + .from(schema.accounts) + .where(eq(schema.accounts.slug, owner)) + .limit(1) - if (!account) return c.json([],) + if (!account) return c.json([]) // Check if the requester owns this account or is an org member - let hasFullAccess = c.get('accountId',) === account.id - if (!hasFullAccess && account.type === 'org' && c.get('accountId',)) { - const [membership,] = await db + let hasFullAccess = c.get('accountId') === account.id + if (!hasFullAccess && account.type === 'org' && c.get('accountId')) { + const [membership] = await db .select() - .from(schema.orgMemberships,) + .from(schema.orgMemberships) .where( and( - eq(schema.orgMemberships.orgId, account.id,), - eq(schema.orgMemberships.userId, c.get('accountId',)!,), + eq(schema.orgMemberships.orgId, account.id), + eq(schema.orgMemberships.userId, c.get('accountId')!), ), ) - .limit(1,) + .limit(1) hasFullAccess = !!membership } - const conditions = [eq(schema.collections.accountId, account.id,),] + const conditions = [eq(schema.collections.accountId, account.id)] if (!hasFullAccess) { - conditions.push(eq(schema.collections.public, true,),) + conditions.push(eq(schema.collections.public, true)) } const results = await db @@ -356,22 +503,22 @@ export async function listByOwner(c: Context,) { public: schema.collections.public, createdAt: schema.collections.createdAt, updatedAt: schema.collections.updatedAt, - },) - .from(schema.collections,) - .where(and(...conditions,),) - .orderBy(schema.collections.updatedAt,) + }) + .from(schema.collections) + .where(and(...conditions)) + .orderBy(schema.collections.updatedAt) - return c.json(results,) + return c.json(results) } // Export collection as .tar.gz archive -export async function exportArchive(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! - const versionParam = c.req.query('version',) +export async function exportArchive(c: Context) { + const owner = c.req.param('owner')! + const slug = c.req.param('slug')! + const versionParam = c.req.query('version') // Resolve collection - const [collection,] = await db + const [collection] = await db .select({ id: schema.collections.id, slug: schema.collections.slug, @@ -379,35 +526,35 @@ export async function exportArchive(c: Context,) { description: schema.collections.description, public: schema.collections.public, accountId: schema.collections.accountId, - },) - .from(schema.collections,) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id,),) - .where(and(eq(schema.accounts.slug, owner,), eq(schema.collections.slug, slug,),),) - .limit(1,) + }) + .from(schema.collections) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) + .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) + .limit(1) if (!collection) { - return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) + return c.json({ error: 'Collection not found', statusCode: 404 }, 404) } - if (!collection.public && c.get('accountId',) !== collection.accountId) { - return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) + if (!collection.public && c.get('accountId') !== collection.accountId) { + return c.json({ error: 'Collection not found', statusCode: 404 }, 404) } // Resolve version (latest if not specified) - const versionConditions = [eq(schema.versions.collectionId, collection.id,),] + const versionConditions = [eq(schema.versions.collectionId, collection.id)] if (versionParam) { - versionConditions.push(eq(schema.versions.number, parseInt(versionParam, 10,),),) + versionConditions.push(eq(schema.versions.number, parseInt(versionParam, 10))) } - const [version,] = await db + const [version] = await db .select() - .from(schema.versions,) - .where(and(...versionConditions,),) - .orderBy(sql`${schema.versions.number} desc`,) - .limit(1,) + .from(schema.versions) + .where(and(...versionConditions)) + .orderBy(sql`${schema.versions.number} desc`) + .limit(1) if (!version) { - return c.json({ error: 'No versions found', statusCode: 404, }, 404,) + return c.json({ error: 'No versions found', statusCode: 404 }, 404) } // Fetch records and files for this version @@ -416,9 +563,9 @@ export async function exportArchive(c: Context,) { recordId: schema.records.recordId, type: schema.records.type, data: schema.records.data, - },) - .from(schema.records,) - .where(eq(schema.records.versionId, version.id,),) + }) + .from(schema.records) + .where(eq(schema.records.versionId, version.id)) const versionFiles = await db .select({ @@ -426,28 +573,26 @@ export async function exportArchive(c: Context,) { size: schema.files.size, mimeType: schema.files.mimeType, storageKey: schema.files.storageKey, - },) - .from(schema.versionFiles,) - .innerJoin(schema.files, eq(schema.versionFiles.fileHash, schema.files.hash,),) - .where(eq(schema.versionFiles.versionId, version.id,),) + }) + .from(schema.versionFiles) + .innerJoin(schema.files, eq(schema.versionFiles.fileHash, schema.files.hash)) + .where(eq(schema.versionFiles.versionId, version.id)) // Load schemas for this version const versionSchemaEntries = await db .select({ slug: schema.versionSchemas.slug, schemaBody: schema.schemas.schema, - },) - .from(schema.versionSchemas,) - .innerJoin(schema.schemas, eq(schema.versionSchemas.schemaId, schema.schemas.id,),) - .where(eq(schema.versionSchemas.versionId, version.id,),) + }) + .from(schema.versionSchemas) + .innerJoin(schema.schemas, eq(schema.versionSchemas.schemaId, schema.schemas.id)) + .where(eq(schema.versionSchemas.versionId, version.id)) - const schemasMap = Object.fromEntries( - versionSchemaEntries.map((e,) => [e.slug, e.schemaBody,]), - ) + const schemasMap = Object.fromEntries(versionSchemaEntries.map((e) => [e.slug, e.schemaBody])) // Add manifest.json const manifest = { - collection: { owner, slug, name: collection.name, description: collection.description, }, + collection: { owner, slug, name: collection.name, description: collection.description }, version: { number: version.number, semver: version.semver, @@ -468,28 +613,30 @@ export async function exportArchive(c: Context,) { const filename = `${owner}-${slug}-v${version.number}.tar.gz` // Add manifest - const manifestBuf = Buffer.from(JSON.stringify(manifest, null, 2,),) - pack.entry({ name: 'manifest.json', size: manifestBuf.length, }, manifestBuf,) + const manifestBuf = Buffer.from(JSON.stringify(manifest, null, 2)) + pack.entry({ name: 'manifest.json', size: manifestBuf.length }, manifestBuf) // Add records as NDJSON grouped by type const recordsByType = new Map() for (const rec of records) { - const existing = recordsByType.get(rec.type,) ?? [] - existing.push(rec,) - recordsByType.set(rec.type, existing,) + const existing = recordsByType.get(rec.type) ?? [] + existing.push(rec) + recordsByType.set(rec.type, existing) } - for (const [type, typeRecords,] of recordsByType) { - const lines = typeRecords.map((r,) => JSON.stringify({ id: r.recordId, type: r.type, data: r.data, },)) - const buf = Buffer.from(lines.join('\n',) + '\n',) - pack.entry({ name: `records/${type}.ndjson`, size: buf.length, }, buf,) + for (const [type, typeRecords] of recordsByType) { + const lines = typeRecords.map((r) => + JSON.stringify({ id: r.recordId, type: r.type, data: r.data }), + ) + const buf = Buffer.from(lines.join('\n') + '\n') + pack.entry({ name: `records/${type}.ndjson`, size: buf.length }, buf) } // Add files for (const file of versionFiles) { try { - const fileBuffer = await downloadFromS3(file.storageKey,) - pack.entry({ name: `files/${file.hash}`, size: fileBuffer.length, }, fileBuffer,) + const fileBuffer = await downloadFromS3(file.storageKey) + pack.entry({ name: `files/${file.hash}`, size: fileBuffer.length }, fileBuffer) } catch { // Skip files that can't be downloaded (shouldn't happen in normal operation) } @@ -498,23 +645,23 @@ export async function exportArchive(c: Context,) { pack.finalize() // Pipe tar → gzip and collect into a ReadableStream - const outputStream = pack.pipe(gzip,) + const outputStream = pack.pipe(gzip) const readableStream = new ReadableStream({ - start(controller,) { - outputStream.on('data', (chunk: Buffer,) => { - controller.enqueue(new Uint8Array(chunk,),) - },) + start(controller) { + outputStream.on('data', (chunk: Buffer) => { + controller.enqueue(new Uint8Array(chunk)) + }) outputStream.on('end', () => { controller.close() - },) - outputStream.on('error', (err,) => { - controller.error(err,) - },) + }) + outputStream.on('error', (err) => { + controller.error(err) + }) }, - },) + }) return c.body(readableStream, 200, { 'Content-Type': 'application/gzip', 'Content-Disposition': `attachment; filename="${filename}"`, - },) + }) } diff --git a/src/api/files.ts b/src/api/files.ts index cae4f49..ea3aac6 100644 --- a/src/api/files.ts +++ b/src/api/files.ts @@ -1,9 +1,11 @@ -import { and, eq, sql, } from 'drizzle-orm' -import type { Context, } from 'hono' -import { createHash, } from 'node:crypto' -import { db, schema, } from '../db/client.server.js' -import { getS3ObjectMeta, uploadToS3, } from '../lib/s3.js' -import { type AuthEnv, } from './auth.server.js' +import { createHash } from 'node:crypto' + +import { and, eq, sql } from 'drizzle-orm' +import type { Context } from 'hono' + +import { db, schema } from '../db/client.server.js' +import { getS3ObjectMeta, uploadToS3 } from '../lib/s3.js' +import { type AuthEnv } from './auth.server.js' /** * Check if a file hash is referenced by any public (non-private) record @@ -16,15 +18,15 @@ async function isFilePubliclyAccessible( accountId: string | undefined, ): Promise { // Resolve collection - const [collection,] = await db + const [collection] = await db .select({ id: schema.collections.id, accountId: schema.collections.accountId, - },) - .from(schema.collections,) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id,),) - .where(and(eq(schema.accounts.slug, owner,), eq(schema.collections.slug, slug,),),) - .limit(1,) + }) + .from(schema.collections) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) + .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) + .limit(1) if (!collection) return false @@ -34,23 +36,23 @@ async function isFilePubliclyAccessible( } // Get the latest version - const [latest,] = await db - .select({ id: schema.versions.id, },) - .from(schema.versions,) - .where(eq(schema.versions.collectionId, collection.id,),) - .orderBy(sql`${schema.versions.number} desc`,) - .limit(1,) + const [latest] = await db + .select({ id: schema.versions.id }) + .from(schema.versions) + .where(eq(schema.versions.collectionId, collection.id)) + .orderBy(sql`${schema.versions.number} desc`) + .limit(1) if (!latest) return false // Check if file is associated with this version at all - const [vf,] = await db - .select({ fileHash: schema.versionFiles.fileHash, },) - .from(schema.versionFiles,) + const [vf] = await db + .select({ fileHash: schema.versionFiles.fileHash }) + .from(schema.versionFiles) .where( - and(eq(schema.versionFiles.versionId, latest.id,), eq(schema.versionFiles.fileHash, fileHash,),), + and(eq(schema.versionFiles.versionId, latest.id), eq(schema.versionFiles.fileHash, fileHash)), ) - .limit(1,) + .limit(1) if (!vf) return false @@ -59,56 +61,56 @@ async function isFilePubliclyAccessible( .select({ slug: schema.versionSchemas.slug, schemaBody: schema.schemas.schema, - },) - .from(schema.versionSchemas,) - .innerJoin(schema.schemas, eq(schema.versionSchemas.schemaId, schema.schemas.id,),) - .where(eq(schema.versionSchemas.versionId, latest.id,),) + }) + .from(schema.versionSchemas) + .innerJoin(schema.schemas, eq(schema.versionSchemas.schemaId, schema.schemas.id)) + .where(eq(schema.versionSchemas.versionId, latest.id)) const privateTypes = new Set() const typeSchemaMap = new Map>() for (const entry of schemaEntries) { const body = entry.schemaBody as Record - typeSchemaMap.set(entry.slug, body,) - if (body?.private === true) privateTypes.add(entry.slug,) + typeSchemaMap.set(entry.slug, body) + if (body?.private === true) privateTypes.add(entry.slug) } // Find public records that reference this file hash // A record references a file if its data JSON contains the hash string const records = await db - .select({ type: schema.records.type, data: schema.records.data, },) - .from(schema.records,) + .select({ type: schema.records.type, data: schema.records.data }) + .from(schema.records) .where( and( - eq(schema.records.versionId, latest.id,), - eq(schema.records.private, false,), + eq(schema.records.versionId, latest.id), + eq(schema.records.private, false), sql`${schema.records.data}::text LIKE ${'%' + fileHash + '%'}`, ), ) - .limit(10,) + .limit(10) // Check if any matching record is a public type with the file in a public field for (const rec of records) { - if (privateTypes.has(rec.type,)) continue + if (privateTypes.has(rec.type)) continue // Get private fields for this type - const typeSchema = typeSchemaMap.get(rec.type,) + const typeSchema = typeSchemaMap.get(rec.type) const typeProps = typeSchema?.properties as Record | undefined if (!typeProps) return true // no schema constraints, allow const privateFields = new Set() - for (const [fieldName, fieldDef,] of Object.entries(typeProps,)) { - if ((fieldDef as any)?.private === true) privateFields.add(fieldName,) + for (const [fieldName, fieldDef] of Object.entries(typeProps)) { + if ((fieldDef as any)?.private === true) privateFields.add(fieldName) } // Check if the file reference is in a public field const data = rec.data as Record - for (const [key, val,] of Object.entries(data,)) { - if (privateFields.has(key,)) continue + for (const [key, val] of Object.entries(data)) { + if (privateFields.has(key)) continue if ( - val - && typeof val === 'object' - && '$file' in val - && (val as { $file: string }).$file === `sha256:${fileHash}` + val && + typeof val === 'object' && + '$file' in val && + (val as { $file: string }).$file === `sha256:${fileHash}` ) { return true // found in a public field of a public record } @@ -119,136 +121,142 @@ async function isFilePubliclyAccessible( } // Check if file exists -export async function headFile(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! - const hash = c.req.param('hash',)! - const cleanHash = hash.replace('sha256:', '',) +export async function headFile(c: Context) { + const owner = c.req.param('owner')! + const slug = c.req.param('slug')! + const hash = c.req.param('hash')! + const cleanHash = hash.replace('sha256:', '') - const [file,] = await db + const [file] = await db .select() - .from(schema.files,) - .where(eq(schema.files.hash, cleanHash,),) - .limit(1,) + .from(schema.files) + .where(eq(schema.files.hash, cleanHash)) + .limit(1) if (!file) { - return c.body(null, 404,) + return c.body(null, 404) } // Check visibility - const accessible = await isFilePubliclyAccessible(owner, slug, cleanHash, c.get('accountId',),) + const accessible = await isFilePubliclyAccessible(owner, slug, cleanHash, c.get('accountId')) if (!accessible) { - return c.body(null, 404,) + return c.body(null, 404) } - c.header('Content-Length', String(file.size,),) - c.header('Content-Type', file.mimeType,) - return c.body(null, 200,) + c.header('Content-Length', String(file.size)) + c.header('Content-Type', file.mimeType) + return c.body(null, 200) } // Download file -export async function getFile(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! - const hash = c.req.param('hash',)! - const cleanHash = hash.replace('sha256:', '',) +export async function getFile(c: Context) { + const owner = c.req.param('owner')! + const slug = c.req.param('slug')! + const hash = c.req.param('hash')! + const cleanHash = hash.replace('sha256:', '') - const [file,] = await db + const [file] = await db .select() - .from(schema.files,) - .where(eq(schema.files.hash, cleanHash,),) - .limit(1,) + .from(schema.files) + .where(eq(schema.files.hash, cleanHash)) + .limit(1) if (!file) { - return c.json({ error: 'File not found', statusCode: 404, }, 404,) + return c.json({ error: 'File not found', statusCode: 404 }, 404) } // Check visibility - const accessible = await isFilePubliclyAccessible(owner, slug, cleanHash, c.get('accountId',),) + const accessible = await isFilePubliclyAccessible(owner, slug, cleanHash, c.get('accountId')) if (!accessible) { - return c.json({ error: 'File not found', statusCode: 404, }, 404,) + return c.json({ error: 'File not found', statusCode: 404 }, 404) } // Redirect to CDN - const cdnUrl = `https://assets.underlay.org/files/${cleanHash.slice(0, 2,)}/${cleanHash.slice(2, 4,)}/${cleanHash}` - return c.redirect(cdnUrl,) + const cdnUrl = `https://assets.underlay.org/files/${cleanHash.slice(0, 2)}/${cleanHash.slice(2, 4)}/${cleanHash}` + return c.redirect(cdnUrl) } // Upload file -export async function putFile(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! - const hash = c.req.param('hash',)! - const cleanHash = hash.replace('sha256:', '',) +export async function putFile(c: Context) { + const owner = c.req.param('owner')! + const slug = c.req.param('slug')! + const hash = c.req.param('hash')! + const cleanHash = hash.replace('sha256:', '') // Check if file already exists in DB - const [existing,] = await db + const [existing] = await db .select() - .from(schema.files,) - .where(eq(schema.files.hash, cleanHash,),) - .limit(1,) + .from(schema.files) + .where(eq(schema.files.hash, cleanHash)) + .limit(1) if (existing) { - return c.json({ hash: cleanHash, status: 'exists', }, 200,) + return c.json({ hash: cleanHash, status: 'exists' }, 200) } // Check if file exists in S3 but not in local DB (shared bucket scenario) - const s3Key = `files/${cleanHash.slice(0, 2,)}/${cleanHash.slice(2, 4,)}/${cleanHash}` - const s3Meta = await getS3ObjectMeta(s3Key,) + const s3Key = `files/${cleanHash.slice(0, 2)}/${cleanHash.slice(2, 4)}/${cleanHash}` + const s3Meta = await getS3ObjectMeta(s3Key) if (s3Meta !== null) { - await db.insert(schema.files,).values({ - hash: cleanHash, - size: s3Meta.size, - mimeType: s3Meta.contentType, - storageKey: s3Key, - },).onConflictDoNothing() - return c.json({ hash: cleanHash, status: 'exists', }, 200,) + await db + .insert(schema.files) + .values({ + hash: cleanHash, + size: s3Meta.size, + mimeType: s3Meta.contentType, + storageKey: s3Key, + }) + .onConflictDoNothing() + return c.json({ hash: cleanHash, status: 'exists' }, 200) } // Try multipart first - const contentType = c.req.header('content-type',) ?? 'application/octet-stream' + const contentType = c.req.header('content-type') ?? 'application/octet-stream' let buffer: Buffer let mimeType: string - if (contentType.startsWith('multipart/',)) { + if (contentType.startsWith('multipart/')) { const body = await c.req.parseBody() const file = body['file'] if (file instanceof File) { const ab = await file.arrayBuffer() - buffer = Buffer.from(ab,) + buffer = Buffer.from(ab) mimeType = file.type || 'application/octet-stream' } else { - return c.json({ error: 'No file in multipart body', statusCode: 400, }, 400,) + return c.json({ error: 'No file in multipart body', statusCode: 400 }, 400) } } else { // Raw binary body const ab = await c.req.arrayBuffer() - buffer = Buffer.from(ab,) + buffer = Buffer.from(ab) mimeType = contentType } // Verify hash - const computedHash = createHash('sha256',).update(buffer,).digest('hex',) + const computedHash = createHash('sha256').update(buffer).digest('hex') if (computedHash !== cleanHash) { - return c.json({ - error: 'Hash mismatch', - expected: cleanHash, - computed: computedHash, - statusCode: 400, - }, 400,) + return c.json( + { + error: 'Hash mismatch', + expected: cleanHash, + computed: computedHash, + statusCode: 400, + }, + 400, + ) } - const storageKey = `files/${cleanHash.slice(0, 2,)}/${cleanHash.slice(2, 4,)}/${cleanHash}` + const storageKey = `files/${cleanHash.slice(0, 2)}/${cleanHash.slice(2, 4)}/${cleanHash}` - await uploadToS3(storageKey, buffer, mimeType,) + await uploadToS3(storageKey, buffer, mimeType) - await db.insert(schema.files,).values({ + await db.insert(schema.files).values({ hash: cleanHash, size: buffer.length, mimeType, storageKey, - },) + }) - return c.json({ hash: cleanHash, size: buffer.length, }, 201,) + return c.json({ hash: cleanHash, size: buffer.length }, 201) } diff --git a/src/api/health.ts b/src/api/health.ts index 24bd286..648041b 100644 --- a/src/api/health.ts +++ b/src/api/health.ts @@ -1,5 +1,5 @@ -import type { Context, } from 'hono' +import type { Context } from 'hono' -export async function check(c: Context,) { - return c.json({ status: 'ok', timestamp: new Date().toISOString(), },) +export async function check(c: Context) { + return c.json({ status: 'ok', timestamp: new Date().toISOString() }) } diff --git a/src/api/kf-auth.ts b/src/api/kf-auth.ts new file mode 100644 index 0000000..a6d367a --- /dev/null +++ b/src/api/kf-auth.ts @@ -0,0 +1,190 @@ +import crypto from 'node:crypto' + +import { eq } from 'drizzle-orm' +import type { Context } from 'hono' +import { deleteCookie, getCookie, setCookie } from 'hono/cookie' +import { v4 as uuidv4 } from 'uuid' + +import { db, schema } from '../db/client.server.js' +import { + buildAuthorizeUrl, + exchangeCode, + fetchUserInfo, + type KFOrg, +} from '../lib/kf-auth.server.js' +import { type AuthEnv, setSessionCookie } from './auth.server.js' + +const STATE_COOKIE = 'kf_oauth_state' +const RETURN_COOKIE = 'kf_oauth_return' +const VERIFIER_COOKIE = 'kf_oauth_verifier' + +/** + * GET /auth/login — redirect to KF Auth with CSRF state + PKCE. + * Optional ?return_to= query param preserved for post-login redirect. + */ +export async function login(c: Context) { + const state = crypto.randomBytes(24).toString('hex') + const returnTo = c.req.query('return_to') ?? '/dashboard' + + const cookieOpts = { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'Lax' as const, + path: '/', + maxAge: 600, // 10 minutes + } + + setCookie(c, STATE_COOKIE, state, cookieOpts) + setCookie(c, RETURN_COOKIE, returnTo, cookieOpts) + + const { url, codeVerifier } = buildAuthorizeUrl(state) + setCookie(c, VERIFIER_COOKIE, codeVerifier, cookieOpts) + + return c.redirect(url) +} + +/** + * GET /auth/callback — handle the OIDC callback. + * Exchanges the code for tokens, fetches user info, upserts the local + * account, creates a session, and redirects to the return URL. + */ +export async function callback(c: Context) { + const code = c.req.query('code') + const state = c.req.query('state') + const error = c.req.query('error') + + if (error) { + console.error('KF Auth returned error:', error, c.req.query('error_description')) + return c.redirect('/login?error=auth_failed') + } + + if (!code || !state) { + return c.redirect('/login?error=missing_params') + } + + // Validate CSRF state + const savedState = getCookie(c, STATE_COOKIE) + const codeVerifier = getCookie(c, VERIFIER_COOKIE) + deleteCookie(c, STATE_COOKIE, { path: '/' }) + deleteCookie(c, VERIFIER_COOKIE, { path: '/' }) + + if (!savedState || savedState !== state) { + return c.redirect('/login?error=invalid_state') + } + + if (!codeVerifier) { + return c.redirect('/login?error=missing_verifier') + } + + // Exchange code for tokens + let accessToken: string + try { + const tokens = await exchangeCode(code, codeVerifier) + accessToken = tokens.access_token + } catch (err) { + console.error('Token exchange failed:', err) + return c.redirect('/login?error=token_exchange') + } + + // Fetch user info + let userInfo: Awaited> + try { + userInfo = await fetchUserInfo(accessToken) + } catch (err) { + console.error('UserInfo fetch failed:', err) + return c.redirect('/login?error=userinfo') + } + + // Find or create local account. + // User account id IS the KF Auth user id (userInfo.sub). + // No profile data stored locally — fetched from KF Auth on demand. + const kfUserId = userInfo.sub + const kfOrgs: KFOrg[] = userInfo['https://knowledgefutures.org/orgs'] ?? [] + const kfPersonalOrg = kfOrgs.find((o) => o.type === 'personal') + const accountId = kfUserId + + const [existing] = await db + .select({ id: schema.accounts.id }) + .from(schema.accounts) + .where(eq(schema.accounts.id, kfUserId)) + .limit(1) + + if (existing) { + // Update personal org link if it changed + if (kfPersonalOrg) { + await db + .update(schema.accounts) + .set({ kfOrgId: kfPersonalOrg.id }) + .where(eq(schema.accounts.id, accountId)) + } + } else { + // Create new account — generate a slug from email or name + const baseSlug = (userInfo.email?.split('@')[0] ?? userInfo.name ?? 'user') + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-') + .replace(/-+/g, '-') + .slice(0, 30) + + // Ensure slug is unique + let slug = baseSlug + let attempt = 0 + while (true) { + const [conflict] = await db + .select({ id: schema.accounts.id }) + .from(schema.accounts) + .where(eq(schema.accounts.slug, slug)) + .limit(1) + + if (!conflict) break + attempt++ + slug = `${baseSlug}-${attempt}` + } + + await db.insert(schema.accounts).values({ + id: kfUserId, + slug, + type: 'user', + kfOrgId: kfPersonalOrg?.id ?? null, + }) + } + + // Create session + const sessionId = uuidv4() + const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 days + await db.insert(schema.sessions).values({ + id: sessionId, + userId: accountId, + expiresAt, + userAgent: c.req.header('user-agent') ?? null, + ipAddress: c.req.header('x-forwarded-for') || 'unknown', + }) + + setSessionCookie(c, sessionId) + + // Redirect to saved return URL + const returnTo = getCookie(c, RETURN_COOKIE) ?? '/dashboard' + deleteCookie(c, RETURN_COOKIE, { path: '/' }) + + return c.redirect(returnTo) +} + +/** + * POST /auth/logout — clear local session, return JSON. + * The client is responsible for redirecting to KF Auth's signout endpoint. + */ +export async function logout(c: Context) { + const sessionCookie = getCookie(c, 'session') + if (sessionCookie) { + let sessionId = sessionCookie + const dotIdx = sessionCookie.lastIndexOf('.') + if (dotIdx > 0) sessionId = sessionCookie.slice(0, dotIdx) + + await db + .delete(schema.sessions) + .where(eq(schema.sessions.id, sessionId)) + .catch(() => {}) + } + + deleteCookie(c, 'session', { path: '/' }) + return c.json({ ok: true }) +} diff --git a/src/api/kf-summary.ts b/src/api/kf-summary.ts new file mode 100644 index 0000000..b028ddf --- /dev/null +++ b/src/api/kf-summary.ts @@ -0,0 +1,125 @@ +import { eq, sql } from 'drizzle-orm' +import type { Context } from 'hono' + +import { db, schema } from '../db/client.server.js' + +/** + * GET /api/kf/summary?kf_org_id=xxx + * + * Returns Underlay accounts and their collections linked to a KF org. + * For user-type accounts it also includes UL orgs the user belongs to. + * + * Auth: requires KF_INTERNAL_API_KEY (service-to-service). + */ +export async function summary(c: Context) { + const kfOrgId = c.req.query('kf_org_id') + if (!kfOrgId) { + return c.json({ error: 'kf_org_id is required' }, 400) + } + + // Verify internal API key + const authHeader = c.req.header('Authorization') + const expectedKey = process.env.KF_INTERNAL_API_KEY + if (!expectedKey || authHeader !== `Bearer ${expectedKey}`) { + return c.json({ error: 'Unauthorized' }, 401) + } + + const APP_URL = process.env.APP_URL ?? 'http://localhost:4100' + + // Find local accounts linked to this KF org via kf_org_id. + const directAccounts = await db + .select({ + id: schema.accounts.id, + slug: schema.accounts.slug, + type: schema.accounts.type, + displayName: schema.accounts.displayName, + }) + .from(schema.accounts) + .where(eq(schema.accounts.kfOrgId, kfOrgId)) + + if (directAccounts.length === 0) { + return c.json({ accounts: [] }) + } + + const allAccountIds = directAccounts.map((a) => a.id) + + // Get collections for all accounts + const collections = await db + .select({ + id: schema.collections.id, + slug: schema.collections.slug, + name: schema.collections.name, + accountId: schema.collections.accountId, + ownerSlug: schema.accounts.slug, + }) + .from(schema.collections) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) + .where( + sql`${schema.collections.accountId} IN (${sql.join( + allAccountIds.map((id) => sql`${id}`), + sql`, `, + )})`, + ) + + // Get version stats if we have collections + const statsMap = new Map< + string, + { versions: number; records: number; files: number; bytes: number } + >() + + if (collections.length > 0) { + const versionStats = await db + .select({ + collectionId: schema.versions.collectionId, + versionCount: sql`count(*)::int`, + totalRecords: sql`coalesce(sum(${schema.versions.recordCount}), 0)::int`, + totalFiles: sql`coalesce(sum(${schema.versions.fileCount}), 0)::int`, + totalBytes: sql`coalesce(sum(${schema.versions.totalBytes}), 0)::bigint`, + }) + .from(schema.versions) + .where( + sql`${schema.versions.collectionId} IN (${sql.join( + collections.map((c) => sql`${c.id}`), + sql`, `, + )})`, + ) + .groupBy(schema.versions.collectionId) + + for (const s of versionStats) { + statsMap.set(s.collectionId, { + versions: s.versionCount, + records: s.totalRecords, + files: s.totalFiles, + bytes: s.totalBytes, + }) + } + } + + // Group collections by account + const collectionsByAccount = new Map() + for (const col of collections) { + const list = collectionsByAccount.get(col.accountId) ?? [] + list.push(col) + collectionsByAccount.set(col.accountId, list) + } + + return c.json({ + accounts: directAccounts.map((acct) => ({ + id: acct.id, + slug: acct.slug, + type: acct.type, + name: acct.displayName ?? acct.slug, + url: `${APP_URL}/${acct.slug}`, + collections: (collectionsByAccount.get(acct.id) ?? []).map((col) => { + const stats = statsMap.get(col.id) + return { + id: col.id, + name: col.name, + slug: col.slug, + url: `${APP_URL}/${col.ownerSlug}/${col.slug}`, + stats: stats ?? { versions: 0, records: 0, files: 0, bytes: 0 }, + } + }), + })), + }) +} diff --git a/src/api/query.ts b/src/api/query.ts index ddc2c16..5ecb407 100644 --- a/src/api/query.ts +++ b/src/api/query.ts @@ -1,8 +1,9 @@ -import { and, desc, eq, ilike, inArray, or, } from 'drizzle-orm' -import type { Context, } from 'hono' -import { db, schema, } from '../db/client.server.js' -import { buildSqliteBuffer, generateAllDDL, generateDDL, } from '../lib/sqlite-gen.js' -import { type AuthEnv, } from './auth.server.js' +import { and, desc, eq, ilike, inArray, or } from 'drizzle-orm' +import type { Context } from 'hono' + +import { db, schema } from '../db/client.server.js' +import { buildSqliteBuffer, generateAllDDL, generateDDL } from '../lib/sqlite-gen.js' +import { type AuthEnv } from './auth.server.js' // In-memory LRU cache: key = `${collectionId}:${versionNumber}`, value = { buffer, expiresAt } const sqliteCache = new Map< @@ -20,8 +21,8 @@ const CACHE_MAX_ENTRIES = 10 function cleanExpired() { const now = Date.now() - for (const [key, entry,] of sqliteCache) { - if (entry.expiresAt < now) sqliteCache.delete(key,) + for (const [key, entry] of sqliteCache) { + if (entry.expiresAt < now) sqliteCache.delete(key) } } @@ -29,51 +30,60 @@ function evictIfNeeded() { while (sqliteCache.size >= CACHE_MAX_ENTRIES) { // Evict oldest entry (first key in Map insertion order) const firstKey = sqliteCache.keys().next().value - if (firstKey) sqliteCache.delete(firstKey,) + if (firstKey) sqliteCache.delete(firstKey) else break } } // Run cleanup every 5 minutes -setInterval(cleanExpired, 5 * 60 * 1000,) +setInterval(cleanExpired, 5 * 60 * 1000) -async function getOrBuildSqlite(owner: string, slug: string, versionNumber: number,) { +async function getOrBuildSqlite(owner: string, slug: string, versionNumber: number) { // Resolve collection - const [collection,] = await db - .select({ id: schema.collections.id, accountId: schema.collections.accountId, public: schema.collections.public, },) - .from(schema.collections,) - .innerJoin(schema.accounts, eq(schema.accounts.id, schema.collections.accountId,),) - .where(and(eq(schema.accounts.slug, owner,), eq(schema.collections.slug, slug,),),) - .limit(1,) + const [collection] = await db + .select({ + id: schema.collections.id, + accountId: schema.collections.accountId, + public: schema.collections.public, + }) + .from(schema.collections) + .innerJoin(schema.accounts, eq(schema.accounts.id, schema.collections.accountId)) + .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) + .limit(1) if (!collection) return null // Resolve version - const [version,] = await db - .select({ id: schema.versions.id, number: schema.versions.number, },) - .from(schema.versions,) - .where(and(eq(schema.versions.collectionId, collection.id,), eq(schema.versions.number, versionNumber,),),) - .limit(1,) + const [version] = await db + .select({ id: schema.versions.id, number: schema.versions.number }) + .from(schema.versions) + .where( + and( + eq(schema.versions.collectionId, collection.id), + eq(schema.versions.number, versionNumber), + ), + ) + .limit(1) if (!version) return null const cacheKey = `${collection.id}:${version.number}` // Check cache (re-insert to move to end for LRU ordering) - const cached = sqliteCache.get(cacheKey,) + const cached = sqliteCache.get(cacheKey) if (cached && cached.expiresAt > Date.now()) { - sqliteCache.delete(cacheKey,) + sqliteCache.delete(cacheKey) cached.expiresAt = Date.now() + CACHE_TTL_MS - sqliteCache.set(cacheKey, cached,) + sqliteCache.set(cacheKey, cached) return cached } // Load schemas for this version const versionSchemas = await db - .select({ slug: schema.versionSchemas.slug, schema: schema.schemas.schema, },) - .from(schema.versionSchemas,) - .innerJoin(schema.schemas, eq(schema.versionSchemas.schemaId, schema.schemas.id,),) - .where(eq(schema.versionSchemas.versionId, version.id,),) + .select({ slug: schema.versionSchemas.slug, schema: schema.schemas.schema }) + .from(schema.versionSchemas) + .innerJoin(schema.schemas, eq(schema.versionSchemas.schemaId, schema.schemas.id)) + .where(eq(schema.versionSchemas.versionId, version.id)) const schemasMap: Record = {} for (const vs of versionSchemas) { @@ -82,93 +92,100 @@ async function getOrBuildSqlite(owner: string, slug: string, versionNumber: numb // Load records const records = await db - .select({ recordId: schema.records.recordId, type: schema.records.type, data: schema.records.data, },) - .from(schema.records,) - .where(eq(schema.records.versionId, version.id,),) + .select({ + recordId: schema.records.recordId, + type: schema.records.type, + data: schema.records.data, + }) + .from(schema.records) + .where(eq(schema.records.versionId, version.id)) // Build SQLite - const buffer = buildSqliteBuffer(schemasMap, records as any,) - const ddl = generateAllDDL(schemasMap,) + const buffer = buildSqliteBuffer(schemasMap, records as any) + const ddl = generateAllDDL(schemasMap) // Generate sample data (first row per table) for LLM context const sampleRows: Record> = {} - for (const [typeName,] of Object.entries(schemasMap,)) { - const firstRecord = records.find((r,) => r.type === typeName) + for (const [typeName] of Object.entries(schemasMap)) { + const firstRecord = records.find((r) => r.type === typeName) if (firstRecord && firstRecord.data && typeof firstRecord.data === 'object') { sampleRows[typeName] = firstRecord.data as Record } } // Build DDL with inline sample rows (each sample right after its CREATE TABLE) - const ddlWithSamples = Object.entries(schemasMap,) - .map(([name, s,],) => { - const tableDdl = generateDDL(name, s,) + const ddlWithSamples = Object.entries(schemasMap) + .map(([name, s]) => { + const tableDdl = generateDDL(name, s) const sample = sampleRows[name] if (sample) { - return tableDdl + `\n-- Example row: ${JSON.stringify(sample,)}` + return tableDdl + `\n-- Example row: ${JSON.stringify(sample)}` } return tableDdl - },) - .join('\n\n',) + }) + .join('\n\n') - const entry = { buffer, ddl, ddlWithSamples, sampleRows, expiresAt: Date.now() + CACHE_TTL_MS, } + const entry = { buffer, ddl, ddlWithSamples, sampleRows, expiresAt: Date.now() + CACHE_TTL_MS } evictIfNeeded() - sqliteCache.set(cacheKey, entry,) + sqliteCache.set(cacheKey, entry) return entry } // GET /query/sqlite/:owner/:slug/:version — Download SQLite file for a version -export async function sqlite(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! - const version = c.req.param('version',)! - const versionNum = parseInt(version, 10,) - if (isNaN(versionNum,)) return c.json({ error: 'Invalid version number', }, 400,) +export async function sqlite(c: Context) { + const owner = c.req.param('owner')! + const slug = c.req.param('slug')! + const version = c.req.param('version')! + const versionNum = parseInt(version, 10) + if (isNaN(versionNum)) return c.json({ error: 'Invalid version number' }, 400) - const result = await getOrBuildSqlite(owner, slug, versionNum,) - if (!result) return c.json({ error: 'Collection or version not found', }, 404,) + const result = await getOrBuildSqlite(owner, slug, versionNum) + if (!result) return c.json({ error: 'Collection or version not found' }, 404) - return new Response(new Uint8Array(result.buffer,), { + return new Response(new Uint8Array(result.buffer), { status: 200, headers: { 'Content-Type': 'application/x-sqlite3', 'Content-Disposition': `attachment; filename="${slug}-v${versionNum}.sqlite"`, 'Cache-Control': 'public, max-age=86400', }, - },) + }) } // GET /query/ddl/:owner/:slug/:version — Get DDL (schema only) for a version -export async function ddl(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! - const version = c.req.param('version',)! - const versionNum = parseInt(version, 10,) - if (isNaN(versionNum,)) return c.json({ error: 'Invalid version number', }, 400,) +export async function ddl(c: Context) { + const owner = c.req.param('owner')! + const slug = c.req.param('slug')! + const version = c.req.param('version')! + const versionNum = parseInt(version, 10) + if (isNaN(versionNum)) return c.json({ error: 'Invalid version number' }, 400) - const result = await getOrBuildSqlite(owner, slug, versionNum,) - if (!result) return c.json({ error: 'Collection or version not found', }, 404,) + const result = await getOrBuildSqlite(owner, slug, versionNum) + if (!result) return c.json({ error: 'Collection or version not found' }, 404) - return c.json({ ddl: result.ddl, },) + return c.json({ ddl: result.ddl }) } // POST /query/generate-sql — LLM-powered SQL generation from natural language -export async function generateSql(c: Context,) { - const { collections: collectionRefs, question, } = await c.req.json() +export async function generateSql(c: Context) { + const { collections: collectionRefs, question } = await c.req.json() if (!collectionRefs?.length || !question) { - return c.json({ error: 'collections and question are required', }, 400,) + return c.json({ error: 'collections and question are required' }, 400) } const cfAccountId = process.env.CF_ACCOUNT_ID const cfApiToken = process.env.CF_API_TOKEN if (!cfAccountId || !cfApiToken) { - return c.json({ - error: 'LLM not configured', - message: - 'Set CF_ACCOUNT_ID and CF_API_TOKEN environment variables to enable natural language queries. You can still write SQL directly.', - }, 503,) + return c.json( + { + error: 'LLM not configured', + message: + 'Set CF_ACCOUNT_ID and CF_API_TOKEN environment variables to enable natural language queries. You can still write SQL directly.', + }, + 503, + ) } // Build DDL with sample rows server-side @@ -177,29 +194,33 @@ export async function generateSql(c: Context,) { if (collectionRefs.length === 1) { const ref = collectionRefs[0] - const result = await getOrBuildSqlite(ref.owner, ref.slug, ref.version,) - if (!result) return c.json({ error: `Collection ${ref.owner}/${ref.slug} v${ref.version} not found`, }, 404,) + const result = await getOrBuildSqlite(ref.owner, ref.slug, ref.version) + if (!result) + return c.json({ error: `Collection ${ref.owner}/${ref.slug} v${ref.version} not found` }, 404) combinedDdl = result.ddlWithSamples // Count records from cache (approximation from the version table already captured) } else { const parts: string[] = [] for (const ref of collectionRefs) { - const result = await getOrBuildSqlite(ref.owner, ref.slug, ref.version,) - if (!result) return c.json({ error: `Collection ${ref.owner}/${ref.slug} v${ref.version} not found`, }, 404,) - const prefix = ref.slug.replace(/-/g, '_',) + const result = await getOrBuildSqlite(ref.owner, ref.slug, ref.version) + if (!result) + return c.json( + { error: `Collection ${ref.owner}/${ref.slug} v${ref.version} not found` }, + 404, + ) + const prefix = ref.slug.replace(/-/g, '_') // Prefix table names and add _source column to DDL const ddlPrefixed = result.ddlWithSamples - .replace(/CREATE TABLE "([^"]+)"/g, `CREATE TABLE "${prefix}__$1"`,) - .replace(/\);/g, `,\n "_source" TEXT\n);`,) - parts.push(`-- Collection: ${ref.owner}/${ref.slug} v${ref.version}\n` + ddlPrefixed,) + .replace(/CREATE TABLE "([^"]+)"/g, `CREATE TABLE "${prefix}__$1"`) + .replace(/\);/g, `,\n "_source" TEXT\n);`) + parts.push(`-- Collection: ${ref.owner}/${ref.slug} v${ref.version}\n` + ddlPrefixed) } - combinedDdl = parts.join('\n\n',) + combinedDdl = parts.join('\n\n') } const isMultiCollection = collectionRefs.length > 1 - const systemPrompt = - `You are a SQL assistant for SQLite databases. Given a schema and a user's question, produce a single SELECT query that answers it. + const systemPrompt = `You are a SQL assistant for SQLite databases. Given a schema and a user's question, produce a single SELECT query that answers it. Respond in EXACTLY this format (two sections separated by the marker): @@ -211,24 +232,24 @@ REASONING: Important rules: - Examine the "Example row" comments in the schema — they show the ACTUAL data format stored in each column.${ - isMultiCollection - ? ` + isMultiCollection + ? ` - When multiple collections are loaded, consider ALL of them in your answer unless the question specifies otherwise. - Every table has a "_source" column containing the collection identifier (e.g. "account/collection"). For row-level results, include _source as a column. For aggregations, include GROUP_CONCAT(DISTINCT _source) as _source so the user can see which collections contributed to the result. - When counting across multiple tables, use UNION ALL to combine rows, not JOIN.` - : '' - } + : '' + } - Only use JOIN when the question asks about relationships between tables. - COUNT(*) counts rows.${ - isMultiCollection ? ' Use UNION ALL to combine rows from separate tables before counting.' : '' - } + isMultiCollection ? ' Use UNION ALL to combine rows from separate tables before counting.' : '' + } - When tables have a prefix like "collection__TableName", always use that full prefixed name. - Do NOT include columns that don't exist in the schema.` const userPrompt = `Schema:\n${combinedDdl}\n\nQuestion: ${question}` // Log the full prompt for debugging - console.info(`[generate-sql] User prompt:\n${userPrompt}`,) + console.info(`[generate-sql] User prompt:\n${userPrompt}`) try { const response = await fetch( @@ -241,84 +262,97 @@ Important rules: }, body: JSON.stringify({ messages: [ - { role: 'system', content: systemPrompt, }, - { role: 'user', content: userPrompt, }, + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, ], max_tokens: 800, temperature: 0, - },), + }), }, ) if (!response.ok) { const text = await response.text() - console.error(`Cloudflare AI error: ${response.status} ${text}`,) - return c.json({ error: 'LLM request failed', rawResponse: text, }, 502,) + console.error(`Cloudflare AI error: ${response.status} ${text}`) + return c.json({ error: 'LLM request failed', rawResponse: text }, 502) } const data = (await response.json()) as any let raw = data?.result?.response?.trim() if (!raw) { - return c.json({ error: 'LLM returned empty response', rawResponse: JSON.stringify(data,), }, 500,) + return c.json( + { error: 'LLM returned empty response', rawResponse: JSON.stringify(data) }, + 500, + ) } // Parse structured response let sql: string let reasoning: string | undefined - const sqlMarker = raw.indexOf('SQL:',) - const reasoningMarker = raw.indexOf('REASONING:',) + const sqlMarker = raw.indexOf('SQL:') + const reasoningMarker = raw.indexOf('REASONING:') if (sqlMarker !== -1 && reasoningMarker !== -1) { - sql = raw.substring(sqlMarker + 4, reasoningMarker,).replace(/```sql\n?/g, '',).replace(/```/g, '',).trim() - reasoning = raw.substring(reasoningMarker + 10,).trim() + sql = raw + .substring(sqlMarker + 4, reasoningMarker) + .replace(/```sql\n?/g, '') + .replace(/```/g, '') + .trim() + reasoning = raw.substring(reasoningMarker + 10).trim() } else { // Fallback: treat entire response as SQL - sql = raw.replace(/```sql\n?/g, '',).replace(/```/g, '',).trim() + sql = raw + .replace(/```sql\n?/g, '') + .replace(/```/g, '') + .trim() } // Basic safety: only allow SELECT statements - const normalized = sql.replace(/--.*$/gm, '',).trim().toUpperCase() - if (!normalized.startsWith('SELECT',) && !normalized.startsWith('WITH',)) { - return c.json({ - error: 'Generated query is not a SELECT statement', - sql, - reasoning, - rawResponse: raw, - }, 400,) + const normalized = sql.replace(/--.*$/gm, '').trim().toUpperCase() + if (!normalized.startsWith('SELECT') && !normalized.startsWith('WITH')) { + return c.json( + { + error: 'Generated query is not a SELECT statement', + sql, + reasoning, + rawResponse: raw, + }, + 400, + ) } - return c.json({ sql, reasoning, },) + return c.json({ sql, reasoning }) } catch (err: any) { - console.error(`LLM generation error: ${err.message}`,) - return c.json({ error: 'Failed to generate SQL', }, 500,) + console.error(`LLM generation error: ${err.message}`) + return c.json({ error: 'Failed to generate SQL' }, 500) } } // GET /query/collections/search?q=term — Search collections (public + user's private) -export async function searchCollections(c: Context,) { - const q = c.req.query('q',) - if (!q || q.trim().length < 2) return c.json([],) +export async function searchCollections(c: Context) { + const q = c.req.query('q') + if (!q || q.trim().length < 2) return c.json([]) const term = `%${q.trim()}%` - const userId = c.get('accountId',) + const userId = c.get('accountId') // Build accessible account IDs (user's own + orgs they belong to) let accessibleAccountIds: string[] = [] if (userId) { const memberships = await db - .select({ orgId: schema.orgMemberships.orgId, },) - .from(schema.orgMemberships,) - .where(eq(schema.orgMemberships.userId, userId,),) - accessibleAccountIds = [userId, ...memberships.map((m,) => m.orgId),] + .select({ orgId: schema.orgMemberships.orgId }) + .from(schema.orgMemberships) + .where(eq(schema.orgMemberships.userId, userId)) + accessibleAccountIds = [userId, ...memberships.map((m) => m.orgId)] } // Query: public collections OR private collections owned by accessible accounts const searchCondition = or( - ilike(schema.accounts.slug, term,), - ilike(schema.collections.slug, term,), - ilike(schema.collections.name, term,), + ilike(schema.accounts.slug, term), + ilike(schema.collections.slug, term), + ilike(schema.collections.name, term), ) let whereCondition @@ -326,12 +360,12 @@ export async function searchCollections(c: Context,) { whereCondition = and( searchCondition, or( - eq(schema.collections.public, true,), - inArray(schema.collections.accountId, accessibleAccountIds,), + eq(schema.collections.public, true), + inArray(schema.collections.accountId, accessibleAccountIds), ), ) } else { - whereCondition = and(searchCondition, eq(schema.collections.public, true,),) + whereCondition = and(searchCondition, eq(schema.collections.public, true)) } const collections = await db @@ -341,27 +375,27 @@ export async function searchCollections(c: Context,) { name: schema.collections.name, description: schema.collections.description, public: schema.collections.public, - },) - .from(schema.collections,) - .innerJoin(schema.accounts, eq(schema.accounts.id, schema.collections.accountId,),) - .where(whereCondition,) - .limit(20,) + }) + .from(schema.collections) + .innerJoin(schema.accounts, eq(schema.accounts.id, schema.collections.accountId)) + .where(whereCondition) + .limit(20) // Get latest version + record count for each match const result = [] for (const c2 of collections) { - const [latestVersion,] = await db + const [latestVersion] = await db .select({ number: schema.versions.number, semver: schema.versions.semver, recordCount: schema.versions.recordCount, - },) - .from(schema.versions,) - .innerJoin(schema.collections, eq(schema.collections.id, schema.versions.collectionId,),) - .innerJoin(schema.accounts, eq(schema.accounts.id, schema.collections.accountId,),) - .where(and(eq(schema.accounts.slug, c2.ownerSlug,), eq(schema.collections.slug, c2.slug,),),) - .orderBy(desc(schema.versions.number,),) - .limit(1,) + }) + .from(schema.versions) + .innerJoin(schema.collections, eq(schema.collections.id, schema.versions.collectionId)) + .innerJoin(schema.accounts, eq(schema.accounts.id, schema.collections.accountId)) + .where(and(eq(schema.accounts.slug, c2.ownerSlug), eq(schema.collections.slug, c2.slug))) + .orderBy(desc(schema.versions.number)) + .limit(1) result.push({ ownerSlug: c2.ownerSlug, @@ -372,16 +406,16 @@ export async function searchCollections(c: Context,) { latestVersion: latestVersion?.number ?? null, latestSemver: latestVersion?.semver ?? null, recordCount: latestVersion?.recordCount ?? 0, - },) + }) } - return c.json(result,) + return c.json(result) } // GET /query/collections/:owner/:slug/versions — List versions for a collection -export async function collectionVersions(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! +export async function collectionVersions(c: Context) { + const owner = c.req.param('owner')! + const slug = c.req.param('slug')! const versions = await db .select({ @@ -390,22 +424,22 @@ export async function collectionVersions(c: Context,) { recordCount: schema.versions.recordCount, createdAt: schema.versions.createdAt, message: schema.versions.message, - },) - .from(schema.versions,) - .innerJoin(schema.collections, eq(schema.collections.id, schema.versions.collectionId,),) - .innerJoin(schema.accounts, eq(schema.accounts.id, schema.collections.accountId,),) + }) + .from(schema.versions) + .innerJoin(schema.collections, eq(schema.collections.id, schema.versions.collectionId)) + .innerJoin(schema.accounts, eq(schema.accounts.id, schema.collections.accountId)) .where( and( - eq(schema.accounts.slug, owner,), - eq(schema.collections.slug, slug,), - eq(schema.collections.public, true,), + eq(schema.accounts.slug, owner), + eq(schema.collections.slug, slug), + eq(schema.collections.public, true), ), ) - .orderBy(desc(schema.versions.number,),) + .orderBy(desc(schema.versions.number)) if (versions.length === 0) { - return c.json({ error: 'Collection not found or not public', }, 404,) + return c.json({ error: 'Collection not found or not public' }, 404) } - return c.json(versions,) + return c.json(versions) } diff --git a/src/api/schemas.ts b/src/api/schemas.ts index 47af20b..246b4bd 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -1,78 +1,81 @@ -import { and, eq, ilike, inArray, sql, } from 'drizzle-orm' -import type { Context, } from 'hono' -import { db, schema, } from '../db/client.server.js' -import { type AuthEnv, } from './auth.server.js' +import { and, eq, ilike, inArray, sql } from 'drizzle-orm' +import type { Context } from 'hono' + +import { db, schema } from '../db/client.server.js' +import { type AuthEnv } from './auth.server.js' // --- Global schema search --- // GET /schemas?q=...&slug=...&label=...&schema_hash=...&limit=...&offset=... -export async function listSchemas(c: Context,) { - const q = c.req.query('q',) - const slugFilter = c.req.query('slug',) - const label = c.req.query('label',) - const schema_hash = c.req.query('schema_hash',) - const limit = c.req.query('limit',) - const offset = c.req.query('offset',) +export async function listSchemas(c: Context) { + const q = c.req.query('q') + const slugFilter = c.req.query('slug') + const label = c.req.query('label') + const schema_hash = c.req.query('schema_hash') + const limit = c.req.query('limit') + const offset = c.req.query('offset') - const pageLimit = Math.min(parseInt(limit ?? '50', 10,), 100,) - const pageOffset = parseInt(offset ?? '0', 10,) + const pageLimit = Math.min(parseInt(limit ?? '50', 10), 100) + const pageOffset = parseInt(offset ?? '0', 10) // Search by exact hash if (schema_hash) { - const [row,] = await db + const [row] = await db .select() - .from(schema.schemas,) - .where(eq(schema.schemas.schemaHash, schema_hash,),) - .limit(1,) + .from(schema.schemas) + .where(eq(schema.schemas.schemaHash, schema_hash)) + .limit(1) - if (!row) return c.json({ error: 'Schema not found', statusCode: 404, }, 404,) + if (!row) return c.json({ error: 'Schema not found', statusCode: 404 }, 404) const labels = await db - .select({ label: schema.schemaLabels.label, },) - .from(schema.schemaLabels,) - .where(eq(schema.schemaLabels.schemaId, row.id,),) + .select({ label: schema.schemaLabels.label }) + .from(schema.schemaLabels) + .where(eq(schema.schemaLabels.schemaId, row.id)) - const usageCount = await getUsageCount(row.id,) + const usageCount = await getUsageCount(row.id) return c.json({ ...row, - labels: labels.map((l,) => l.label), + labels: labels.map((l) => l.label), usageCount, - },) + }) } // Search by slug (find schemas used as a particular type name) if (slugFilter) { const vsRows = await db - .select({ schemaId: schema.versionSchemas.schemaId, },) - .from(schema.versionSchemas,) - .where(eq(schema.versionSchemas.slug, slugFilter,),) - .groupBy(schema.versionSchemas.schemaId,) - .limit(pageLimit,) - .offset(pageOffset,) + .select({ schemaId: schema.versionSchemas.schemaId }) + .from(schema.versionSchemas) + .where(eq(schema.versionSchemas.slug, slugFilter)) + .groupBy(schema.versionSchemas.schemaId) + .limit(pageLimit) + .offset(pageOffset) - if (vsRows.length === 0) return c.json([],) + if (vsRows.length === 0) return c.json([]) - const schemaIds = vsRows.map((r,) => r.schemaId) + const schemaIds = vsRows.map((r) => r.schemaId) const schemaRows = await db .select() - .from(schema.schemas,) - .where(inArray(schema.schemas.id, schemaIds,),) + .from(schema.schemas) + .where(inArray(schema.schemas.id, schemaIds)) const allLabels = await db - .select({ schemaId: schema.schemaLabels.schemaId, label: schema.schemaLabels.label, },) - .from(schema.schemaLabels,) - .where(inArray(schema.schemaLabels.schemaId, schemaIds,),) + .select({ schemaId: schema.schemaLabels.schemaId, label: schema.schemaLabels.label }) + .from(schema.schemaLabels) + .where(inArray(schema.schemaLabels.schemaId, schemaIds)) const labelsMap = new Map() for (const l of allLabels) { - if (!labelsMap.has(l.schemaId,)) labelsMap.set(l.schemaId, [],) - labelsMap.get(l.schemaId,)!.push(l.label,) + if (!labelsMap.has(l.schemaId)) labelsMap.set(l.schemaId, []) + labelsMap.get(l.schemaId)!.push(l.label) } - return c.json(schemaRows.map((s,) => ({ - ...s, - labels: labelsMap.get(s.id,) ?? [], - })),) + return c.json( + schemaRows.map((s) => ({ + ...s, + labels: labelsMap.get(s.id) ?? [], + })), + ) } // Search by label @@ -81,112 +84,116 @@ export async function listSchemas(c: Context,) { .select({ schemaId: schema.schemaLabels.schemaId, label: schema.schemaLabels.label, - },) - .from(schema.schemaLabels,) - .where(ilike(schema.schemaLabels.label, `%${label}%`,),) - .limit(pageLimit,) - .offset(pageOffset,) + }) + .from(schema.schemaLabels) + .where(ilike(schema.schemaLabels.label, `%${label}%`)) + .limit(pageLimit) + .offset(pageOffset) - if (labelRows.length === 0) return c.json([],) + if (labelRows.length === 0) return c.json([]) - const schemaIds = [...new Set(labelRows.map((r,) => r.schemaId),),] + const schemaIds = [...new Set(labelRows.map((r) => r.schemaId))] const schemaRows = await db .select() - .from(schema.schemas,) - .where(inArray(schema.schemas.id, schemaIds,),) + .from(schema.schemas) + .where(inArray(schema.schemas.id, schemaIds)) // Gather all labels for these schemas const allLabels = await db - .select({ schemaId: schema.schemaLabels.schemaId, label: schema.schemaLabels.label, },) - .from(schema.schemaLabels,) - .where(inArray(schema.schemaLabels.schemaId, schemaIds,),) + .select({ schemaId: schema.schemaLabels.schemaId, label: schema.schemaLabels.label }) + .from(schema.schemaLabels) + .where(inArray(schema.schemaLabels.schemaId, schemaIds)) const labelsMap = new Map() for (const l of allLabels) { - if (!labelsMap.has(l.schemaId,)) labelsMap.set(l.schemaId, [],) - labelsMap.get(l.schemaId,)!.push(l.label,) + if (!labelsMap.has(l.schemaId)) labelsMap.set(l.schemaId, []) + labelsMap.get(l.schemaId)!.push(l.label) } - return c.json(schemaRows.map((s,) => ({ - ...s, - labels: labelsMap.get(s.id,) ?? [], - })),) + return c.json( + schemaRows.map((s) => ({ + ...s, + labels: labelsMap.get(s.id) ?? [], + })), + ) } // Full-text search across schema JSON (search for field names, types, etc.) if (q) { const rows = await db .select() - .from(schema.schemas,) - .where(sql`${schema.schemas.schema}::text ILIKE ${'%' + q + '%'}`,) - .limit(pageLimit,) - .offset(pageOffset,) - - const schemaIds = rows.map((r,) => r.id) - const allLabels = schemaIds.length > 0 - ? await db - .select({ schemaId: schema.schemaLabels.schemaId, label: schema.schemaLabels.label, },) - .from(schema.schemaLabels,) - .where(inArray(schema.schemaLabels.schemaId, schemaIds,),) - : [] + .from(schema.schemas) + .where(sql`${schema.schemas.schema}::text ILIKE ${'%' + q + '%'}`) + .limit(pageLimit) + .offset(pageOffset) + + const schemaIds = rows.map((r) => r.id) + const allLabels = + schemaIds.length > 0 + ? await db + .select({ schemaId: schema.schemaLabels.schemaId, label: schema.schemaLabels.label }) + .from(schema.schemaLabels) + .where(inArray(schema.schemaLabels.schemaId, schemaIds)) + : [] const labelsMap = new Map() for (const l of allLabels) { - if (!labelsMap.has(l.schemaId,)) labelsMap.set(l.schemaId, [],) - labelsMap.get(l.schemaId,)!.push(l.label,) + if (!labelsMap.has(l.schemaId)) labelsMap.set(l.schemaId, []) + labelsMap.get(l.schemaId)!.push(l.label) } - return c.json(rows.map((s,) => ({ - ...s, - labels: labelsMap.get(s.id,) ?? [], - })),) + return c.json( + rows.map((s) => ({ + ...s, + labels: labelsMap.get(s.id) ?? [], + })), + ) } // No filter: list all schemas const rows = await db .select() - .from(schema.schemas,) - .orderBy(sql`${schema.schemas.createdAt} desc`,) - .limit(pageLimit,) - .offset(pageOffset,) - - const schemaIds = rows.map((r,) => r.id) - const allLabels = schemaIds.length > 0 - ? await db - .select({ schemaId: schema.schemaLabels.schemaId, label: schema.schemaLabels.label, },) - .from(schema.schemaLabels,) - .where(inArray(schema.schemaLabels.schemaId, schemaIds,),) - : [] + .from(schema.schemas) + .orderBy(sql`${schema.schemas.createdAt} desc`) + .limit(pageLimit) + .offset(pageOffset) + + const schemaIds = rows.map((r) => r.id) + const allLabels = + schemaIds.length > 0 + ? await db + .select({ schemaId: schema.schemaLabels.schemaId, label: schema.schemaLabels.label }) + .from(schema.schemaLabels) + .where(inArray(schema.schemaLabels.schemaId, schemaIds)) + : [] const labelsMap = new Map() for (const l of allLabels) { - if (!labelsMap.has(l.schemaId,)) labelsMap.set(l.schemaId, [],) - labelsMap.get(l.schemaId,)!.push(l.label,) + if (!labelsMap.has(l.schemaId)) labelsMap.set(l.schemaId, []) + labelsMap.get(l.schemaId)!.push(l.label) } - return c.json(rows.map((s,) => ({ - ...s, - labels: labelsMap.get(s.id,) ?? [], - })),) + return c.json( + rows.map((s) => ({ + ...s, + labels: labelsMap.get(s.id) ?? [], + })), + ) } // --- Single schema by ID --- // GET /schemas/:id -export async function getSchema(c: Context,) { - const id = c.req.param('id',)! +export async function getSchema(c: Context) { + const id = c.req.param('id')! - const [row,] = await db - .select() - .from(schema.schemas,) - .where(eq(schema.schemas.id, id,),) - .limit(1,) + const [row] = await db.select().from(schema.schemas).where(eq(schema.schemas.id, id)).limit(1) - if (!row) return c.json({ error: 'Schema not found', statusCode: 404, }, 404,) + if (!row) return c.json({ error: 'Schema not found', statusCode: 404 }, 404) const labels = await db - .select({ label: schema.schemaLabels.label, createdAt: schema.schemaLabels.createdAt, },) - .from(schema.schemaLabels,) - .where(eq(schema.schemaLabels.schemaId, id,),) + .select({ label: schema.schemaLabels.label, createdAt: schema.schemaLabels.createdAt }) + .from(schema.schemaLabels) + .where(eq(schema.schemaLabels.schemaId, id)) // Usage: which collections/versions reference this schema const usage = await db @@ -197,68 +204,72 @@ export async function getSchema(c: Context,) { collectionSlug: schema.collections.slug, owner: schema.accounts.slug, isPublic: schema.collections.public, - },) - .from(schema.versionSchemas,) - .innerJoin(schema.versions, eq(schema.versionSchemas.versionId, schema.versions.id,),) - .innerJoin(schema.collections, eq(schema.versions.collectionId, schema.collections.id,),) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id,),) - .where(and(eq(schema.versionSchemas.schemaId, id,), eq(schema.collections.public, true,),),) - .orderBy(sql`${schema.versions.createdAt} desc`,) - .limit(50,) + }) + .from(schema.versionSchemas) + .innerJoin(schema.versions, eq(schema.versionSchemas.versionId, schema.versions.id)) + .innerJoin(schema.collections, eq(schema.versions.collectionId, schema.collections.id)) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) + .where(and(eq(schema.versionSchemas.schemaId, id), eq(schema.collections.public, true))) + .orderBy(sql`${schema.versions.createdAt} desc`) + .limit(50) return c.json({ ...row, - labels: labels.map((l,) => ({ label: l.label, createdAt: l.createdAt, })), - usage: usage.map((u,) => ({ + labels: labels.map((l) => ({ label: l.label, createdAt: l.createdAt })), + usage: usage.map((u) => ({ slug: u.slug, semver: u.semver, versionNumber: u.versionNumber, collection: `${u.owner}/${u.collectionSlug}`, })), - },) + }) } // --- Collection schemas (for a specific version or latest) --- // GET /collections/:owner/:slug/schemas?version=N -export async function collectionSchemas(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! - const versionParam = c.req.query('version',) - const raw = c.req.query('raw',) +export async function collectionSchemas(c: Context) { + const owner = c.req.param('owner')! + const slug = c.req.param('slug')! + const versionParam = c.req.query('version') + const raw = c.req.query('raw') // Resolve collection - const [collection,] = await db + const [collection] = await db .select({ id: schema.collections.id, accountId: schema.collections.accountId, public: schema.collections.public, - },) - .from(schema.collections,) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id,),) - .where(and(eq(schema.accounts.slug, owner,), eq(schema.collections.slug, slug,),),) - .limit(1,) + }) + .from(schema.collections) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) + .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) + .limit(1) - if (!collection) return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) + if (!collection) return c.json({ error: 'Collection not found', statusCode: 404 }, 404) // Visibility check - if (!collection.public && c.get('accountId',) !== collection.accountId) { - return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) + if (!collection.public && c.get('accountId') !== collection.accountId) { + return c.json({ error: 'Collection not found', statusCode: 404 }, 404) } // Resolve version - const versionConditions = [eq(schema.versions.collectionId, collection.id,),] + const versionConditions = [eq(schema.versions.collectionId, collection.id)] if (versionParam) { - versionConditions.push(eq(schema.versions.number, parseInt(versionParam, 10,),),) + versionConditions.push(eq(schema.versions.number, parseInt(versionParam, 10))) } - const [version,] = await db - .select({ id: schema.versions.id, number: schema.versions.number, semver: schema.versions.semver, },) - .from(schema.versions,) - .where(and(...versionConditions,),) - .orderBy(sql`${schema.versions.number} desc`,) - .limit(1,) + const [version] = await db + .select({ + id: schema.versions.id, + number: schema.versions.number, + semver: schema.versions.semver, + }) + .from(schema.versions) + .where(and(...versionConditions)) + .orderBy(sql`${schema.versions.number} desc`) + .limit(1) - if (!version) return c.json({ error: 'No versions found', statusCode: 404, }, 404,) + if (!version) return c.json({ error: 'No versions found', statusCode: 404 }, 404) // Load schemas for this version const entries = await db @@ -267,36 +278,37 @@ export async function collectionSchemas(c: Context,) { schemaId: schema.versionSchemas.schemaId, schemaBody: schema.schemas.schema, schemaHash: schema.schemas.schemaHash, - },) - .from(schema.versionSchemas,) - .innerJoin(schema.schemas, eq(schema.versionSchemas.schemaId, schema.schemas.id,),) - .where(eq(schema.versionSchemas.versionId, version.id,),) + }) + .from(schema.versionSchemas) + .innerJoin(schema.schemas, eq(schema.versionSchemas.schemaId, schema.schemas.id)) + .where(eq(schema.versionSchemas.versionId, version.id)) // Load labels for all referenced schemas (unless raw mode) let labelsMap = new Map() if (raw !== 'true' && entries.length > 0) { - const schemaIds = entries.map((e,) => e.schemaId) + const schemaIds = entries.map((e) => e.schemaId) const allLabels = await db - .select({ schemaId: schema.schemaLabels.schemaId, label: schema.schemaLabels.label, },) - .from(schema.schemaLabels,) - .where(inArray(schema.schemaLabels.schemaId, schemaIds,),) + .select({ schemaId: schema.schemaLabels.schemaId, label: schema.schemaLabels.label }) + .from(schema.schemaLabels) + .where(inArray(schema.schemaLabels.schemaId, schemaIds)) for (const l of allLabels) { - if (!labelsMap.has(l.schemaId,)) labelsMap.set(l.schemaId, [],) - labelsMap.get(l.schemaId,)!.push(l.label,) + if (!labelsMap.has(l.schemaId)) labelsMap.set(l.schemaId, []) + labelsMap.get(l.schemaId)!.push(l.label) } } return c.json({ version: version.number, semver: version.semver, - schemas: entries.map((e,) => { - const labels = labelsMap.get(e.schemaId,) ?? [] - const body = raw === 'true' - ? e.schemaBody - : labels.length > 0 - ? { ...(e.schemaBody as object), 'x-underlay-labels': labels, } - : e.schemaBody + schemas: entries.map((e) => { + const labels = labelsMap.get(e.schemaId) ?? [] + const body = + raw === 'true' + ? e.schemaBody + : labels.length > 0 + ? { ...(e.schemaBody as object), 'x-underlay-labels': labels } + : e.schemaBody return { slug: e.slug, @@ -304,75 +316,75 @@ export async function collectionSchemas(c: Context,) { schemaHash: e.schemaHash, schema: body, } - },), - },) + }), + }) } // --- Label management --- // Add a label to a schema // POST /schemas/:id/labels { label: "schema.org/Person" } -export async function addLabel(c: Context,) { - const id = c.req.param('id',)! - const { label, } = await c.req.json() +export async function addLabel(c: Context) { + const id = c.req.param('id')! + const { label } = await c.req.json() if (!label || typeof label !== 'string' || label.trim().length === 0) { - return c.json({ error: 'Label is required', statusCode: 400, }, 400,) + return c.json({ error: 'Label is required', statusCode: 400 }, 400) } // Verify schema exists - const [existing,] = await db - .select({ id: schema.schemas.id, },) - .from(schema.schemas,) - .where(eq(schema.schemas.id, id,),) - .limit(1,) + const [existing] = await db + .select({ id: schema.schemas.id }) + .from(schema.schemas) + .where(eq(schema.schemas.id, id)) + .limit(1) if (!existing) { - return c.json({ error: 'Schema not found', statusCode: 404, }, 404,) + return c.json({ error: 'Schema not found', statusCode: 404 }, 404) } // Upsert label (ignore conflict on duplicate) try { - const [inserted,] = await db - .insert(schema.schemaLabels,) - .values({ schemaId: id, label: label.trim(), },) + const [inserted] = await db + .insert(schema.schemaLabels) + .values({ schemaId: id, label: label.trim() }) .onConflictDoNothing() .returning() if (!inserted) { - return c.json({ status: 'exists', schemaId: id, label: label.trim(), },) + return c.json({ status: 'exists', schemaId: id, label: label.trim() }) } - return c.json({ status: 'created', schemaId: id, label: label.trim(), }, 201,) + return c.json({ status: 'created', schemaId: id, label: label.trim() }, 201) } catch (err: any) { - return c.json({ error: 'Failed to add label', statusCode: 500, }, 500,) + return c.json({ error: 'Failed to add label', statusCode: 500 }, 500) } } // Remove a label from a schema // DELETE /schemas/:id/labels/:label -export async function removeLabel(c: Context,) { - const id = c.req.param('id',)! - const label = c.req.param('label',)! +export async function removeLabel(c: Context) { + const id = c.req.param('id')! + const label = c.req.param('label')! const result = await db - .delete(schema.schemaLabels,) - .where(and(eq(schema.schemaLabels.schemaId, id,), eq(schema.schemaLabels.label, label,),),) + .delete(schema.schemaLabels) + .where(and(eq(schema.schemaLabels.schemaId, id), eq(schema.schemaLabels.label, label))) .returning() if (result.length === 0) { - return c.json({ error: 'Label not found', statusCode: 404, }, 404,) + return c.json({ error: 'Label not found', statusCode: 404 }, 404) } - return c.json({ status: 'deleted', schemaId: id, label, },) + return c.json({ status: 'deleted', schemaId: id, label }) } // --- Helpers --- -async function getUsageCount(schemaId: string,): Promise { - const [result,] = await db - .select({ count: sql`count(distinct ${schema.versionSchemas.versionId})::int`, },) - .from(schema.versionSchemas,) - .where(eq(schema.versionSchemas.schemaId, schemaId,),) +async function getUsageCount(schemaId: string): Promise { + const [result] = await db + .select({ count: sql`count(distinct ${schema.versionSchemas.versionId})::int` }) + .from(schema.versionSchemas) + .where(eq(schema.versionSchemas.schemaId, schemaId)) return result?.count ?? 0 } diff --git a/src/api/uploads.ts b/src/api/uploads.ts index 9f50200..8fef07f 100644 --- a/src/api/uploads.ts +++ b/src/api/uploads.ts @@ -1,8 +1,10 @@ -import { and, eq, inArray, sql, } from 'drizzle-orm' -import type { Context, } from 'hono' -import { createHash, } from 'node:crypto' -import { db, schema, } from '../db/client.server.js' -import { getS3ObjectMeta, } from '../lib/s3.js' +import { createHash } from 'node:crypto' + +import { and, eq, inArray, sql } from 'drizzle-orm' +import type { Context } from 'hono' + +import { db, schema } from '../db/client.server.js' +import { getS3ObjectMeta } from '../lib/s3.js' import { ajv, deriveSemver, @@ -14,7 +16,7 @@ import { loadVersionSchemas, type SchemaEntry, } from '../lib/version-helpers.server.js' -import { type AuthEnv, } from './auth.server.js' +import { type AuthEnv } from './auth.server.js' /** Session expiry: 1 hour from creation */ const SESSION_TTL_MS = 60 * 60 * 1000 @@ -22,24 +24,24 @@ const SESSION_TTL_MS = 60 * 60 * 1000 /** Max records per batch request */ const MAX_BATCH_SIZE = 10_000 -async function resolveCollection(owner: string, slug: string,) { - const [result,] = await db +async function resolveCollection(owner: string, slug: string) { + const [result] = await db .select({ id: schema.collections.id, accountId: schema.collections.accountId, slug: schema.collections.slug, - },) - .from(schema.collections,) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id,),) - .where(and(eq(schema.accounts.slug, owner,), eq(schema.collections.slug, slug,),),) - .limit(1,) + }) + .from(schema.collections) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) + .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) + .limit(1) return result ?? null } // --- Start a chunked upload session --- -export async function startSession(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! +export async function startSession(c: Context) { + const owner = c.req.param('owner')! + const slug = c.req.param('slug')! const body = await c.req.json<{ base_version: number | null message?: string @@ -49,38 +51,41 @@ export async function startSession(c: Context,) { schemas?: Record }>() - const collection = await resolveCollection(owner, slug,) - if (!collection) return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) + const collection = await resolveCollection(owner, slug) + if (!collection) return c.json({ error: 'Collection not found', statusCode: 404 }, 404) // Verify the caller owns this collection - if (c.get('accountId',) !== collection.accountId) { - return c.json({ error: 'Not authorized for this collection', statusCode: 403, }, 403,) + if (c.get('accountId') !== collection.accountId) { + return c.json({ error: 'Not authorized for this collection', statusCode: 403 }, 403) } // Optimistic lock check at session creation time - const [latest,] = await db - .select({ number: schema.versions.number, },) - .from(schema.versions,) - .where(eq(schema.versions.collectionId, collection.id,),) - .orderBy(sql`${schema.versions.number} desc`,) - .limit(1,) + const [latest] = await db + .select({ number: schema.versions.number }) + .from(schema.versions) + .where(eq(schema.versions.collectionId, collection.id)) + .orderBy(sql`${schema.versions.number} desc`) + .limit(1) const currentNumber = latest?.number ?? 0 if (body.base_version !== null && body.base_version !== currentNumber) { - return c.json({ - error: 'Version conflict', - currentVersion: currentNumber, - statusCode: 409, - }, 409,) + return c.json( + { + error: 'Version conflict', + currentVersion: currentNumber, + statusCode: 409, + }, + 409, + ) } - const expiresAt = new Date(Date.now() + SESSION_TTL_MS,) + const expiresAt = new Date(Date.now() + SESSION_TTL_MS) - const [session,] = await db - .insert(schema.uploadSessions,) + const [session] = await db + .insert(schema.uploadSessions) .values({ collectionId: collection.id, - accountId: c.get('accountId',)!, + accountId: c.get('accountId')!, baseVersion: body.base_version ?? null, message: body.message ?? null, readme: body.readme ?? null, @@ -90,20 +95,23 @@ export async function startSession(c: Context,) { status: 'open', recordCount: 0, expiresAt, - },) - .returning({ id: schema.uploadSessions.id, },) - - return c.json({ - sessionId: session!.id, - expiresAt: expiresAt.toISOString(), - }, 201,) + }) + .returning({ id: schema.uploadSessions.id }) + + return c.json( + { + sessionId: session!.id, + expiresAt: expiresAt.toISOString(), + }, + 201, + ) } // --- Append a batch of changes to a session --- -export async function appendBatch(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! - const sessionId = c.req.param('sessionId',)! +export async function appendBatch(c: Context) { + const owner = c.req.param('owner')! + const slug = c.req.param('slug')! + const sessionId = c.req.param('sessionId')! const body = await c.req.json<{ changes: { added?: { id: string; type: string; data: unknown; private?: boolean }[] @@ -113,37 +121,40 @@ export async function appendBatch(c: Context,) { }>() // Validate session exists and belongs to caller - const [session,] = await db + const [session] = await db .select() - .from(schema.uploadSessions,) - .where(eq(schema.uploadSessions.id, sessionId,),) - .limit(1,) + .from(schema.uploadSessions) + .where(eq(schema.uploadSessions.id, sessionId)) + .limit(1) if (!session) { - return c.json({ error: 'Upload session not found', statusCode: 404, }, 404,) + return c.json({ error: 'Upload session not found', statusCode: 404 }, 404) } - if (session.accountId !== c.get('accountId',)) { - return c.json({ error: 'Not authorized for this session', statusCode: 403, }, 403,) + if (session.accountId !== c.get('accountId')) { + return c.json({ error: 'Not authorized for this session', statusCode: 403 }, 403) } if (session.status !== 'open') { - return c.json({ - error: 'Session is not open', - status: session.status, - statusCode: 409, - }, 409,) + return c.json( + { + error: 'Session is not open', + status: session.status, + statusCode: 409, + }, + 409, + ) } - if (new Date(session.expiresAt,) < new Date()) { + if (new Date(session.expiresAt) < new Date()) { await db - .update(schema.uploadSessions,) - .set({ status: 'expired', },) - .where(eq(schema.uploadSessions.id, sessionId,),) - return c.json({ error: 'Upload session expired', statusCode: 410, }, 410,) + .update(schema.uploadSessions) + .set({ status: 'expired' }) + .where(eq(schema.uploadSessions.id, sessionId)) + return c.json({ error: 'Upload session expired', statusCode: 410 }, 410) } // Verify collection matches - const collection = await resolveCollection(owner, slug,) + const collection = await resolveCollection(owner, slug) if (!collection || collection.id !== session.collectionId) { - return c.json({ error: 'Collection mismatch', statusCode: 404, }, 404,) + return c.json({ error: 'Collection mismatch', statusCode: 404 }, 404) } // Count total records in this batch @@ -153,13 +164,16 @@ export async function appendBatch(c: Context,) { const batchSize = addedCount + updatedCount + removedCount if (batchSize === 0) { - return c.json({ error: 'Empty batch', statusCode: 400, }, 400,) + return c.json({ error: 'Empty batch', statusCode: 400 }, 400) } if (batchSize > MAX_BATCH_SIZE) { - return c.json({ - error: `Batch too large. Maximum ${MAX_BATCH_SIZE} records per batch.`, - statusCode: 400, - }, 400,) + return c.json( + { + error: `Batch too large. Maximum ${MAX_BATCH_SIZE} records per batch.`, + statusCode: 400, + }, + 400, + ) } // Insert records into staging table (upsert to handle re-sends) @@ -180,7 +194,7 @@ export async function appendBatch(c: Context,) { data: rec.data, private: rec.private ?? false, operation: 'add', - },) + }) } for (const rec of body.changes.updated ?? []) { rows.push({ @@ -190,7 +204,7 @@ export async function appendBatch(c: Context,) { data: rec.data, private: rec.private ?? false, operation: 'update', - },) + }) } for (const id of body.changes.removed ?? []) { rows.push({ @@ -200,59 +214,59 @@ export async function appendBatch(c: Context,) { data: null, private: false, operation: 'remove', - },) + }) } // Batch insert (upsert: last write wins for same recordId) const BATCH = 1000 for (let i = 0; i < rows.length; i += BATCH) { - const batch = rows.slice(i, i + BATCH,) + const batch = rows.slice(i, i + BATCH) await db - .insert(schema.uploadRecords,) - .values(batch,) + .insert(schema.uploadRecords) + .values(batch) .onConflictDoUpdate({ - target: [schema.uploadRecords.sessionId, schema.uploadRecords.recordId,], + target: [schema.uploadRecords.sessionId, schema.uploadRecords.recordId], set: { type: sql`excluded.type`, data: sql`excluded.data`, private: sql`excluded.private`, operation: sql`excluded.operation`, }, - },) + }) } // Update session record count - const [countResult,] = await db - .select({ count: sql`count(*)`, },) - .from(schema.uploadRecords,) - .where(eq(schema.uploadRecords.sessionId, sessionId,),) + const [countResult] = await db + .select({ count: sql`count(*)` }) + .from(schema.uploadRecords) + .where(eq(schema.uploadRecords.sessionId, sessionId)) await db - .update(schema.uploadSessions,) - .set({ recordCount: countResult?.count ?? 0, },) - .where(eq(schema.uploadSessions.id, sessionId,),) + .update(schema.uploadSessions) + .set({ recordCount: countResult?.count ?? 0 }) + .where(eq(schema.uploadSessions.id, sessionId)) return c.json({ - received: { added: addedCount, updated: updatedCount, removed: removedCount, }, + received: { added: addedCount, updated: updatedCount, removed: removedCount }, totalStaged: countResult?.count ?? 0, - },) + }) } // --- Get session status --- -export async function getSession(c: Context,) { - const sessionId = c.req.param('sessionId',)! +export async function getSession(c: Context) { + const sessionId = c.req.param('sessionId')! - const [session,] = await db + const [session] = await db .select() - .from(schema.uploadSessions,) - .where(eq(schema.uploadSessions.id, sessionId,),) - .limit(1,) + .from(schema.uploadSessions) + .where(eq(schema.uploadSessions.id, sessionId)) + .limit(1) if (!session) { - return c.json({ error: 'Upload session not found', statusCode: 404, }, 404,) + return c.json({ error: 'Upload session not found', statusCode: 404 }, 404) } - if (session.accountId !== c.get('accountId',)) { - return c.json({ error: 'Not authorized for this session', statusCode: 403, }, 403,) + if (session.accountId !== c.get('accountId')) { + return c.json({ error: 'Not authorized for this session', statusCode: 403 }, 403) } return c.json({ @@ -262,128 +276,147 @@ export async function getSession(c: Context,) { baseVersion: session.baseVersion, expiresAt: session.expiresAt, createdAt: session.createdAt, - },) + }) } // --- Finalize: build the version from staged records --- -export async function finalize(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! - const sessionId = c.req.param('sessionId',)! +export async function finalize(c: Context) { + const owner = c.req.param('owner')! + const slug = c.req.param('slug')! + const sessionId = c.req.param('sessionId')! // Load and validate session - const [session,] = await db + const [session] = await db .select() - .from(schema.uploadSessions,) - .where(eq(schema.uploadSessions.id, sessionId,),) - .limit(1,) + .from(schema.uploadSessions) + .where(eq(schema.uploadSessions.id, sessionId)) + .limit(1) if (!session) { - return c.json({ error: 'Upload session not found', statusCode: 404, }, 404,) + return c.json({ error: 'Upload session not found', statusCode: 404 }, 404) } - if (session.accountId !== c.get('accountId',)) { - return c.json({ error: 'Not authorized for this session', statusCode: 403, }, 403,) + if (session.accountId !== c.get('accountId')) { + return c.json({ error: 'Not authorized for this session', statusCode: 403 }, 403) } if (session.status !== 'open') { - return c.json({ - error: `Session cannot be finalized (status: ${session.status})`, - statusCode: 409, - }, 409,) + return c.json( + { + error: `Session cannot be finalized (status: ${session.status})`, + statusCode: 409, + }, + 409, + ) } - if (new Date(session.expiresAt,) < new Date()) { + if (new Date(session.expiresAt) < new Date()) { await db - .update(schema.uploadSessions,) - .set({ status: 'expired', },) - .where(eq(schema.uploadSessions.id, sessionId,),) - return c.json({ error: 'Upload session expired', statusCode: 410, }, 410,) + .update(schema.uploadSessions) + .set({ status: 'expired' }) + .where(eq(schema.uploadSessions.id, sessionId)) + return c.json({ error: 'Upload session expired', statusCode: 410 }, 410) } - const collection = await resolveCollection(owner, slug,) + const collection = await resolveCollection(owner, slug) if (!collection || collection.id !== session.collectionId) { - return c.json({ error: 'Collection mismatch', statusCode: 404, }, 404,) + return c.json({ error: 'Collection mismatch', statusCode: 404 }, 404) } // Mark session as finalizing await db - .update(schema.uploadSessions,) - .set({ status: 'finalizing', },) - .where(eq(schema.uploadSessions.id, sessionId,),) + .update(schema.uploadSessions) + .set({ status: 'finalizing' }) + .where(eq(schema.uploadSessions.id, sessionId)) try { // Re-check optimistic lock - const [latest,] = await db + const [latest] = await db .select() - .from(schema.versions,) - .where(eq(schema.versions.collectionId, collection.id,),) - .orderBy(sql`${schema.versions.number} desc`,) - .limit(1,) + .from(schema.versions) + .where(eq(schema.versions.collectionId, collection.id)) + .orderBy(sql`${schema.versions.number} desc`) + .limit(1) const currentNumber = latest?.number ?? 0 if (session.baseVersion !== null && session.baseVersion !== currentNumber) { await db - .update(schema.uploadSessions,) - .set({ status: 'failed', },) - .where(eq(schema.uploadSessions.id, sessionId,),) - return c.json({ - error: 'Version conflict', - currentVersion: currentNumber, - statusCode: 409, - }, 409,) + .update(schema.uploadSessions) + .set({ status: 'failed' }) + .where(eq(schema.uploadSessions.id, sessionId)) + return c.json( + { + error: 'Version conflict', + currentVersion: currentNumber, + statusCode: 409, + }, + 409, + ) } // --- Resolve schemas --- let prevSchemaEntries: SchemaEntry[] = [] if (latest) { - prevSchemaEntries = await loadVersionSchemas(latest.id,) + prevSchemaEntries = await loadVersionSchemas(latest.id) } let schemasInput: Record - if (session.schemas && Object.keys(session.schemas as object,).length > 0) { + if (session.schemas && Object.keys(session.schemas as object).length > 0) { schemasInput = session.schemas as Record } else if (prevSchemaEntries.length > 0) { - schemasInput = Object.fromEntries(prevSchemaEntries.map((e,) => [e.slug, e.schema,]),) + schemasInput = Object.fromEntries(prevSchemaEntries.map((e) => [e.slug, e.schema])) } else { await db - .update(schema.uploadSessions,) - .set({ status: 'failed', },) - .where(eq(schema.uploadSessions.id, sessionId,),) - return c.json({ - error: 'Schemas required', - message: 'First version must include a `schemas` map with at least one type definition.', - statusCode: 422, - }, 422,) + .update(schema.uploadSessions) + .set({ status: 'failed' }) + .where(eq(schema.uploadSessions.id, sessionId)) + return c.json( + { + error: 'Schemas required', + message: 'First version must include a `schemas` map with at least one type definition.', + statusCode: 422, + }, + 422, + ) } // Hash and upsert schemas - const newSchemaSet: { slug: string; schemaId: string; schemaHash: string; schema: Record }[] = [] - for (const [typeSlug, typeSchema,] of Object.entries(schemasInput,)) { - const hash = hashSchema(typeSchema,) - const [existing,] = await db - .select({ id: schema.schemas.id, },) - .from(schema.schemas,) - .where(eq(schema.schemas.schemaHash, hash,),) - .limit(1,) + const newSchemaSet: { + slug: string + schemaId: string + schemaHash: string + schema: Record + }[] = [] + for (const [typeSlug, typeSchema] of Object.entries(schemasInput)) { + const hash = hashSchema(typeSchema) + const [existing] = await db + .select({ id: schema.schemas.id }) + .from(schema.schemas) + .where(eq(schema.schemas.schemaHash, hash)) + .limit(1) let schemaId: string if (existing) { schemaId = existing.id } else { - const [inserted,] = await db - .insert(schema.schemas,) - .values({ schema: typeSchema as any, schemaHash: hash, },) - .returning({ id: schema.schemas.id, },) + const [inserted] = await db + .insert(schema.schemas) + .values({ schema: typeSchema as any, schemaHash: hash }) + .returning({ id: schema.schemas.id }) schemaId = inserted!.id } - newSchemaSet.push({ slug: typeSlug, schemaId, schemaHash: hash, schema: typeSchema as Record, },) + newSchemaSet.push({ + slug: typeSlug, + schemaId, + schemaHash: hash, + schema: typeSchema as Record, + }) } // Check schema changes - const prevSchemaMap = new Map(prevSchemaEntries.map((e,) => [e.slug, e.schemaHash,]),) - const newSchemaMap = new Map(newSchemaSet.map((e,) => [e.slug, e.schemaHash,]),) + const prevSchemaMap = new Map(prevSchemaEntries.map((e) => [e.slug, e.schemaHash])) + const newSchemaMap = new Map(newSchemaSet.map((e) => [e.slug, e.schemaHash])) let schemaChanged = prevSchemaMap.size !== newSchemaMap.size if (!schemaChanged) { - for (const [s, hash,] of newSchemaMap) { - if (prevSchemaMap.get(s,) !== hash) { + for (const [s, hash] of newSchemaMap) { + if (prevSchemaMap.get(s) !== hash) { schemaChanged = true break } @@ -393,17 +426,17 @@ export async function finalize(c: Context,) { // Build validators const validators = new Map>() for (const entry of newSchemaSet) { - validators.set(entry.slug, ajv.compile(entry.schema as object,),) + validators.set(entry.slug, ajv.compile(entry.schema as object)) } // Get file hashes from previous version let existingFileHashes: string[] = [] if (latest) { const vf = await db - .select({ hash: schema.versionFiles.fileHash, },) - .from(schema.versionFiles,) - .where(eq(schema.versionFiles.versionId, latest.id,),) - existingFileHashes = vf.map((f,) => f.hash) + .select({ hash: schema.versionFiles.fileHash }) + .from(schema.versionFiles) + .where(eq(schema.versionFiles.versionId, latest.id)) + existingFileHashes = vf.map((f) => f.hash) } // --- Streaming finalize --- @@ -422,7 +455,7 @@ export async function finalize(c: Context,) { data jsonb NOT NULL, private boolean NOT NULL DEFAULT false ) ON COMMIT DROP - `,) + `) // Insert existing records from base version (if any) if (latest) { @@ -431,7 +464,7 @@ export async function finalize(c: Context,) { SELECT record_id, type, data, private FROM records WHERE version_id = ${latest.id} - `,) + `) } // Apply staged changes (upserts and deletes) @@ -445,7 +478,7 @@ export async function finalize(c: Context,) { type = EXCLUDED.type, data = EXCLUDED.data, private = EXCLUDED.private - `,) + `) // Remove deleted records await db.execute(sql` @@ -454,64 +487,68 @@ export async function finalize(c: Context,) { SELECT record_id FROM upload_records WHERE session_id = ${sessionId} AND operation = 'remove' ) - `,) + `) // Get total count - const [countResult,] = await db.execute(sql`SELECT count(*) as cnt FROM _finalize_records`,) - const totalRecordCount = Number((countResult as any).cnt,) + const [countResult] = await db.execute(sql`SELECT count(*) as cnt FROM _finalize_records`) + const totalRecordCount = Number((countResult as any).cnt) // Check all record types have schemas - const [typesResult,] = await db.execute(sql`SELECT DISTINCT type FROM _finalize_records`,) + const [typesResult] = await db.execute(sql`SELECT DISTINCT type FROM _finalize_records`) // typesResult is an array of rows - const allTypes: string[] = (Array.isArray(typesResult,) ? typesResult : [typesResult,]) - .filter(Boolean,) - .map((r: any,) => r.type) - const missingSchemas = allTypes.filter((t,) => !(t in schemasInput)) + const allTypes: string[] = (Array.isArray(typesResult) ? typesResult : [typesResult]) + .filter(Boolean) + .map((r: any) => r.type) + const missingSchemas = allTypes.filter((t) => !(t in schemasInput)) if (missingSchemas.length > 0) { - await db.update(schema.uploadSessions,).set({ status: 'failed', },).where( - eq(schema.uploadSessions.id, sessionId,), + await db + .update(schema.uploadSessions) + .set({ status: 'failed' }) + .where(eq(schema.uploadSessions.id, sessionId)) + await db.execute(sql`DROP TABLE IF EXISTS _finalize_records`) + return c.json( + { + error: 'Missing schemas for record types', + types: missingSchemas, + statusCode: 422, + }, + 422, ) - await db.execute(sql`DROP TABLE IF EXISTS _finalize_records`,) - return c.json({ - error: 'Missing schemas for record types', - types: missingSchemas, - statusCode: 422, - }, 422,) } // --- Stream through records in sorted batches --- // We compute hashes incrementally and validate + collect file refs + insert records const STREAM_BATCH = 5000 - const privateTypes = getPrivateTypes(newSchemaSet as SchemaEntry[],) + const privateTypes = getPrivateTypes(newSchemaSet as SchemaEntry[]) // Streaming hash state - const privateHasher = createHash('sha256',) - const publicHasher = createHash('sha256',) + const privateHasher = createHash('sha256') + const publicHasher = createHash('sha256') // We build the canonical hash as: {"schemas":{...},"records":[],"files":[...],"readme":...} // For streaming, we compute records portion incrementally const schemaSetForHash = newSchemaSet - .map((e,) => ({ slug: e.slug, schemaHash: e.schemaHash, })) - .sort((a, b,) => a.slug.localeCompare(b.slug,)) + .map((e) => ({ slug: e.slug, schemaHash: e.schemaHash })) + .sort((a, b) => a.slug.localeCompare(b.slug)) const publicSchemaSet = newSchemaSet - .filter((e,) => !privateTypes.has(e.slug,)) - .map((e,) => ({ slug: e.slug, schemaHash: hashSchema(filterTypeSchema(e.schema,),), })) - .sort((a, b,) => a.slug.localeCompare(b.slug,)) + .filter((e) => !privateTypes.has(e.slug)) + .map((e) => ({ slug: e.slug, schemaHash: hashSchema(filterTypeSchema(e.schema)) })) + .sort((a, b) => a.slug.localeCompare(b.slug)) // We'll collect all record canonical forms for hashing // Using incremental approach: hash prefix, then each record, then suffix const schemasCanonical = JSON.stringify( - Object.fromEntries(schemaSetForHash.map((s,) => [s.slug, s.schemaHash,]),), + Object.fromEntries(schemaSetForHash.map((s) => [s.slug, s.schemaHash])), ) const publicSchemasCanonical = JSON.stringify( - Object.fromEntries(publicSchemaSet.map((s,) => [s.slug, s.schemaHash,]),), + Object.fromEntries(publicSchemaSet.map((s) => [s.slug, s.schemaHash])), ) // Start building canonical: {"schemas":...,"records":[ - privateHasher.update(`{"schemas":${schemasCanonical},"records":[`,) - publicHasher.update(`{"schemas":${publicSchemasCanonical},"records":[`,) + privateHasher.update(`{"schemas":${schemasCanonical},"records":[`) + publicHasher.update(`{"schemas":${publicSchemasCanonical},"records":[`) - const referencedHashes = new Set(existingFileHashes,) + const referencedHashes = new Set(existingFileHashes) const validationErrors: { recordId: string; type: string; errors: string[] }[] = [] let totalBytes = 0 let recordCount = 0 @@ -521,16 +558,16 @@ export async function finalize(c: Context,) { let hasMore = true // Check if staged records exist (indicates changes) - const [stagedCount,] = await db - .select({ count: sql`count(*)`, },) - .from(schema.uploadRecords,) - .where(eq(schema.uploadRecords.sessionId, sessionId,),) + const [stagedCount] = await db + .select({ count: sql`count(*)` }) + .from(schema.uploadRecords) + .where(eq(schema.uploadRecords.sessionId, sessionId)) hasChanges = (stagedCount?.count ?? 0) > 0 // Insert the new version early to get its ID for record insertion // We'll update the hash fields after streaming const readmeValue = session.readme !== null ? session.readme : (latest?.readme ?? null) - const semver = deriveSemver(latest?.semver ?? null, schemaChanged, hasChanges,) + const semver = deriveSemver(latest?.semver ?? null, schemaChanged, hasChanges) const newNumber = currentNumber + 1 // We need to process all records before we can insert the version (need hashes) @@ -539,15 +576,15 @@ export async function finalize(c: Context,) { // Phase 2: insert records (re-stream from temp table) while (hasMore) { - const batch = await db.execute(sql` + const batch = (await db.execute(sql` SELECT record_id, type, data, private FROM _finalize_records WHERE record_id > ${cursor} ORDER BY record_id ASC LIMIT ${STREAM_BATCH} - `,) as any[] + `)) as any[] - const rows = Array.isArray(batch,) ? batch : [] + const rows = Array.isArray(batch) ? batch : [] if (rows.length === 0) { hasMore = false break @@ -555,56 +592,56 @@ export async function finalize(c: Context,) { for (const rec of rows) { // Validate - const validate = validators.get(rec.type,) + const validate = validators.get(rec.type) if (!validate) { validationErrors.push({ recordId: rec.record_id, type: rec.type, - errors: [`No schema defined for record type "${rec.type}"`,], - },) - } else if (!validate(rec.data,)) { + errors: [`No schema defined for record type "${rec.type}"`], + }) + } else if (!validate(rec.data)) { validationErrors.push({ recordId: rec.record_id, type: rec.type, errors: (validate.errors ?? []).map( - (e,) => `${e.instancePath || '/'} ${e.message ?? 'validation failed'}`, + (e) => `${e.instancePath || '/'} ${e.message ?? 'validation failed'}`, ), - },) + }) } // Feed into private hash (all records) - const recCanonical = JSON.stringify({ id: rec.record_id, type: rec.type, data: rec.data, },) - if (recordCount > 0) privateHasher.update(',',) - privateHasher.update(recCanonical,) + const recCanonical = JSON.stringify({ id: rec.record_id, type: rec.type, data: rec.data }) + if (recordCount > 0) privateHasher.update(',') + privateHasher.update(recCanonical) recordCount++ // Feed into public hash (non-private records only, with private fields stripped) const isPrivateRecord = rec.private === true - const isPrivateType = privateTypes.has(rec.type,) + const isPrivateType = privateTypes.has(rec.type) if (!isPrivateRecord && !isPrivateType) { - const entry = newSchemaSet.find((e,) => e.slug === rec.type) - const privFields = entry ? getPrivateFields(entry.schema,) : new Set() - const pubData = privFields.size > 0 ? filterRecordData(rec.data, privFields,) : rec.data - const pubCanonical = JSON.stringify({ id: rec.record_id, type: rec.type, data: pubData, },) - if (publicRecordCount > 0) publicHasher.update(',',) - publicHasher.update(pubCanonical,) + const entry = newSchemaSet.find((e) => e.slug === rec.type) + const privFields = entry ? getPrivateFields(entry.schema) : new Set() + const pubData = privFields.size > 0 ? filterRecordData(rec.data, privFields) : rec.data + const pubCanonical = JSON.stringify({ id: rec.record_id, type: rec.type, data: pubData }) + if (publicRecordCount > 0) publicHasher.update(',') + publicHasher.update(pubCanonical) publicRecordCount++ } // Compute bytes - totalBytes += Buffer.byteLength(JSON.stringify(rec.data,), 'utf-8',) + totalBytes += Buffer.byteLength(JSON.stringify(rec.data), 'utf-8') // Scan for $file references const data = rec.data as Record - for (const val of Object.values(data,)) { + for (const val of Object.values(data)) { if ( - typeof val === 'object' - && val !== null - && '$file' in val - && typeof (val as { $file: string }).$file === 'string' + typeof val === 'object' && + val !== null && + '$file' in val && + typeof (val as { $file: string }).$file === 'string' ) { - const fileHash = (val as { $file: string }).$file.replace('sha256:', '',) - referencedHashes.add(fileHash,) + const fileHash = (val as { $file: string }).$file.replace('sha256:', '') + referencedHashes.add(fileHash) } } } @@ -615,107 +652,119 @@ export async function finalize(c: Context,) { // Bail on validation errors if (validationErrors.length > 0) { - await db.update(schema.uploadSessions,).set({ status: 'failed', },).where( - eq(schema.uploadSessions.id, sessionId,), + await db + .update(schema.uploadSessions) + .set({ status: 'failed' }) + .where(eq(schema.uploadSessions.id, sessionId)) + await db.execute(sql`DROP TABLE IF EXISTS _finalize_records`) + return c.json( + { + error: 'Schema validation failed', + validationErrors: validationErrors.slice(0, 100), // cap error list + statusCode: 422, + }, + 422, ) - await db.execute(sql`DROP TABLE IF EXISTS _finalize_records`,) - return c.json({ - error: 'Schema validation failed', - validationErrors: validationErrors.slice(0, 100,), // cap error list - statusCode: 422, - }, 422,) } // Check all referenced files exist - const allFileHashes = Array.from(referencedHashes,) + const allFileHashes = Array.from(referencedHashes) if (allFileHashes.length > 0) { const existingFiles = await db - .select({ hash: schema.files.hash, },) - .from(schema.files,) - .where(inArray(schema.files.hash, allFileHashes,),) - const existingSet = new Set(existingFiles.map((f,) => f.hash),) - let filesNeeded = allFileHashes.filter((h,) => !existingSet.has(h,)) + .select({ hash: schema.files.hash }) + .from(schema.files) + .where(inArray(schema.files.hash, allFileHashes)) + const existingSet = new Set(existingFiles.map((f) => f.hash)) + let filesNeeded = allFileHashes.filter((h) => !existingSet.has(h)) // For files not in local DB, check if they exist in S3 (shared bucket) if (filesNeeded.length > 0) { const stillNeeded: string[] = [] for (const h of filesNeeded) { - const key = `files/${h.slice(0, 2,)}/${h.slice(2, 4,)}/${h}` - const meta = await getS3ObjectMeta(key,) + const key = `files/${h.slice(0, 2)}/${h.slice(2, 4)}/${h}` + const meta = await getS3ObjectMeta(key) if (meta !== null) { - await db.insert(schema.files,).values({ - hash: h, - size: meta.size, - mimeType: meta.contentType, - storageKey: key, - },).onConflictDoNothing() + await db + .insert(schema.files) + .values({ + hash: h, + size: meta.size, + mimeType: meta.contentType, + storageKey: key, + }) + .onConflictDoNothing() } else { - stillNeeded.push(h,) + stillNeeded.push(h) } } filesNeeded = stillNeeded } if (filesNeeded.length > 0) { - await db.update(schema.uploadSessions,).set({ status: 'failed', },).where( - eq(schema.uploadSessions.id, sessionId,), + await db + .update(schema.uploadSessions) + .set({ status: 'failed' }) + .where(eq(schema.uploadSessions.id, sessionId)) + await db.execute(sql`DROP TABLE IF EXISTS _finalize_records`) + return c.json( + { + error: 'Missing files', + filesNeeded: filesNeeded.map((h) => `sha256:${h}`), + statusCode: 422, + }, + 422, ) - await db.execute(sql`DROP TABLE IF EXISTS _finalize_records`,) - return c.json({ - error: 'Missing files', - filesNeeded: filesNeeded.map((h,) => `sha256:${h}`), - statusCode: 422, - }, 422,) } } // Finalize hash computation const sortedFileHashes = allFileHashes.sort() - const filesCanonical = JSON.stringify(sortedFileHashes,) - const readmeCanonical = JSON.stringify(readmeValue ?? null,) + const filesCanonical = JSON.stringify(sortedFileHashes) + const readmeCanonical = JSON.stringify(readmeValue ?? null) - privateHasher.update(`],"files":${filesCanonical},"readme":${readmeCanonical}}`,) - publicHasher.update(`],"files":${filesCanonical},"readme":${readmeCanonical}}`,) + privateHasher.update(`],"files":${filesCanonical},"readme":${readmeCanonical}}`) + publicHasher.update(`],"files":${filesCanonical},"readme":${readmeCanonical}}`) - const versionHash = 'private:' + privateHasher.digest('hex',) - const publicHash = 'public:' + publicHasher.digest('hex',) + const versionHash = 'private:' + privateHasher.digest('hex') + const publicHash = 'public:' + publicHasher.digest('hex') // Check for duplicate hash - const [existingHash,] = await db - .select({ number: schema.versions.number, },) - .from(schema.versions,) + const [existingHash] = await db + .select({ number: schema.versions.number }) + .from(schema.versions) .where( - and( - eq(schema.versions.collectionId, collection.id,), - eq(schema.versions.hash, versionHash,), - ), + and(eq(schema.versions.collectionId, collection.id), eq(schema.versions.hash, versionHash)), ) - .limit(1,) + .limit(1) if (existingHash) { - await db.update(schema.uploadSessions,).set({ status: 'failed', },).where( - eq(schema.uploadSessions.id, sessionId,), + await db + .update(schema.uploadSessions) + .set({ status: 'failed' }) + .where(eq(schema.uploadSessions.id, sessionId)) + await db.execute(sql`DROP TABLE IF EXISTS _finalize_records`) + return c.json( + { + error: 'No changes detected', + message: `Version ${existingHash.number} already has identical content`, + existingVersion: existingHash.number, + }, + 409, ) - await db.execute(sql`DROP TABLE IF EXISTS _finalize_records`,) - return c.json({ - error: 'No changes detected', - message: `Version ${existingHash.number} already has identical content`, - existingVersion: existingHash.number, - }, 409,) } // Add file sizes to totalBytes if (allFileHashes.length > 0) { - const [fileSizeSum,] = await db - .select({ total: sql`coalesce(sum(${schema.files.size}), 0)`, },) - .from(schema.files,) - .where(inArray(schema.files.hash, allFileHashes,),) - totalBytes += Number(fileSizeSum?.total ?? 0,) + const [fileSizeSum] = await db + .select({ total: sql`coalesce(sum(${schema.files.size}), 0)` }) + .from(schema.files) + .where(inArray(schema.files.hash, allFileHashes)) + totalBytes += Number(fileSizeSum?.total ?? 0) } // Insert version - const [version,] = await db - .insert(schema.versions,) + const [version] = await db + .insert(schema.versions) .values({ collectionId: collection.id, number: newNumber, @@ -725,13 +774,13 @@ export async function finalize(c: Context,) { baseNumber: session.baseVersion, message: session.message ?? null, readme: readmeValue, - pushedBy: c.get('accountId',) ?? null, + pushedBy: c.get('accountId') ?? null, appId: session.appId ?? null, actorId: session.actorId ?? null, recordCount, fileCount: allFileHashes.length, totalBytes, - },) + }) .returning() // Phase 2: Insert records from temp table into the real records table (in batches) @@ -739,15 +788,15 @@ export async function finalize(c: Context,) { INSERT INTO records (version_id, record_id, type, data, private) SELECT ${version!.id}, record_id, type, data, private FROM _finalize_records - `,) + `) // Clean up temp table - await db.execute(sql`DROP TABLE IF EXISTS _finalize_records`,) + await db.execute(sql`DROP TABLE IF EXISTS _finalize_records`) // Insert version_files if (allFileHashes.length > 0) { - await db.insert(schema.versionFiles,).values( - allFileHashes.map((hash,) => ({ + await db.insert(schema.versionFiles).values( + allFileHashes.map((hash) => ({ versionId: version!.id, fileHash: hash, })), @@ -755,8 +804,8 @@ export async function finalize(c: Context,) { } // Insert version_schemas - await db.insert(schema.versionSchemas,).values( - newSchemaSet.map((entry,) => ({ + await db.insert(schema.versionSchemas).values( + newSchemaSet.map((entry) => ({ versionId: version!.id, slug: entry.slug, schemaId: entry.schemaId, @@ -765,60 +814,55 @@ export async function finalize(c: Context,) { // Update collection timestamp await db - .update(schema.collections,) - .set({ updatedAt: new Date(), },) - .where(eq(schema.collections.id, collection.id,),) + .update(schema.collections) + .set({ updatedAt: new Date() }) + .where(eq(schema.collections.id, collection.id)) // Clean up: delete staged records and the session itself - await db - .delete(schema.uploadRecords,) - .where(eq(schema.uploadRecords.sessionId, sessionId,),) - await db - .delete(schema.uploadSessions,) - .where(eq(schema.uploadSessions.id, sessionId,),) - - return c.json({ - version: newNumber, - semver, - hash: versionHash, - recordCount, - fileCount: allFileHashes.length, - }, 201,) + await db.delete(schema.uploadRecords).where(eq(schema.uploadRecords.sessionId, sessionId)) + await db.delete(schema.uploadSessions).where(eq(schema.uploadSessions.id, sessionId)) + + return c.json( + { + version: newNumber, + semver, + hash: versionHash, + recordCount, + fileCount: allFileHashes.length, + }, + 201, + ) } catch (err) { // Mark session as failed on unexpected error - await db.execute(sql`DROP TABLE IF EXISTS _finalize_records`,) + await db.execute(sql`DROP TABLE IF EXISTS _finalize_records`) await db - .update(schema.uploadSessions,) - .set({ status: 'failed', },) - .where(eq(schema.uploadSessions.id, sessionId,),) + .update(schema.uploadSessions) + .set({ status: 'failed' }) + .where(eq(schema.uploadSessions.id, sessionId)) throw err } } // --- Abort/cancel a session --- -export async function cancelSession(c: Context,) { - const sessionId = c.req.param('sessionId',)! +export async function cancelSession(c: Context) { + const sessionId = c.req.param('sessionId')! - const [session,] = await db + const [session] = await db .select() - .from(schema.uploadSessions,) - .where(eq(schema.uploadSessions.id, sessionId,),) - .limit(1,) + .from(schema.uploadSessions) + .where(eq(schema.uploadSessions.id, sessionId)) + .limit(1) if (!session) { - return c.json({ error: 'Upload session not found', statusCode: 404, }, 404,) + return c.json({ error: 'Upload session not found', statusCode: 404 }, 404) } - if (session.accountId !== c.get('accountId',)) { - return c.json({ error: 'Not authorized for this session', statusCode: 403, }, 403,) + if (session.accountId !== c.get('accountId')) { + return c.json({ error: 'Not authorized for this session', statusCode: 403 }, 403) } // Delete staged records and session - await db - .delete(schema.uploadRecords,) - .where(eq(schema.uploadRecords.sessionId, sessionId,),) - await db - .delete(schema.uploadSessions,) - .where(eq(schema.uploadSessions.id, sessionId,),) + await db.delete(schema.uploadRecords).where(eq(schema.uploadRecords.sessionId, sessionId)) + await db.delete(schema.uploadSessions).where(eq(schema.uploadSessions.id, sessionId)) - return c.body(null, 204,) + return c.body(null, 204) } diff --git a/src/api/versions.ts b/src/api/versions.ts index a89470a..337115d 100644 --- a/src/api/versions.ts +++ b/src/api/versions.ts @@ -1,8 +1,10 @@ -import { and, eq, inArray, sql, } from 'drizzle-orm' -import type { Context, } from 'hono' -import { createHash, } from 'node:crypto' -import { db, schema, } from '../db/client.server.js' -import { buildArkUrl, DEFAULT_NAAN, } from '../lib/ark.js' +import { createHash } from 'node:crypto' + +import { and, eq, inArray, sql } from 'drizzle-orm' +import type { Context } from 'hono' + +import { db, schema } from '../db/client.server.js' +import { buildArkUrl, DEFAULT_NAAN } from '../lib/ark.js' import { ajv, deriveSemver, @@ -14,20 +16,20 @@ import { loadVersionSchemas, type SchemaEntry, } from '../lib/version-helpers.server.js' -import { type AuthEnv, } from './auth.server.js' +import { type AuthEnv } from './auth.server.js' /** Build a public-facing schemas map (excluding private types, stripping private fields) */ -function filterSchemasForPublic(schemaEntries: SchemaEntry[],): Record { +function filterSchemasForPublic(schemaEntries: SchemaEntry[]): Record { const result: Record = {} for (const entry of schemaEntries) { if ((entry.schema as any)?.private === true) continue - result[entry.slug] = filterTypeSchema(entry.schema,) + result[entry.slug] = filterTypeSchema(entry.schema) } return result } /** Check if requester is the owner of a collection */ -function isOwner(accountId: string | undefined, collectionAccountId: string,): boolean { +function isOwner(accountId: string | undefined, collectionAccountId: string): boolean { return accountId != null && accountId === collectionAccountId } @@ -39,15 +41,15 @@ function computeVersionHash( ): string { const canonical = JSON.stringify({ schemas: Object.fromEntries( - schemaSet.sort((a, b,) => a.slug.localeCompare(b.slug,)).map((s,) => [s.slug, s.schemaHash,]), + schemaSet.sort((a, b) => a.slug.localeCompare(b.slug)).map((s) => [s.slug, s.schemaHash]), ), records: recordRows - .sort((a, b,) => a.recordId.localeCompare(b.recordId,)) - .map((r,) => ({ id: r.recordId, type: r.type, data: r.data, })), + .sort((a, b) => a.recordId.localeCompare(b.recordId)) + .map((r) => ({ id: r.recordId, type: r.type, data: r.data })), files: fileHashes.sort(), readme: readme ?? null, - },) - return 'private:' + createHash('sha256',).update(canonical,).digest('hex',) + }) + return 'private:' + createHash('sha256').update(canonical).digest('hex') } /** Compute a public hash that only covers non-private content */ @@ -57,85 +59,91 @@ function computePublicHash( fileHashes: string[], readme: string | null, ): string { - const privateTypes = getPrivateTypes(schemaEntries,) + const privateTypes = getPrivateTypes(schemaEntries) // Build public schema set (non-private types, with private fields stripped) const publicSchemaSet: { slug: string; schemaHash: string }[] = [] for (const entry of schemaEntries) { - if (privateTypes.has(entry.slug,)) continue - const filtered = filterTypeSchema(entry.schema,) - publicSchemaSet.push({ slug: entry.slug, schemaHash: hashSchema(filtered,), },) + if (privateTypes.has(entry.slug)) continue + const filtered = filterTypeSchema(entry.schema) + publicSchemaSet.push({ slug: entry.slug, schemaHash: hashSchema(filtered) }) } // Filter to public records only, and strip private fields const publicRecords = recordRows - .filter((r,) => !r.private && !privateTypes.has(r.type,)) - .map((r,) => { - const entry = schemaEntries.find((e,) => e.slug === r.type) - const privateFields = entry ? getPrivateFields(entry.schema,) : new Set() - const data = privateFields.size > 0 ? filterRecordData(r.data, privateFields,) : r.data - return { id: r.recordId, type: r.type, data, } - },) - .sort((a, b,) => a.id.localeCompare(b.id,)) + .filter((r) => !r.private && !privateTypes.has(r.type)) + .map((r) => { + const entry = schemaEntries.find((e) => e.slug === r.type) + const privateFields = entry ? getPrivateFields(entry.schema) : new Set() + const data = privateFields.size > 0 ? filterRecordData(r.data, privateFields) : r.data + return { id: r.recordId, type: r.type, data } + }) + .sort((a, b) => a.id.localeCompare(b.id)) const canonical = JSON.stringify({ schemas: Object.fromEntries( - publicSchemaSet.sort((a, b,) => a.slug.localeCompare(b.slug,)).map((s,) => [s.slug, s.schemaHash,]), + publicSchemaSet + .sort((a, b) => a.slug.localeCompare(b.slug)) + .map((s) => [s.slug, s.schemaHash]), ), records: publicRecords, files: fileHashes.sort(), readme: readme ?? null, - },) - return 'public:' + createHash('sha256',).update(canonical,).digest('hex',) + }) + return 'public:' + createHash('sha256').update(canonical).digest('hex') } // Lazily backfill totalBytes for versions that were created before we tracked it // or where the value was corrupted by a string concatenation bug -async function backfillTotalBytes(version: { id: number; totalBytes: number; recordCount: number },) { +async function backfillTotalBytes(version: { + id: number + totalBytes: number + recordCount: number +}) { // Skip recomputation if totalBytes looks reasonable (> 0 and < 1TB) - if (version.totalBytes > 0 && version.totalBytes < 1_099_511_627_776 || version.recordCount === 0) { + if ( + (version.totalBytes > 0 && version.totalBytes < 1_099_511_627_776) || + version.recordCount === 0 + ) { return version.totalBytes } const records = await db - .select({ data: schema.records.data, },) - .from(schema.records,) - .where(eq(schema.records.versionId, version.id,),) + .select({ data: schema.records.data }) + .from(schema.records) + .where(eq(schema.records.versionId, version.id)) let totalBytes = 0 for (const r of records) { - totalBytes += Buffer.byteLength(JSON.stringify(r.data,), 'utf-8',) + totalBytes += Buffer.byteLength(JSON.stringify(r.data), 'utf-8') } - const [fileSizeResult,] = await db - .select({ total: sql`coalesce(sum(${schema.files.size}), 0)`, },) - .from(schema.versionFiles,) - .innerJoin(schema.files, eq(schema.versionFiles.fileHash, schema.files.hash,),) - .where(eq(schema.versionFiles.versionId, version.id,),) - totalBytes += Number(fileSizeResult?.total ?? 0,) + const [fileSizeResult] = await db + .select({ total: sql`coalesce(sum(${schema.files.size}), 0)` }) + .from(schema.versionFiles) + .innerJoin(schema.files, eq(schema.versionFiles.fileHash, schema.files.hash)) + .where(eq(schema.versionFiles.versionId, version.id)) + totalBytes += Number(fileSizeResult?.total ?? 0) // Persist so we don't recompute next time - await db - .update(schema.versions,) - .set({ totalBytes, },) - .where(eq(schema.versions.id, version.id,),) + await db.update(schema.versions).set({ totalBytes }).where(eq(schema.versions.id, version.id)) return totalBytes } // List versions -export async function list(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! - const limit = c.req.query('limit',) - const offset = c.req.query('offset',) +export async function list(c: Context) { + const owner = c.req.param('owner')! + const slug = c.req.param('slug')! + const limit = c.req.query('limit') + const offset = c.req.query('offset') - const collection = await resolveCollection(owner, slug,) - if (!collection) return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) + const collection = await resolveCollection(owner, slug) + if (!collection) return c.json({ error: 'Collection not found', statusCode: 404 }, 404) - const accountId = c.get('accountId',) - const ownerAccess = isOwner(accountId, collection.accountId,) - const arkInfo = await getCollectionArkInfo(collection.id,).catch(() => null) + const accountId = c.get('accountId') + const ownerAccess = isOwner(accountId, collection.accountId) + const arkInfo = await getCollectionArkInfo(collection.id).catch(() => null) const rows = await db .select({ @@ -150,206 +158,226 @@ export async function list(c: Context,) { fileCount: schema.versions.fileCount, totalBytes: schema.versions.totalBytes, createdAt: schema.versions.createdAt, - },) - .from(schema.versions,) - .where(eq(schema.versions.collectionId, collection.id,),) - .orderBy(sql`${schema.versions.number} desc`,) - .limit(Math.min(parseInt(limit ?? '50', 10,), 100,),) - .offset(parseInt(offset ?? '0', 10,),) - - return c.json(rows.map((row,) => ({ - number: row.number, - semver: row.semver, - hash: ownerAccess ? row.hash : (row.publicHash ?? row.hash), - message: row.message, - appId: row.appId, - actorId: row.actorId, - recordCount: row.recordCount, - fileCount: row.fileCount, - totalBytes: row.totalBytes, - createdAt: row.createdAt, - ark: arkInfo ? buildArkUrl(arkInfo.naan, arkInfo.shoulder, arkInfo.arkId, row.number,) : null, - })),) + }) + .from(schema.versions) + .where(eq(schema.versions.collectionId, collection.id)) + .orderBy(sql`${schema.versions.number} desc`) + .limit(Math.min(parseInt(limit ?? '50', 10), 100)) + .offset(parseInt(offset ?? '0', 10)) + + return c.json( + rows.map((row) => ({ + number: row.number, + semver: row.semver, + hash: ownerAccess ? row.hash : (row.publicHash ?? row.hash), + message: row.message, + appId: row.appId, + actorId: row.actorId, + recordCount: row.recordCount, + fileCount: row.fileCount, + totalBytes: row.totalBytes, + createdAt: row.createdAt, + ark: arkInfo ? buildArkUrl(arkInfo.naan, arkInfo.shoulder, arkInfo.arkId, row.number) : null, + })), + ) } // Latest version -export async function latest(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! - const collection = await resolveCollection(owner, slug,) - if (!collection) return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) +export async function latest(c: Context) { + const owner = c.req.param('owner')! + const slug = c.req.param('slug')! + const collection = await resolveCollection(owner, slug) + if (!collection) return c.json({ error: 'Collection not found', statusCode: 404 }, 404) - const [version,] = await db + const [version] = await db .select() - .from(schema.versions,) - .where(eq(schema.versions.collectionId, collection.id,),) - .orderBy(sql`${schema.versions.number} desc`,) - .limit(1,) + .from(schema.versions) + .where(eq(schema.versions.collectionId, collection.id)) + .orderBy(sql`${schema.versions.number} desc`) + .limit(1) - if (!version) return c.json({ error: 'No versions', statusCode: 404, }, 404,) - version.totalBytes = await backfillTotalBytes(version,) + if (!version) return c.json({ error: 'No versions', statusCode: 404 }, 404) + version.totalBytes = await backfillTotalBytes(version) - const schemaEntries = await loadVersionSchemas(version.id,) - const accountId = c.get('accountId',) - const ownerAccess = isOwner(accountId, collection.accountId,) - const arkInfo = await getCollectionArkInfo(collection.id,).catch(() => null) + const schemaEntries = await loadVersionSchemas(version.id) + const accountId = c.get('accountId') + const ownerAccess = isOwner(accountId, collection.accountId) + const arkInfo = await getCollectionArkInfo(collection.id).catch(() => null) const schemasMap = ownerAccess - ? Object.fromEntries(schemaEntries.map((e,) => [e.slug, e.schema,]),) - : filterSchemasForPublic(schemaEntries,) + ? Object.fromEntries(schemaEntries.map((e) => [e.slug, e.schema])) + : filterSchemasForPublic(schemaEntries) return c.json({ ...version, hash: ownerAccess ? version.hash : (version.publicHash ?? version.hash), schemas: schemasMap, - ark: arkInfo ? buildArkUrl(arkInfo.naan, arkInfo.shoulder, arkInfo.arkId, version.number,) : null, - },) + ark: arkInfo + ? buildArkUrl(arkInfo.naan, arkInfo.shoulder, arkInfo.arkId, version.number) + : null, + }) } // Get version by number -export async function getByNumber(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! - const n = c.req.param('n',)! - const collection = await resolveCollection(owner, slug,) - if (!collection) return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) - - const [version,] = await db +export async function getByNumber(c: Context) { + const owner = c.req.param('owner')! + const slug = c.req.param('slug')! + const n = c.req.param('n')! + const collection = await resolveCollection(owner, slug) + if (!collection) return c.json({ error: 'Collection not found', statusCode: 404 }, 404) + + const [version] = await db .select() - .from(schema.versions,) + .from(schema.versions) .where( - and(eq(schema.versions.collectionId, collection.id,), eq(schema.versions.number, parseInt(n, 10,),),), + and( + eq(schema.versions.collectionId, collection.id), + eq(schema.versions.number, parseInt(n, 10)), + ), ) - .limit(1,) + .limit(1) - if (!version) return c.json({ error: 'Version not found', statusCode: 404, }, 404,) - version.totalBytes = await backfillTotalBytes(version,) + if (!version) return c.json({ error: 'Version not found', statusCode: 404 }, 404) + version.totalBytes = await backfillTotalBytes(version) - const schemaEntries = await loadVersionSchemas(version.id,) - const accountId = c.get('accountId',) - const ownerAccess = isOwner(accountId, collection.accountId,) - const arkInfo = await getCollectionArkInfo(collection.id,).catch(() => null) + const schemaEntries = await loadVersionSchemas(version.id) + const accountId = c.get('accountId') + const ownerAccess = isOwner(accountId, collection.accountId) + const arkInfo = await getCollectionArkInfo(collection.id).catch(() => null) const schemasMap = ownerAccess - ? Object.fromEntries(schemaEntries.map((e,) => [e.slug, e.schema,]),) - : filterSchemasForPublic(schemaEntries,) + ? Object.fromEntries(schemaEntries.map((e) => [e.slug, e.schema])) + : filterSchemasForPublic(schemaEntries) return c.json({ ...version, hash: ownerAccess ? version.hash : (version.publicHash ?? version.hash), schemas: schemasMap, - ark: arkInfo ? buildArkUrl(arkInfo.naan, arkInfo.shoulder, arkInfo.arkId, version.number,) : null, - },) + ark: arkInfo + ? buildArkUrl(arkInfo.naan, arkInfo.shoulder, arkInfo.arkId, version.number) + : null, + }) } // Get records for a version -export async function records(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! - const n = c.req.param('n',)! - const type = c.req.query('type',) - const limit = c.req.query('limit',) - const offset = c.req.query('offset',) - const after = c.req.query('after',) - - const collection = await resolveCollection(owner, slug,) - if (!collection) return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) - - const [version,] = await db +export async function records(c: Context) { + const owner = c.req.param('owner')! + const slug = c.req.param('slug')! + const n = c.req.param('n')! + const type = c.req.query('type') + const limit = c.req.query('limit') + const offset = c.req.query('offset') + const after = c.req.query('after') + + const collection = await resolveCollection(owner, slug) + if (!collection) return c.json({ error: 'Collection not found', statusCode: 404 }, 404) + + const [version] = await db .select() - .from(schema.versions,) + .from(schema.versions) .where( - and(eq(schema.versions.collectionId, collection.id,), eq(schema.versions.number, parseInt(n, 10,),),), + and( + eq(schema.versions.collectionId, collection.id), + eq(schema.versions.number, parseInt(n, 10)), + ), ) - .limit(1,) + .limit(1) - if (!version) return c.json({ error: 'Version not found', statusCode: 404, }, 404,) + if (!version) return c.json({ error: 'Version not found', statusCode: 404 }, 404) - const conditions = [eq(schema.records.versionId, version.id,),] - if (type) conditions.push(eq(schema.records.type, type,),) + const conditions = [eq(schema.records.versionId, version.id)] + if (type) conditions.push(eq(schema.records.type, type)) // Cursor-based pagination: ?after=recordId (keyset pagination) if (after) { - conditions.push(sql`${schema.records.recordId} > ${after}`,) + conditions.push(sql`${schema.records.recordId} > ${after}`) } // Determine visibility - const accountId = c.get('accountId',) - const ownerAccess = isOwner(accountId, collection.accountId,) + const accountId = c.get('accountId') + const ownerAccess = isOwner(accountId, collection.accountId) let privateTypes = new Set() let schemaEntries: SchemaEntry[] = [] if (!ownerAccess) { - schemaEntries = await loadVersionSchemas(version.id,) - privateTypes = getPrivateTypes(schemaEntries,) + schemaEntries = await loadVersionSchemas(version.id) + privateTypes = getPrivateTypes(schemaEntries) if (privateTypes.size > 0) { - if (type && privateTypes.has(type,)) { - return c.json([],) // requesting a private type as non-owner + if (type && privateTypes.has(type)) { + return c.json([]) // requesting a private type as non-owner } for (const pt of privateTypes) { - conditions.push(sql`${schema.records.type} != ${pt}`,) + conditions.push(sql`${schema.records.type} != ${pt}`) } } // Exclude record-level private records - conditions.push(eq(schema.records.private, false,),) + conditions.push(eq(schema.records.private, false)) } - const pageLimit = Math.min(parseInt(limit ?? '100', 10,), 1000,) + const pageLimit = Math.min(parseInt(limit ?? '100', 10), 1000) const records = await db .select({ id: schema.records.recordId, type: schema.records.type, data: schema.records.data, - },) - .from(schema.records,) - .where(and(...conditions,),) - .orderBy(schema.records.recordId,) - .limit(pageLimit + 1,) - .offset(after ? 0 : parseInt(offset ?? '0', 10,),) + }) + .from(schema.records) + .where(and(...conditions)) + .orderBy(schema.records.recordId) + .limit(pageLimit + 1) + .offset(after ? 0 : parseInt(offset ?? '0', 10)) // Determine if there's a next page const hasMore = records.length > pageLimit - const page = hasMore ? records.slice(0, pageLimit,) : records + const page = hasMore ? records.slice(0, pageLimit) : records const nextCursor = hasMore ? page[page.length - 1]!.id : null // Strip private fields if not owner let resultRecords = page if (!ownerAccess) { const fieldCache = new Map>() - resultRecords = page.map((rec,) => { - if (!fieldCache.has(rec.type,)) { - const entry = schemaEntries.find((e,) => e.slug === rec.type) - fieldCache.set(rec.type, entry ? getPrivateFields(entry.schema,) : new Set(),) + resultRecords = page.map((rec) => { + if (!fieldCache.has(rec.type)) { + const entry = schemaEntries.find((e) => e.slug === rec.type) + fieldCache.set(rec.type, entry ? getPrivateFields(entry.schema) : new Set()) } - const privateFields = fieldCache.get(rec.type,)! + const privateFields = fieldCache.get(rec.type)! return privateFields.size > 0 - ? { ...rec, data: filterRecordData(rec.data, privateFields,), } + ? { ...rec, data: filterRecordData(rec.data, privateFields) } : rec - },) + }) } // Add ARK URLs for record types that have ARKs enabled - const arkInfo = await getCollectionArkInfo(collection.id,).catch(() => null) + const arkInfo = await getCollectionArkInfo(collection.id).catch(() => null) let arkEnabledTypes = new Map() // recordType → redirectUrlField if (arkInfo) { const artRows = await db .select({ recordType: schema.arkRecordTypes.recordType, redirectUrlField: schema.arkRecordTypes.redirectUrlField, - },) - .from(schema.arkRecordTypes,) - .where(eq(schema.arkRecordTypes.collectionId, collection.id,),) - for (const r of artRows) arkEnabledTypes.set(r.recordType, r.redirectUrlField,) + }) + .from(schema.arkRecordTypes) + .where(eq(schema.arkRecordTypes.collectionId, collection.id)) + for (const r of artRows) arkEnabledTypes.set(r.recordType, r.redirectUrlField) } - const recordsWithArk = resultRecords.map((rec,) => { - const ark = arkInfo && arkEnabledTypes.has(rec.type,) - ? buildArkUrl(arkInfo.naan, arkInfo.shoulder, arkInfo.arkId, version.number, rec.type, rec.id,) - : null - return ark ? { ...rec, ark, } : rec - },) + const recordsWithArk = resultRecords.map((rec) => { + const ark = + arkInfo && arkEnabledTypes.has(rec.type) + ? buildArkUrl( + arkInfo.naan, + arkInfo.shoulder, + arkInfo.arkId, + version.number, + rec.type, + rec.id, + ) + : null + return ark ? { ...rec, ark } : rec + }) return c.json({ records: recordsWithArk, @@ -359,26 +387,29 @@ export async function records(c: Context,) { nextCursor, total: version.recordCount, }, - },) + }) } // List files for a version -export async function files(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! - const n = c.req.param('n',)! - const collection = await resolveCollection(owner, slug,) - if (!collection) return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) - - const [version,] = await db +export async function files(c: Context) { + const owner = c.req.param('owner')! + const slug = c.req.param('slug')! + const n = c.req.param('n')! + const collection = await resolveCollection(owner, slug) + if (!collection) return c.json({ error: 'Collection not found', statusCode: 404 }, 404) + + const [version] = await db .select() - .from(schema.versions,) + .from(schema.versions) .where( - and(eq(schema.versions.collectionId, collection.id,), eq(schema.versions.number, parseInt(n, 10,),),), + and( + eq(schema.versions.collectionId, collection.id), + eq(schema.versions.number, parseInt(n, 10)), + ), ) - .limit(1,) + .limit(1) - if (!version) return c.json({ error: 'Version not found', statusCode: 404, }, 404,) + if (!version) return c.json({ error: 'Version not found', statusCode: 404 }, 404) const fileRows = await db .select({ @@ -386,80 +417,89 @@ export async function files(c: Context,) { size: schema.files.size, mimeType: schema.files.mimeType, createdAt: schema.files.createdAt, - },) - .from(schema.versionFiles,) - .innerJoin(schema.files, eq(schema.versionFiles.fileHash, schema.files.hash,),) - .where(eq(schema.versionFiles.versionId, version.id,),) + }) + .from(schema.versionFiles) + .innerJoin(schema.files, eq(schema.versionFiles.fileHash, schema.files.hash)) + .where(eq(schema.versionFiles.versionId, version.id)) // Build file→record reference map by scanning record data for $file refs const allRecords = await db - .select({ recordId: schema.records.recordId, type: schema.records.type, data: schema.records.data, },) - .from(schema.records,) - .where(eq(schema.records.versionId, version.id,),) + .select({ + recordId: schema.records.recordId, + type: schema.records.type, + data: schema.records.data, + }) + .from(schema.records) + .where(eq(schema.records.versionId, version.id)) const fileRefs = new Map() for (const rec of allRecords) { const data = rec.data as Record - for (const [field, val,] of Object.entries(data,)) { + for (const [field, val] of Object.entries(data)) { if (val && typeof val === 'object' && '$file' in (val as any)) { - const hash = ((val as any).$file as string).replace('sha256:', '',) - if (!fileRefs.has(hash,)) fileRefs.set(hash, [],) - fileRefs.get(hash,)!.push({ recordId: rec.recordId, type: rec.type, field, },) + const hash = ((val as any).$file as string).replace('sha256:', '') + if (!fileRefs.has(hash)) fileRefs.set(hash, []) + fileRefs.get(hash)!.push({ recordId: rec.recordId, type: rec.type, field }) } } } - return c.json(fileRows.map((f,) => ({ - ...f, - references: fileRefs.get(f.hash,) ?? [], - })),) + return c.json( + fileRows.map((f) => ({ + ...f, + references: fileRefs.get(f.hash) ?? [], + })), + ) } // Get manifest for a version -export async function manifest(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! - const n = c.req.param('n',)! - const collection = await resolveCollection(owner, slug,) - if (!collection) return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) - - const [version,] = await db +export async function manifest(c: Context) { + const owner = c.req.param('owner')! + const slug = c.req.param('slug')! + const n = c.req.param('n')! + const collection = await resolveCollection(owner, slug) + if (!collection) return c.json({ error: 'Collection not found', statusCode: 404 }, 404) + + const [version] = await db .select() - .from(schema.versions,) + .from(schema.versions) .where( - and(eq(schema.versions.collectionId, collection.id,), eq(schema.versions.number, parseInt(n, 10,),),), + and( + eq(schema.versions.collectionId, collection.id), + eq(schema.versions.number, parseInt(n, 10)), + ), ) - .limit(1,) + .limit(1) - if (!version) return c.json({ error: 'Version not found', statusCode: 404, }, 404,) + if (!version) return c.json({ error: 'Version not found', statusCode: 404 }, 404) const recordIds = await db - .select({ id: schema.records.recordId, type: schema.records.type, },) - .from(schema.records,) - .where(eq(schema.records.versionId, version.id,),) + .select({ id: schema.records.recordId, type: schema.records.type }) + .from(schema.records) + .where(eq(schema.records.versionId, version.id)) const fileHashes = await db - .select({ hash: schema.versionFiles.fileHash, },) - .from(schema.versionFiles,) - .where(eq(schema.versionFiles.versionId, version.id,),) + .select({ hash: schema.versionFiles.fileHash }) + .from(schema.versionFiles) + .where(eq(schema.versionFiles.versionId, version.id)) - const schemaEntries = await loadVersionSchemas(version.id,) + const schemaEntries = await loadVersionSchemas(version.id) return c.json({ version: version.number, semver: version.semver, hash: version.hash, - schemas: Object.fromEntries(schemaEntries.map((e,) => [e.slug, e.schemaHash,]),), + schemas: Object.fromEntries(schemaEntries.map((e) => [e.slug, e.schemaHash])), records: recordIds, - files: fileHashes.map((f,) => f.hash), - },) + files: fileHashes.map((f) => f.hash), + }) } // Push a new version -export async function push(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! - const body = await c.req.json() as { +export async function push(c: Context) { + const owner = c.req.param('owner')! + const slug = c.req.param('slug')! + const body = (await c.req.json()) as { base_version: number | null name?: string description?: string @@ -475,26 +515,29 @@ export async function push(c: Context,) { } } - const collection = await resolveCollection(owner, slug,) - if (!collection) return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) + const collection = await resolveCollection(owner, slug) + if (!collection) return c.json({ error: 'Collection not found', statusCode: 404 }, 404) // Get latest version - const [latest,] = await db + const [latest] = await db .select() - .from(schema.versions,) - .where(eq(schema.versions.collectionId, collection.id,),) - .orderBy(sql`${schema.versions.number} desc`,) - .limit(1,) + .from(schema.versions) + .where(eq(schema.versions.collectionId, collection.id)) + .orderBy(sql`${schema.versions.number} desc`) + .limit(1) const currentNumber = latest?.number ?? 0 // Optimistic lock if (body.base_version !== null && body.base_version !== currentNumber) { - return c.json({ - error: 'Version conflict', - currentVersion: currentNumber, - statusCode: 409, - }, 409,) + return c.json( + { + error: 'Version conflict', + currentVersion: currentNumber, + statusCode: 409, + }, + 409, + ) } // Build the full record set for this version @@ -506,180 +549,213 @@ export async function push(c: Context,) { type: schema.records.type, data: schema.records.data, private: schema.records.private, - },) - .from(schema.records,) - .where(eq(schema.records.versionId, latest.id,),) + }) + .from(schema.records) + .where(eq(schema.records.versionId, latest.id)) } // Apply changes - const recordMap = new Map(existingRecords.map((r,) => [r.recordId, r,]),) + const recordMap = new Map(existingRecords.map((r) => [r.recordId, r])) for (const rec of body.changes.added ?? []) { - recordMap.set(rec.id, { recordId: rec.id, type: rec.type, data: rec.data, private: rec.private ?? false, },) + recordMap.set(rec.id, { + recordId: rec.id, + type: rec.type, + data: rec.data, + private: rec.private ?? false, + }) } for (const rec of body.changes.updated ?? []) { - recordMap.set(rec.id, { recordId: rec.id, type: rec.type, data: rec.data, private: rec.private ?? false, },) + recordMap.set(rec.id, { + recordId: rec.id, + type: rec.type, + data: rec.data, + private: rec.private ?? false, + }) } for (const id of body.changes.removed ?? []) { - recordMap.delete(id,) + recordMap.delete(id) } - const newRecords = Array.from(recordMap.values(),) + const newRecords = Array.from(recordMap.values()) // --- Resolve schemas --- let prevSchemaEntries: SchemaEntry[] = [] if (latest) { - prevSchemaEntries = await loadVersionSchemas(latest.id,) + prevSchemaEntries = await loadVersionSchemas(latest.id) } // Determine the schema set for this version let schemasInput: Record - if (body.schemas && Object.keys(body.schemas,).length > 0) { + if (body.schemas && Object.keys(body.schemas).length > 0) { schemasInput = body.schemas } else if (prevSchemaEntries.length > 0) { // Carry forward previous schemas - schemasInput = Object.fromEntries(prevSchemaEntries.map((e,) => [e.slug, e.schema,]),) + schemasInput = Object.fromEntries(prevSchemaEntries.map((e) => [e.slug, e.schema])) } else { - return c.json({ - error: 'Schemas required', - message: 'First version must include a `schemas` map with at least one type definition.', - statusCode: 422, - }, 422,) + return c.json( + { + error: 'Schemas required', + message: 'First version must include a `schemas` map with at least one type definition.', + statusCode: 422, + }, + 422, + ) } // Ensure every record type has a schema - const recordTypes = new Set(newRecords.map((r,) => r.type),) - const missingSchemas = [...recordTypes,].filter((t,) => !(t in schemasInput)) + const recordTypes = new Set(newRecords.map((r) => r.type)) + const missingSchemas = [...recordTypes].filter((t) => !(t in schemasInput)) if (missingSchemas.length > 0) { - return c.json({ - error: 'Missing schemas for record types', - types: missingSchemas, - message: `Every record type must have a corresponding schema. Missing: ${missingSchemas.join(', ',)}`, - statusCode: 422, - }, 422,) + return c.json( + { + error: 'Missing schemas for record types', + types: missingSchemas, + message: `Every record type must have a corresponding schema. Missing: ${missingSchemas.join(', ')}`, + statusCode: 422, + }, + 422, + ) } // Hash and upsert each schema into the global schemas table - const newSchemaSet: { slug: string; schemaId: string; schemaHash: string; schema: Record }[] = [] - for (const [typeSlug, typeSchema,] of Object.entries(schemasInput,)) { - const hash = hashSchema(typeSchema,) - - const [existing,] = await db - .select({ id: schema.schemas.id, },) - .from(schema.schemas,) - .where(eq(schema.schemas.schemaHash, hash,),) - .limit(1,) + const newSchemaSet: { + slug: string + schemaId: string + schemaHash: string + schema: Record + }[] = [] + for (const [typeSlug, typeSchema] of Object.entries(schemasInput)) { + const hash = hashSchema(typeSchema) + + const [existing] = await db + .select({ id: schema.schemas.id }) + .from(schema.schemas) + .where(eq(schema.schemas.schemaHash, hash)) + .limit(1) let schemaId: string if (existing) { schemaId = existing.id } else { - const [inserted,] = await db - .insert(schema.schemas,) - .values({ schema: typeSchema as any, schemaHash: hash, },) - .returning({ id: schema.schemas.id, },) + const [inserted] = await db + .insert(schema.schemas) + .values({ schema: typeSchema as any, schemaHash: hash }) + .returning({ id: schema.schemas.id }) schemaId = inserted!.id } - newSchemaSet.push({ slug: typeSlug, schemaId, schemaHash: hash, schema: typeSchema as Record, },) + newSchemaSet.push({ + slug: typeSlug, + schemaId, + schemaHash: hash, + schema: typeSchema as Record, + }) } // Validate records against their type's schema const validationErrors: { recordId: string; type: string; errors: string[] }[] = [] const validators = new Map>() for (const entry of newSchemaSet) { - validators.set(entry.slug, ajv.compile(entry.schema as object,),) + validators.set(entry.slug, ajv.compile(entry.schema as object)) } for (const rec of newRecords) { - const validate = validators.get(rec.type,) + const validate = validators.get(rec.type) if (!validate) { validationErrors.push({ recordId: rec.recordId, type: rec.type, - errors: [`No schema defined for record type "${rec.type}"`,], - },) + errors: [`No schema defined for record type "${rec.type}"`], + }) continue } - if (!validate(rec.data,)) { + if (!validate(rec.data)) { validationErrors.push({ recordId: rec.recordId, type: rec.type, errors: (validate.errors ?? []).map( - (e,) => `${e.instancePath || '/'} ${e.message ?? 'validation failed'}`, + (e) => `${e.instancePath || '/'} ${e.message ?? 'validation failed'}`, ), - },) + }) } } if (validationErrors.length > 0) { - return c.json({ - error: 'Schema validation failed', - validationErrors, - statusCode: 422, - }, 422,) + return c.json( + { + error: 'Schema validation failed', + validationErrors, + statusCode: 422, + }, + 422, + ) } // Determine if schema set changed - const prevSchemaMap = new Map(prevSchemaEntries.map((e,) => [e.slug, e.schemaHash,]),) - const newSchemaMap = new Map(newSchemaSet.map((e,) => [e.slug, e.schemaHash,]),) + const prevSchemaMap = new Map(prevSchemaEntries.map((e) => [e.slug, e.schemaHash])) + const newSchemaMap = new Map(newSchemaSet.map((e) => [e.slug, e.schemaHash])) let schemaChanged = prevSchemaMap.size !== newSchemaMap.size if (!schemaChanged) { - for (const [s, hash,] of newSchemaMap) { - if (prevSchemaMap.get(s,) !== hash) { + for (const [s, hash] of newSchemaMap) { + if (prevSchemaMap.get(s) !== hash) { schemaChanged = true break } } } - const recordsChanged = (body.changes.added?.length ?? 0) > 0 - || (body.changes.updated?.length ?? 0) > 0 - || (body.changes.removed?.length ?? 0) > 0 + const recordsChanged = + (body.changes.added?.length ?? 0) > 0 || + (body.changes.updated?.length ?? 0) > 0 || + (body.changes.removed?.length ?? 0) > 0 // Get file hashes from existing version + any new references let existingFileHashes: string[] = [] if (latest) { const vf = await db - .select({ hash: schema.versionFiles.fileHash, },) - .from(schema.versionFiles,) - .where(eq(schema.versionFiles.versionId, latest.id,),) - existingFileHashes = vf.map((f,) => f.hash) + .select({ hash: schema.versionFiles.fileHash }) + .from(schema.versionFiles) + .where(eq(schema.versionFiles.versionId, latest.id)) + existingFileHashes = vf.map((f) => f.hash) } // Scan new records for $file references - const referencedHashes = new Set(existingFileHashes,) + const referencedHashes = new Set(existingFileHashes) for (const rec of newRecords) { const data = rec.data as Record - for (const val of Object.values(data,)) { + for (const val of Object.values(data)) { if ( - typeof val === 'object' - && val !== null - && '$file' in val - && typeof (val as { $file: string }).$file === 'string' + typeof val === 'object' && + val !== null && + '$file' in val && + typeof (val as { $file: string }).$file === 'string' ) { - const hash = (val as { $file: string }).$file.replace('sha256:', '',) - referencedHashes.add(hash,) + const hash = (val as { $file: string }).$file.replace('sha256:', '') + referencedHashes.add(hash) } } } // Check all referenced files exist - const allFileHashes = Array.from(referencedHashes,) + const allFileHashes = Array.from(referencedHashes) if (allFileHashes.length > 0) { const existingFiles = await db - .select({ hash: schema.files.hash, },) - .from(schema.files,) - .where(inArray(schema.files.hash, allFileHashes,),) - const existingSet = new Set(existingFiles.map((f,) => f.hash),) - const filesNeeded = allFileHashes.filter((h,) => !existingSet.has(h,)) + .select({ hash: schema.files.hash }) + .from(schema.files) + .where(inArray(schema.files.hash, allFileHashes)) + const existingSet = new Set(existingFiles.map((f) => f.hash)) + const filesNeeded = allFileHashes.filter((h) => !existingSet.has(h)) if (filesNeeded.length > 0) { - return c.json({ - error: 'Missing files', - filesNeeded: filesNeeded.map((h,) => `sha256:${h}`), - statusCode: 422, - }, 422,) + return c.json( + { + error: 'Missing files', + filesNeeded: filesNeeded.map((h) => `sha256:${h}`), + statusCode: 422, + }, + 422, + ) } } @@ -687,55 +763,60 @@ export async function push(c: Context,) { const readmeValue = body.readme !== undefined ? body.readme : (latest?.readme ?? null) // Compute hashes and semver - const schemaSetForHash = newSchemaSet.map((e,) => ({ slug: e.slug, schemaHash: e.schemaHash, })) - const versionHash = computeVersionHash(schemaSetForHash, newRecords, allFileHashes, readmeValue,) + const schemaSetForHash = newSchemaSet.map((e) => ({ slug: e.slug, schemaHash: e.schemaHash })) + const versionHash = computeVersionHash(schemaSetForHash, newRecords, allFileHashes, readmeValue) - const schemaEntriesForPublicHash: SchemaEntry[] = newSchemaSet.map((e,) => ({ + const schemaEntriesForPublicHash: SchemaEntry[] = newSchemaSet.map((e) => ({ slug: e.slug, schemaId: e.schemaId, schema: e.schema, schemaHash: e.schemaHash, })) - const publicHash = computePublicHash(schemaEntriesForPublicHash, newRecords, allFileHashes, readmeValue,) + const publicHash = computePublicHash( + schemaEntriesForPublicHash, + newRecords, + allFileHashes, + readmeValue, + ) - const semver = deriveSemver(latest?.semver ?? null, schemaChanged, recordsChanged,) + const semver = deriveSemver(latest?.semver ?? null, schemaChanged, recordsChanged) const newNumber = currentNumber + 1 // Check for duplicate hash - const [existingHash,] = await db - .select({ number: schema.versions.number, },) - .from(schema.versions,) + const [existingHash] = await db + .select({ number: schema.versions.number }) + .from(schema.versions) .where( - and( - eq(schema.versions.collectionId, collection.id,), - eq(schema.versions.hash, versionHash,), - ), + and(eq(schema.versions.collectionId, collection.id), eq(schema.versions.hash, versionHash)), ) - .limit(1,) + .limit(1) if (existingHash) { - return c.json({ - error: 'No changes detected', - message: `Version ${existingHash.number} already has identical content (hash: ${versionHash.slice(0, 12,)}...)`, - existingVersion: existingHash.number, - }, 409,) + return c.json( + { + error: 'No changes detected', + message: `Version ${existingHash.number} already has identical content (hash: ${versionHash.slice(0, 12)}...)`, + existingVersion: existingHash.number, + }, + 409, + ) } // Compute total bytes let totalBytes = 0 for (const rec of newRecords) { - totalBytes += Buffer.byteLength(JSON.stringify(rec.data,), 'utf-8',) + totalBytes += Buffer.byteLength(JSON.stringify(rec.data), 'utf-8') } if (allFileHashes.length > 0) { - const [fileSizeSum,] = await db - .select({ total: sql`coalesce(sum(${schema.files.size}), 0)`, },) - .from(schema.files,) - .where(inArray(schema.files.hash, allFileHashes,),) - totalBytes += Number(fileSizeSum?.total ?? 0,) + const [fileSizeSum] = await db + .select({ total: sql`coalesce(sum(${schema.files.size}), 0)` }) + .from(schema.files) + .where(inArray(schema.files.hash, allFileHashes)) + totalBytes += Number(fileSizeSum?.total ?? 0) } // Insert version - const [version,] = await db - .insert(schema.versions,) + const [version] = await db + .insert(schema.versions) .values({ collectionId: collection.id, number: newNumber, @@ -745,22 +826,22 @@ export async function push(c: Context,) { baseNumber: body.base_version, message: body.message ?? null, readme: readmeValue, - pushedBy: c.get('accountId',) ?? null, + pushedBy: c.get('accountId') ?? null, appId: body.app_id ?? null, actorId: body.actor_id ?? null, recordCount: newRecords.length, fileCount: allFileHashes.length, totalBytes, - },) + }) .returning() // Insert records (in batches) if (newRecords.length > 0) { const RECORD_BATCH = 1000 for (let i = 0; i < newRecords.length; i += RECORD_BATCH) { - const batch = newRecords.slice(i, i + RECORD_BATCH,) - await db.insert(schema.records,).values( - batch.map((r,) => ({ + const batch = newRecords.slice(i, i + RECORD_BATCH) + await db.insert(schema.records).values( + batch.map((r) => ({ versionId: version!.id, recordId: r.recordId, type: r.type, @@ -773,8 +854,8 @@ export async function push(c: Context,) { // Insert version_files if (allFileHashes.length > 0) { - await db.insert(schema.versionFiles,).values( - allFileHashes.map((hash,) => ({ + await db.insert(schema.versionFiles).values( + allFileHashes.map((hash) => ({ versionId: version!.id, fileHash: hash, })), @@ -782,8 +863,8 @@ export async function push(c: Context,) { } // Insert version_schemas - await db.insert(schema.versionSchemas,).values( - newSchemaSet.map((entry,) => ({ + await db.insert(schema.versionSchemas).values( + newSchemaSet.map((entry) => ({ versionId: version!.id, slug: entry.slug, schemaId: entry.schemaId, @@ -791,88 +872,95 @@ export async function push(c: Context,) { ) // Update collection timestamp + optional name/description - const collectionUpdates: Record = { updatedAt: new Date(), } + const collectionUpdates: Record = { updatedAt: new Date() } if (body.name) collectionUpdates.name = body.name if (body.description !== undefined) collectionUpdates.description = body.description await db - .update(schema.collections,) - .set(collectionUpdates,) - .where(eq(schema.collections.id, collection.id,),) + .update(schema.collections) + .set(collectionUpdates) + .where(eq(schema.collections.id, collection.id)) - return c.json({ - version: newNumber, - semver, - hash: versionHash, - recordCount: newRecords.length, - fileCount: allFileHashes.length, - }, 201,) + return c.json( + { + version: newNumber, + semver, + hash: versionHash, + recordCount: newRecords.length, + fileCount: allFileHashes.length, + }, + 201, + ) } // Diff between versions -export async function diff(c: Context,) { - const owner = c.req.param('owner',)! - const slug = c.req.param('slug',)! - const n = c.req.param('n',)! - const from = c.req.query('from',) +export async function diff(c: Context) { + const owner = c.req.param('owner')! + const slug = c.req.param('slug')! + const n = c.req.param('n')! + const from = c.req.query('from') - const collection = await resolveCollection(owner, slug,) - if (!collection) return c.json({ error: 'Collection not found', statusCode: 404, }, 404,) + const collection = await resolveCollection(owner, slug) + if (!collection) return c.json({ error: 'Collection not found', statusCode: 404 }, 404) - const targetNum = parseInt(n, 10,) - const fromNum = from ? parseInt(from, 10,) : targetNum - 1 + const targetNum = parseInt(n, 10) + const fromNum = from ? parseInt(from, 10) : targetNum - 1 - const [targetVersion,] = await db + const [targetVersion] = await db .select() - .from(schema.versions,) - .where(and(eq(schema.versions.collectionId, collection.id,), eq(schema.versions.number, targetNum,),),) - .limit(1,) + .from(schema.versions) + .where( + and(eq(schema.versions.collectionId, collection.id), eq(schema.versions.number, targetNum)), + ) + .limit(1) if (!targetVersion) { - return c.json({ error: 'Version not found', statusCode: 404, }, 404,) + return c.json({ error: 'Version not found', statusCode: 404 }, 404) } const targetRecords = await db .select() - .from(schema.records,) - .where(eq(schema.records.versionId, targetVersion.id,),) + .from(schema.records) + .where(eq(schema.records.versionId, targetVersion.id)) let fromVersion: typeof targetVersion | null = null let fromRecords: typeof targetRecords = [] if (fromNum > 0) { - const [fv,] = await db + const [fv] = await db .select() - .from(schema.versions,) - .where(and(eq(schema.versions.collectionId, collection.id,), eq(schema.versions.number, fromNum,),),) - .limit(1,) + .from(schema.versions) + .where( + and(eq(schema.versions.collectionId, collection.id), eq(schema.versions.number, fromNum)), + ) + .limit(1) if (fv) { fromVersion = fv fromRecords = await db .select() - .from(schema.records,) - .where(eq(schema.records.versionId, fv.id,),) + .from(schema.records) + .where(eq(schema.records.versionId, fv.id)) } } - const fromMap = new Map(fromRecords.map((r,) => [r.recordId, r,]),) - const targetMap = new Map(targetRecords.map((r,) => [r.recordId, r,]),) + const fromMap = new Map(fromRecords.map((r) => [r.recordId, r])) + const targetMap = new Map(targetRecords.map((r) => [r.recordId, r])) - const added = targetRecords.filter((r,) => !fromMap.has(r.recordId,)) - const removed = fromRecords.filter((r,) => !targetMap.has(r.recordId,)) - const updated = targetRecords.filter((r,) => { - const prev = fromMap.get(r.recordId,) - return prev && JSON.stringify(prev.data,) !== JSON.stringify(r.data,) - },) + const added = targetRecords.filter((r) => !fromMap.has(r.recordId)) + const removed = fromRecords.filter((r) => !targetMap.has(r.recordId)) + const updated = targetRecords.filter((r) => { + const prev = fromMap.get(r.recordId) + return prev && JSON.stringify(prev.data) !== JSON.stringify(r.data) + }) // Compare schema sets - const targetSchemas = await loadVersionSchemas(targetVersion.id,) - const fromSchemas = fromVersion ? await loadVersionSchemas(fromVersion.id,) : [] - const targetSchemaMap = new Map(targetSchemas.map((e,) => [e.slug, e.schemaHash,]),) - const fromSchemaMap = new Map(fromSchemas.map((e,) => [e.slug, e.schemaHash,]),) + const targetSchemas = await loadVersionSchemas(targetVersion.id) + const fromSchemas = fromVersion ? await loadVersionSchemas(fromVersion.id) : [] + const targetSchemaMap = new Map(targetSchemas.map((e) => [e.slug, e.schemaHash])) + const fromSchemaMap = new Map(fromSchemas.map((e) => [e.slug, e.schemaHash])) let schemaChanged = targetSchemaMap.size !== fromSchemaMap.size if (!schemaChanged) { - for (const [s, hash,] of targetSchemaMap) { - if (fromSchemaMap.get(s,) !== hash) { + for (const [s, hash] of targetSchemaMap) { + if (fromSchemaMap.get(s) !== hash) { schemaChanged = true break } @@ -883,66 +971,71 @@ export async function diff(c: Context,) { // Compare file sets const targetFiles = await db - .select({ hash: schema.versionFiles.fileHash, },) - .from(schema.versionFiles,) - .where(eq(schema.versionFiles.versionId, targetVersion.id,),) + .select({ hash: schema.versionFiles.fileHash }) + .from(schema.versionFiles) + .where(eq(schema.versionFiles.versionId, targetVersion.id)) const fromFiles = fromVersion ? await db - .select({ hash: schema.versionFiles.fileHash, },) - .from(schema.versionFiles,) - .where(eq(schema.versionFiles.versionId, fromVersion.id,),) + .select({ hash: schema.versionFiles.fileHash }) + .from(schema.versionFiles) + .where(eq(schema.versionFiles.versionId, fromVersion.id)) : [] - const targetFileSet = new Set(targetFiles.map((f,) => f.hash),) - const fromFileSet = new Set(fromFiles.map((f,) => f.hash),) - const filesAdded = targetFiles.filter((f,) => !fromFileSet.has(f.hash,)).map((f,) => f.hash) - const filesRemoved = fromFiles.filter((f,) => !targetFileSet.has(f.hash,)).map((f,) => f.hash) + const targetFileSet = new Set(targetFiles.map((f) => f.hash)) + const fromFileSet = new Set(fromFiles.map((f) => f.hash)) + const filesAdded = targetFiles.filter((f) => !fromFileSet.has(f.hash)).map((f) => f.hash) + const filesRemoved = fromFiles.filter((f) => !targetFileSet.has(f.hash)).map((f) => f.hash) return c.json({ from: fromNum, to: targetNum, - added: added.map((r,) => ({ id: r.recordId, type: r.type, data: r.data, })), - updated: updated.map((r,) => ({ id: r.recordId, type: r.type, data: r.data, })), - removed: removed.map((r,) => r.recordId), + added: added.map((r) => ({ id: r.recordId, type: r.type, data: r.data })), + updated: updated.map((r) => ({ id: r.recordId, type: r.type, data: r.data })), + removed: removed.map((r) => r.recordId), meta: { schemaChanged, readmeChanged, - readmeFrom: readmeChanged ? (fromVersion?.readme?.slice(0, 100,) ?? null) : undefined, - readmeTo: readmeChanged ? (targetVersion.readme?.slice(0, 100,) ?? null) : undefined, + readmeFrom: readmeChanged ? (fromVersion?.readme?.slice(0, 100) ?? null) : undefined, + readmeTo: readmeChanged ? (targetVersion.readme?.slice(0, 100) ?? null) : undefined, filesAdded: filesAdded.length, filesRemoved: filesRemoved.length, }, - },) + }) } -async function resolveCollection(owner: string, slug: string,) { - const [result,] = await db +async function resolveCollection(owner: string, slug: string) { + const [result] = await db .select({ id: schema.collections.id, accountId: schema.collections.accountId, slug: schema.collections.slug, - },) - .from(schema.collections,) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id,),) - .where(and(eq(schema.accounts.slug, owner,), eq(schema.collections.slug, slug,),),) - .limit(1,) + }) + .from(schema.collections) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) + .where(and(eq(schema.accounts.slug, owner), eq(schema.collections.slug, slug))) + .limit(1) return result ?? null } async function getCollectionArkInfo( collectionId: string, ): Promise<{ shoulder: string; arkId: string; naan: string } | null> { - const [row,] = await db + const [row] = await db .select({ shoulder: schema.arkShoulders.shoulder, arkId: schema.arkCollections.arkId, naan: schema.accounts.arkNaan, - },) - .from(schema.arkCollections,) - .innerJoin(schema.collections, eq(schema.arkCollections.collectionId, schema.collections.id,),) - .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id,),) - .innerJoin(schema.arkShoulders, eq(schema.arkShoulders.accountId, schema.accounts.id,),) - .where(and(eq(schema.arkCollections.collectionId, collectionId,), eq(schema.arkCollections.enabled, true,),),) - .limit(1,) + }) + .from(schema.arkCollections) + .innerJoin(schema.collections, eq(schema.arkCollections.collectionId, schema.collections.id)) + .innerJoin(schema.accounts, eq(schema.collections.accountId, schema.accounts.id)) + .innerJoin(schema.arkShoulders, eq(schema.arkShoulders.accountId, schema.accounts.id)) + .where( + and( + eq(schema.arkCollections.collectionId, collectionId), + eq(schema.arkCollections.enabled, true), + ), + ) + .limit(1) if (!row) return null - return { shoulder: row.shoulder, arkId: row.arkId, naan: row.naan ?? DEFAULT_NAAN, } + return { shoulder: row.shoulder, arkId: row.arkId, naan: row.naan ?? DEFAULT_NAAN } } diff --git a/src/components/ApiPlayground.tsx b/src/components/ApiPlayground.tsx index d072206..5eb59cf 100644 --- a/src/components/ApiPlayground.tsx +++ b/src/components/ApiPlayground.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState, } from 'react' +import { useCallback, useState } from 'react' interface Collection { id: string @@ -25,7 +25,7 @@ interface Endpoint { description: string } -function getEndpoints(slug: string, collectionSlug: string,): Endpoint[] { +function getEndpoints(slug: string, collectionSlug: string): Endpoint[] { return [ { label: 'List collections', @@ -43,58 +43,58 @@ function getEndpoints(slug: string, collectionSlug: string,): Endpoint[] { }, ...(collectionSlug ? [ - { - label: 'Get collection', - method: 'GET', - path: `/api/collections/${slug}/${collectionSlug}`, - body: '', - description: 'Returns collection metadata and latest version info.', - }, - { - label: 'List versions', - method: 'GET', - path: `/api/collections/${slug}/${collectionSlug}/versions`, - body: '', - description: 'Returns all versions for this collection.', - }, - { - label: 'Get latest version', - method: 'GET', - path: `/api/collections/${slug}/${collectionSlug}/versions/latest`, - body: '', - description: 'Returns the latest version with records and files.', - }, - { - label: 'List files', - method: 'GET', - path: `/api/collections/${slug}/${collectionSlug}/files`, - body: '', - description: 'Returns all files in the latest version.', - }, - ] + { + label: 'Get collection', + method: 'GET', + path: `/api/collections/${slug}/${collectionSlug}`, + body: '', + description: 'Returns collection metadata and latest version info.', + }, + { + label: 'List versions', + method: 'GET', + path: `/api/collections/${slug}/${collectionSlug}/versions`, + body: '', + description: 'Returns all versions for this collection.', + }, + { + label: 'Get latest version', + method: 'GET', + path: `/api/collections/${slug}/${collectionSlug}/versions/latest`, + body: '', + description: 'Returns the latest version with records and files.', + }, + { + label: 'List files', + method: 'GET', + path: `/api/collections/${slug}/${collectionSlug}/files`, + body: '', + description: 'Returns all files in the latest version.', + }, + ] : []), ] } -export function ApiPlayground({ slug, collections, }: ApiPlaygroundProps,) { - const [selectedCollection, setSelectedCollection,] = useState(collections[0]?.slug ?? '',) - const [selectedEndpoint, setSelectedEndpoint,] = useState(0,) - const [response, setResponse,] = useState(null,) - const [loading, setLoading,] = useState(false,) - const [copied, setCopied,] = useState(false,) - const [token, setToken,] = useState('',) +export function ApiPlayground({ slug, collections }: ApiPlaygroundProps) { + const [selectedCollection, setSelectedCollection] = useState(collections[0]?.slug ?? '') + const [selectedEndpoint, setSelectedEndpoint] = useState(0) + const [response, setResponse] = useState(null) + const [loading, setLoading] = useState(false) + const [copied, setCopied] = useState(false) + const [token, setToken] = useState('') - const endpoints = getEndpoints(slug, selectedCollection,) + const endpoints = getEndpoints(slug, selectedCollection) const current = endpoints[selectedEndpoint] ?? endpoints[0] const sendRequest = useCallback(async () => { if (!current) return - setLoading(true,) - setResponse(null,) + setLoading(true) + setResponse(null) const start = performance.now() try { - const headers: Record = { 'Content-Type': 'application/json', } + const headers: Record = { 'Content-Type': 'application/json' } if (token.trim()) { headers['Authorization'] = `Bearer ${token.trim()}` } @@ -106,23 +106,23 @@ export function ApiPlayground({ slug, collections, }: ApiPlaygroundProps,) { if (current.body && current.method !== 'GET') { opts.body = current.body } - const res = await fetch(current.path, opts,) - const elapsed = Math.round(performance.now() - start,) + const res = await fetch(current.path, opts) + const elapsed = Math.round(performance.now() - start) let body: string - const contentType = res.headers.get('content-type',) ?? '' - if (contentType.includes('json',)) { + const contentType = res.headers.get('content-type') ?? '' + if (contentType.includes('json')) { const json = await res.json() - body = JSON.stringify(json, null, 2,) + body = JSON.stringify(json, null, 2) } else { body = await res.text() } - setResponse({ status: res.status, statusText: res.statusText, time: elapsed, body, },) + setResponse({ status: res.status, statusText: res.statusText, time: elapsed, body }) } catch (err: any) { - setResponse({ status: 0, statusText: 'Network Error', time: 0, body: err.message, },) + setResponse({ status: 0, statusText: 'Network Error', time: 0, body: err.message }) } finally { - setLoading(false,) + setLoading(false) } - }, [current, token,],) + }, [current, token]) const copyAsCurl = useCallback(() => { if (!current) return @@ -133,68 +133,74 @@ export function ApiPlayground({ slug, collections, }: ApiPlaygroundProps,) { cmd += ` \\\n -H 'Content-Type: application/json'` cmd += ` \\\n -d '${current.body}'` } - navigator.clipboard.writeText(cmd,) - setCopied(true,) - setTimeout(() => setCopied(false,), 2000,) - }, [current, token,],) + navigator.clipboard.writeText(cmd) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }, [current, token]) return ( -
+
{/* Controls bar */} -
+
{collections.length > 0 && ( -
- +
+
)} -
- +
+ setToken(e.target.value,)} - placeholder='Paste key to test it (optional)' - className='text-xs bg-parchment border border-rule px-2 py-1 w-52 font-mono focus:outline-none focus:border-ink' + onChange={(e) => setToken(e.target.value)} + placeholder="Paste key to test it (optional)" + className="bg-parchment border-rule focus:border-ink w-52 border px-2 py-1 font-mono text-xs focus:outline-none" />
-
+
{/* Left column: endpoint list */} -
-

Endpoints

-
- {endpoints.map((ep, i,) => ( +
+

Endpoints

+
+ {endpoints.map((ep, i) => (
{collections.length === 0 && ( -

+

No collections yet. Create one to see collection endpoints.

)}
{/* Right column: request + response */} -
+
{current && ( <> {/* Request display */} -
-
+
+
{current.method} - {current.path} + {current.path}
-

{current.description}

+

{current.description}

{/* Action bar */} -
+
- + {token.trim() ? 'Using API key' : 'Using your session'}
@@ -263,25 +269,27 @@ export function ApiPlayground({ slug, collections, }: ApiPlaygroundProps,) { {response && (
= 200 && response.status < 300 ? 'bg-green-50 text-green-800' : response.status >= 400 - ? 'bg-red-50 text-red-800' - : 'bg-parchment-dark' + ? 'bg-red-50 text-red-800' + : 'bg-parchment-dark' }`} > - {response.status} {response.statusText} - {response.time}ms + + {response.status} {response.statusText} + + {response.time}ms
-
+              
                 {response.body}
               
)} {!response && !loading && ( -
+
Select an endpoint and hit Send to see the response.
)} diff --git a/src/components/BaseLayout.tsx b/src/components/BaseLayout.tsx index b7ba334..5b09690 100644 --- a/src/components/BaseLayout.tsx +++ b/src/components/BaseLayout.tsx @@ -1,6 +1,7 @@ -import { Link, } from 'react-router' +import { Link } from 'react-router' + import UserMenu from '~/components/UserMenu' -import { useSSRData, } from '~/lib/ssr-data' +import { useSSRData } from '~/lib/ssr-data' interface MirrorConfig { enabled: boolean @@ -8,59 +9,79 @@ interface MirrorConfig { upstream: string } -export default function BaseLayout({ children, }: { children: React.ReactNode },) { - const currentUser = useSSRData('currentUser',) - const mirrorConfig = useSSRData('mirrorConfig',) +export default function BaseLayout({ children }: { children: React.ReactNode }) { + const currentUser = useSSRData('currentUser') + const mirrorConfig = useSSRData('mirrorConfig') return ( <> -
-