diff --git a/knip.json b/knip.json new file mode 100644 index 0000000..11ca9bc --- /dev/null +++ b/knip.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://unpkg.com/knip@6/schema.json", + "entry": [ + "scripts/*.ts", + "test/utils/**/*.ts" + ], + "project": [ + "server/**/*.ts", + "app/**/*.{ts,vue}", + "scripts/**/*.ts", + "test/**/*.ts", + "tests/**/*.ts" + ], + "ignoreDependencies": [ + "rolldown", + "vue-router", + "@stylistic/eslint-plugin", + "h3" + ], + "ignoreBinaries": [ + "ssh-keygen", + "git-receive-pack", + "git-upload-pack" + ] +} diff --git a/package.json b/package.json index efe5008..9940089 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "test:nuxt": "vp test --project nuxt", "test:browser": "playwright test", "test:browser:ui": "playwright test --ui", - "test:browser:update": "docker run --rm --network host -v $(pwd):/work/ -v /tmp/playwright-node-modules:/work/node_modules -w /work/ -it mcr.microsoft.com/playwright:v1.59.1-noble bash -c 'corepack enable && pnpm i && pnpm playwright test test/browser --update-snapshots'" + "test:browser:update": "docker run --rm --network host -v $(pwd):/work/ -v /tmp/playwright-node-modules:/work/node_modules -w /work/ -it mcr.microsoft.com/playwright:v1.59.1-noble bash -c 'corepack enable && pnpm i && pnpm playwright test test/browser --update-snapshots'", + "knip": "knip" }, "dependencies": { "@atcute/tid": "^1.1.2", @@ -46,7 +47,6 @@ "@nuxt/scripts": "^1.2.1", "@nuxtjs/html-validator": "^2.1.0", "@octokit/app": "^16.1.2", - "@octokit/auth-app": "^8.2.0", "@octokit/webhooks-methods": "^6.0.0", "drizzle-orm": "^0.45.2", "nuxt": "^4.4.8", @@ -69,6 +69,7 @@ "@vue/test-utils": "2.4.10", "drizzle-kit": "^0.31.10", "happy-dom": "20.9.0", + "knip": "^6.16.1", "nano-staged": "^1.0.2", "playwright-core": "^1.59.1", "simple-git-hooks": "2.13.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34d189e..a700a9d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,9 +45,6 @@ importers: '@octokit/app': specifier: ^16.1.2 version: 16.1.2 - '@octokit/auth-app': - specifier: ^8.2.0 - version: 8.2.0 '@octokit/webhooks-methods': specifier: ^6.0.0 version: 6.0.0 @@ -59,7 +56,7 @@ importers: version: 4.4.8(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0))(@electric-sql/pglite@0.4.5)(@parcel/watcher@2.5.6)(@types/node@24.13.2)(@vue/compiler-sfc@3.5.38)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.5)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.5)(@neondatabase/serverless@1.1.0)))(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.5)(@neondatabase/serverless@1.1.0))(esbuild@0.28.0)(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(magicast@0.5.3)(optionator@0.9.4)(oxlint@1.61.0(oxlint-tsgolint@0.22.0))(rolldown@1.0.0-rc.18)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2))(rollup@4.60.2)(srvx@0.11.15)(terser@5.46.2)(tsx@4.21.0)(typescript@6.0.3)(vite@7.3.2(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))(vue-tsc@3.2.7(typescript@6.0.3))(yaml@2.9.0) nuxt-og-image: specifier: ^6.4.11 - version: 6.5.3(7c45f4c9f7a120d9de0460f4082612ea) + version: 6.5.3(778382f6defca828f628d461f7cab343) rolldown: specifier: ^1.0.0-rc.18 version: 1.0.0-rc.18 @@ -109,6 +106,9 @@ importers: happy-dom: specifier: 20.9.0 version: 20.9.0 + knip: + specifier: ^6.16.1 + version: 6.16.1 nano-staged: specifier: ^1.0.2 version: 1.0.2 @@ -1964,6 +1964,109 @@ packages: '@oxc-project/types@0.134.0': resolution: {integrity: sha512-T0xuRRKrQFmocH8y+jGfpmSkGcheaJExY9lEihmR1Gm2aH+75B8CzgU2rABRQSzzDxLjZ15Sc0bRVLj5lVeNXQ==} + '@oxc-resolver/binding-android-arm-eabi@11.20.0': + resolution: {integrity: sha512-IjfWOXRgJFNdORDl+Uf1aibNgZY2guOD3zmOhx1BGVb/MIiqlFTdmjpQNplSN58lhWehnX4UNqC3QwpUo8pjJg==} + cpu: [arm] + os: [android] + + '@oxc-resolver/binding-android-arm64@11.20.0': + resolution: {integrity: sha512-QqslZAuFQG8Q9xm7JuIn8JUbvywhSBMVhuQHtYW+auirZJloS41oxUUaBXk7uUhZJgp44c5zQLeVvmFaDQB+2Q==} + cpu: [arm64] + os: [android] + + '@oxc-resolver/binding-darwin-arm64@11.20.0': + resolution: {integrity: sha512-MUcavykj2ewlR+kc5arpg4tC2RvzJkUxWtNv74pf7lcNk00GpIpN43vXMj+j6r4eMmfZhlb8hueKoIb8e9kAGQ==} + cpu: [arm64] + os: [darwin] + + '@oxc-resolver/binding-darwin-x64@11.20.0': + resolution: {integrity: sha512-BGB16nRUK5Etiv//ihPyzj8Lj1px0mhh4YIfe0FDf045ywknfSm0GEbiRESpr6Q4K82AvnyaRIhhluHByvS4bg==} + cpu: [x64] + os: [darwin] + + '@oxc-resolver/binding-freebsd-x64@11.20.0': + resolution: {integrity: sha512-JZgtePaqj3qmD5XFHJaSLWzHRxQu0LaPkdoM1KJXYADvAaa83ijXHclV3ej3CueeW0wxfIAbGCZVP45J0CA7uQ==} + cpu: [x64] + os: [freebsd] + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.20.0': + resolution: {integrity: sha512-hOQ/p3ry3v3SchUBXicrrnszaI/UmYzM4wtS4RGfwgVUX7a+HbyQSzJ5aOzu+o6XZkFkS3ZXN4PZAzhOb77OSg==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm-musleabihf@11.20.0': + resolution: {integrity: sha512-2ArPksaw0AqeuGBfoS715VF+JvJQAhD2niWgjE5hVO+L+nAfikVQopvngCMX9x4BD8itWoQ3dnikrQyl5Ho5Jg==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-gnu@11.20.0': + resolution: {integrity: sha512-0bJnmYFp62JdZ4nVMDUZ/C58BCZOCcqgKtnUlp7L9Ojf/czIN+3j72YlLPeWLkzlr6SlYvIQA4SGV/HyO0d+qg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-arm64-musl@11.20.0': + resolution: {integrity: sha512-wKHHzPKZo7Ufhv/Bt6yxT7FOgnIgW4gwXcJUipkShGp68W3wGVqvr1Sr0fY65lN0Oy6y41+g2kIDvkgZaMMUkw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-linux-ppc64-gnu@11.20.0': + resolution: {integrity: sha512-RN8goF7Ie0B79L4i4G6OeBocTgSC56vJbQ65VJje+oXnldVpLnOU7j/AQ/dP94TcCS+Yh6WG8u3Qt4ETteXFNQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-riscv64-gnu@11.20.0': + resolution: {integrity: sha512-5l1yU6/xQEqLZRzxqmMxJfWPslpwCmBsdDGaBvABPehxquCXDC7dd7oraNdKSJUMDXSM7VvVj8H2D2FTjU7oWw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-riscv64-musl@11.20.0': + resolution: {integrity: sha512-xHEvkbgz6UC+A3JOyDQy76LkUaxsNSfIr3/GV8slwZsnuooJiIB34gzJfsyvR4JdCYNUUPsRJc/w/oWkODu+hg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-linux-s390x-gnu@11.20.0': + resolution: {integrity: sha512-aWPDUUmSeyHvlW+SoEUd+JIJsQhVhu6a5tBpDRMu058naPAchTgAVGCFy35zjbnFlt0i8hLWziff6HX0D3LU4g==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-x64-gnu@11.20.0': + resolution: {integrity: sha512-x2YeSimvhJjKLVD8KSu8f/rqU1potcdEMkApIPJqjZWN7c2Fpt4g2X32WDg1p+XDAmyT7nuQGe0vnhvXeLbH+g==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-x64-musl@11.20.0': + resolution: {integrity: sha512-kcRLEIxpZefeYfLChjpgFf3ilBzRDZ+yobMrpRsQlSrxuFGtm3U6PMU7AaEpMqo3NfDGVyJJseAjnRLzMFHjwQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-openharmony-arm64@11.20.0': + resolution: {integrity: sha512-HHcfnApSZGtKhTiHqe8OZruOZe5XuFQH5/E0Yhj3u8fnFvzkM4/k6WjacUf4SvA0SPEAbfbgYmVPuo0VX/fIBQ==} + cpu: [arm64] + os: [openharmony] + + '@oxc-resolver/binding-wasm32-wasi@11.20.0': + resolution: {integrity: sha512-Tn0y1XOFYHNfK1wp1Z5QK8Rcld/bsOwRISQXfqAZ5IBpv8Gz1IvV39fUWNprqNdRizgcvFhOzWwFun2zkJsyBg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-resolver/binding-win32-arm64-msvc@11.20.0': + resolution: {integrity: sha512-qPi25YNPe4YenS8MgsQU2+bIFHxxpLx1LVna2444cEHqNPhNjvWf9zqj4aWE43H9LpAsTmkkAlA3eL5ElBU3mA==} + cpu: [arm64] + os: [win32] + + '@oxc-resolver/binding-win32-x64-msvc@11.20.0': + resolution: {integrity: sha512-Wb14jWEW8huH6It9F6sXd9vrYmIS7pMrgkU6sxpLxkP+9z+wRgs71hUEhRpcn8FOXAFa27FVWfY2tRpbfTzfLw==} + cpu: [x64] + os: [win32] + '@oxc-transform/binding-android-arm-eabi@0.133.0': resolution: {integrity: sha512-2A79NBpyBKgHJ0FwgC8D1hzp3x2ujyvqq/kG+M76YyDMMkxLhX6A3vjnAnfEKycOoZxuKhwYu8BF9hKq67ykIA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4122,6 +4225,9 @@ packages: fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fd-package-json@2.0.0: + resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -4174,6 +4280,11 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + formatly@0.3.0: + resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} + engines: {node: '>=18.3.0'} + hasBin: true + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -4539,6 +4650,11 @@ packages: resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==} engines: {node: '>= 8'} + knip@6.16.1: + resolution: {integrity: sha512-TKMn1rxgH6h9vXR9Y0B+Cq7AdPTr9EI02IwoT65NzqYUkvoDQAaJ/aPybiFpAhZ1px6cNYYwXf86iHkBgzCo9w==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + knitwork@1.3.0: resolution: {integrity: sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==} @@ -4978,6 +5094,9 @@ packages: resolution: {integrity: sha512-Hs8fRG6A94BzMrMkGOtrUS7JQjmslfF+IvIXslf3QURzK3ud0QmFJRiYZjTe4TzAQnTfvlk4AwZnqIbrUjiE4w==} engines: {node: ^20.19.0 || >=22.12.0} + oxc-resolver@11.20.0: + resolution: {integrity: sha512-CblytBiV/a/ZXY34dsVU2NxhIOxMXst8CvDCtyBelVITgd7PLrKzbEbA6oKLdPjvDKDzCiW48qzmzZ+mYaqn+g==} + oxc-transform@0.133.0: resolution: {integrity: sha512-9lt2b+hkG6yqe0fUDMHhMk7rgI9uTjNxU9wauQiYnHzc4kZI8JP/OhBqXTIJQTrqRJ8CkSH3O5AhQ13ke28yNg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5507,6 +5626,10 @@ packages: resolution: {integrity: sha512-KAkBqZl3c2GvNgNhcoyJae1aKldDW0LO279wF9bk1PnluRTETKBq0WyzRXxEhoQLk56yHaOY4JCBEKDuJIET5g==} engines: {node: '>=20.0.0'} + smol-toml@1.6.1: + resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} + engines: {node: '>= 18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -5577,6 +5700,10 @@ packages: resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} engines: {node: '>=12'} + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} @@ -5716,6 +5843,10 @@ packages: ultrahtml@1.6.0: resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==} + unbash@3.0.0: + resolution: {integrity: sha512-FeFPZ/WFT0mbRCuydiZzpPFlrYN8ZUpphQKoq4EeElVIYjYyGzPMxQR/simUwCOJIyVhpFk4RbtyO7RuMpMnHA==} + engines: {node: '>=14'} + unconfig-core@7.5.0: resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} @@ -6049,6 +6180,10 @@ packages: typescript: optional: true + walk-up-path@4.0.0: + resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} + engines: {node: 20 || >=22} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -6154,6 +6289,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: '@atcute/tid@1.1.2': @@ -7954,6 +8092,67 @@ snapshots: '@oxc-project/types@0.134.0': {} + '@oxc-resolver/binding-android-arm-eabi@11.20.0': + optional: true + + '@oxc-resolver/binding-android-arm64@11.20.0': + optional: true + + '@oxc-resolver/binding-darwin-arm64@11.20.0': + optional: true + + '@oxc-resolver/binding-darwin-x64@11.20.0': + optional: true + + '@oxc-resolver/binding-freebsd-x64@11.20.0': + optional: true + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.20.0': + optional: true + + '@oxc-resolver/binding-linux-arm-musleabihf@11.20.0': + optional: true + + '@oxc-resolver/binding-linux-arm64-gnu@11.20.0': + optional: true + + '@oxc-resolver/binding-linux-arm64-musl@11.20.0': + optional: true + + '@oxc-resolver/binding-linux-ppc64-gnu@11.20.0': + optional: true + + '@oxc-resolver/binding-linux-riscv64-gnu@11.20.0': + optional: true + + '@oxc-resolver/binding-linux-riscv64-musl@11.20.0': + optional: true + + '@oxc-resolver/binding-linux-s390x-gnu@11.20.0': + optional: true + + '@oxc-resolver/binding-linux-x64-gnu@11.20.0': + optional: true + + '@oxc-resolver/binding-linux-x64-musl@11.20.0': + optional: true + + '@oxc-resolver/binding-openharmony-arm64@11.20.0': + optional: true + + '@oxc-resolver/binding-wasm32-wasi@11.20.0': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@oxc-resolver/binding-win32-arm64-msvc@11.20.0': + optional: true + + '@oxc-resolver/binding-win32-x64-msvc@11.20.0': + optional: true + '@oxc-transform/binding-android-arm-eabi@0.133.0': optional: true @@ -9784,6 +9983,10 @@ snapshots: dependencies: reusify: 1.1.0 + fd-package-json@2.0.0: + dependencies: + walk-up-path: 4.0.0 + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -9867,6 +10070,10 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + formatly@0.3.0: + dependencies: + fd-package-json: 2.0.0 + fraction.js@5.3.4: {} fresh@2.0.0: {} @@ -10221,6 +10428,22 @@ snapshots: klona@2.0.6: {} + knip@6.16.1: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + formatly: 0.3.0 + get-tsconfig: 4.14.0 + jiti: 2.7.0 + oxc-parser: 0.133.0 + oxc-resolver: 11.20.0 + picomatch: 4.0.4 + smol-toml: 1.6.1 + strip-json-comments: 5.0.3 + tinyglobby: 0.2.17 + unbash: 3.0.0 + yaml: 2.9.0 + zod: 4.4.3 + knitwork@1.3.0: {} launch-editor@2.13.2: @@ -10555,6 +10778,112 @@ snapshots: - supports-color - uploadthing + nitropack@2.13.4(@electric-sql/pglite@0.4.5)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.5)(@neondatabase/serverless@1.1.0))(oxc-parser@0.134.0)(rolldown@1.0.0-rc.18)(srvx@0.11.15): + dependencies: + '@cloudflare/kv-asset-handler': 0.4.2 + '@rollup/plugin-alias': 6.0.0(rollup@4.60.2) + '@rollup/plugin-commonjs': 29.0.2(rollup@4.60.2) + '@rollup/plugin-inject': 5.0.5(rollup@4.60.2) + '@rollup/plugin-json': 6.1.0(rollup@4.60.2) + '@rollup/plugin-node-resolve': 16.0.3(rollup@4.60.2) + '@rollup/plugin-replace': 6.0.3(rollup@4.60.2) + '@rollup/plugin-terser': 1.0.0(rollup@4.60.2) + '@vercel/nft': 1.5.0(rollup@4.60.2) + archiver: 7.0.1 + c12: 3.3.4(magicast@0.5.3) + chokidar: 5.0.0 + citty: 0.2.2 + compatx: 0.2.0 + confbox: 0.2.4 + consola: 3.4.2 + cookie-es: 2.0.1 + croner: 10.0.1 + crossws: 0.3.5 + db0: 0.3.4(@electric-sql/pglite@0.4.5)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.5)(@neondatabase/serverless@1.1.0)) + defu: 6.1.7 + destr: 2.0.5 + dot-prop: 10.1.0 + esbuild: 0.28.0 + escape-string-regexp: 5.0.0 + etag: 1.8.1 + exsolve: 1.0.8 + globby: 16.2.0 + gzip-size: 7.0.0 + h3: 1.15.11 + hookable: 5.5.3 + httpxy: 0.5.1 + ioredis: 5.10.1 + jiti: 2.7.0 + klona: 2.0.6 + knitwork: 1.3.0 + listhen: 1.10.0(srvx@0.11.15) + magic-string: 0.30.21 + magicast: 0.5.3 + mime: 4.1.0 + mlly: 1.8.2 + node-fetch-native: 1.6.7 + node-mock-http: 1.0.4 + ofetch: 1.5.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.1 + pretty-bytes: 7.1.0 + radix3: 1.1.2 + rollup: 4.60.2 + rollup-plugin-visualizer: 7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2) + scule: 1.3.0 + semver: 7.8.4 + serve-placeholder: 2.0.2 + serve-static: 2.2.1 + source-map: 0.7.6 + std-env: 4.1.0 + ufo: 1.6.4 + ultrahtml: 1.6.0 + uncrypto: 0.1.3 + unctx: 2.5.0 + unenv: 2.0.0-rc.24 + unimport: 6.3.0(oxc-parser@0.134.0)(rolldown@1.0.0-rc.18) + unplugin-utils: 0.3.1 + unstorage: 1.17.5(db0@0.3.4(@electric-sql/pglite@0.4.5)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.5)(@neondatabase/serverless@1.1.0)))(ioredis@5.10.1) + untyped: 2.0.0 + unwasm: 0.5.3 + youch: 4.1.1 + youch-core: 0.3.3 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@electric-sql/pglite' + - '@libsql/client' + - '@netlify/blobs' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bare-abort-controller + - bare-buffer + - better-sqlite3 + - drizzle-orm + - encoding + - idb-keyval + - mysql2 + - oxc-parser + - react-native-b4a + - rolldown + - sqlite3 + - srvx + - supports-color + - uploadthing + optional: true + node-addon-api@7.1.1: {} node-fetch-native@1.6.7: {} @@ -10594,7 +10923,7 @@ snapshots: dependencies: boolbase: 1.0.0 - nuxt-og-image@6.5.3(7c45f4c9f7a120d9de0460f4082612ea): + nuxt-og-image@6.5.3(778382f6defca828f628d461f7cab343): dependencies: '@clack/prompts': 1.5.1 '@nuxt/kit': 4.4.8(magicast@0.5.3) @@ -10612,8 +10941,8 @@ snapshots: magic-string: 0.30.21 magicast: 0.5.3 mocked-exports: 0.1.1 - nuxt-site-config: 4.0.8(d03ab0b60595d2cc94d63040f8689603) - nuxtseo-shared: 5.2.5(7f88583bab06a429a9cb0767d714ae42) + nuxt-site-config: 4.0.8(4a76482caed7f2aef5522e947723b6aa) + nuxtseo-shared: 5.2.5(8f0621ef11216476fa76fba6dad1aaac) nypm: 0.6.6 ofetch: 1.5.1 ohash: 2.0.11 @@ -10634,11 +10963,11 @@ snapshots: '@resvg/resvg-js': 2.6.2 '@resvg/resvg-wasm': 2.6.2 fontless: 0.2.1(db0@0.3.4(@electric-sql/pglite@0.4.5)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.5)(@neondatabase/serverless@1.1.0)))(ioredis@5.10.1)(vite@7.3.2(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)) - nitropack: 2.13.4(@electric-sql/pglite@0.4.5)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.5)(@neondatabase/serverless@1.1.0))(oxc-parser@0.133.0)(rolldown@1.0.0-rc.18)(srvx@0.11.15) + nitropack: 2.13.4(@electric-sql/pglite@0.4.5)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.5)(@neondatabase/serverless@1.1.0))(oxc-parser@0.134.0)(rolldown@1.0.0-rc.18)(srvx@0.11.15) playwright-core: 1.59.1 sharp: 0.34.5 unifont: 0.7.4 - zod: 3.25.76 + zod: 4.4.3 transitivePeerDependencies: - '@nuxt/schema' - nuxt @@ -10657,12 +10986,12 @@ snapshots: - magicast - vue - nuxt-site-config@4.0.8(d03ab0b60595d2cc94d63040f8689603): + nuxt-site-config@4.0.8(4a76482caed7f2aef5522e947723b6aa): dependencies: '@nuxt/kit': 4.4.8(magicast@0.5.3) h3: 1.15.11 nuxt-site-config-kit: 4.0.8(magicast@0.5.3)(vue@3.5.33(typescript@6.0.3)) - nuxtseo-shared: 5.2.5(7f88583bab06a429a9cb0767d714ae42) + nuxtseo-shared: 5.2.5(8f0621ef11216476fa76fba6dad1aaac) pathe: 2.0.3 pkg-types: 2.3.1 site-config-stack: 4.0.8(vue@3.5.33(typescript@6.0.3)) @@ -10809,7 +11138,7 @@ snapshots: - xml2js - yaml - nuxtseo-shared@5.2.5(7f88583bab06a429a9cb0767d714ae42): + nuxtseo-shared@5.2.5(8f0621ef11216476fa76fba6dad1aaac): dependencies: '@clack/prompts': 1.5.1 '@nuxt/devtools-kit': 4.0.0-alpha.3(magicast@0.5.3)(vite@7.3.2(@types/node@24.13.2)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)) @@ -10828,8 +11157,8 @@ snapshots: ufo: 1.6.4 vue: 3.5.33(typescript@6.0.3) optionalDependencies: - nuxt-site-config: 4.0.8(d03ab0b60595d2cc94d63040f8689603) - zod: 3.25.76 + nuxt-site-config: 4.0.8(4a76482caed7f2aef5522e947723b6aa) + zod: 4.4.3 transitivePeerDependencies: - magicast - vite @@ -10960,6 +11289,28 @@ snapshots: '@oxc-parser/binding-win32-ia32-msvc': 0.134.0 '@oxc-parser/binding-win32-x64-msvc': 0.134.0 + oxc-resolver@11.20.0: + optionalDependencies: + '@oxc-resolver/binding-android-arm-eabi': 11.20.0 + '@oxc-resolver/binding-android-arm64': 11.20.0 + '@oxc-resolver/binding-darwin-arm64': 11.20.0 + '@oxc-resolver/binding-darwin-x64': 11.20.0 + '@oxc-resolver/binding-freebsd-x64': 11.20.0 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.20.0 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.20.0 + '@oxc-resolver/binding-linux-arm64-gnu': 11.20.0 + '@oxc-resolver/binding-linux-arm64-musl': 11.20.0 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.20.0 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.20.0 + '@oxc-resolver/binding-linux-riscv64-musl': 11.20.0 + '@oxc-resolver/binding-linux-s390x-gnu': 11.20.0 + '@oxc-resolver/binding-linux-x64-gnu': 11.20.0 + '@oxc-resolver/binding-linux-x64-musl': 11.20.0 + '@oxc-resolver/binding-openharmony-arm64': 11.20.0 + '@oxc-resolver/binding-wasm32-wasi': 11.20.0 + '@oxc-resolver/binding-win32-arm64-msvc': 11.20.0 + '@oxc-resolver/binding-win32-x64-msvc': 11.20.0 + oxc-transform@0.133.0: optionalDependencies: '@oxc-transform/binding-android-arm-eabi': 0.133.0 @@ -11570,6 +11921,8 @@ snapshots: smob@1.6.1: {} + smol-toml@1.6.1: {} + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -11644,6 +11997,8 @@ snapshots: strip-final-newline@3.0.0: {} + strip-json-comments@5.0.3: {} + strip-literal@3.1.0: dependencies: js-tokens: 9.0.1 @@ -11779,6 +12134,8 @@ snapshots: ultrahtml@1.6.0: {} + unbash@3.0.0: {} + unconfig-core@7.5.0: dependencies: '@quansync/fs': 1.0.0 @@ -11847,6 +12204,27 @@ snapshots: oxc-parser: 0.133.0 rolldown: 1.0.0-rc.18 + unimport@6.3.0(oxc-parser@0.134.0)(rolldown@1.0.0-rc.18): + dependencies: + acorn: 8.16.0 + escape-string-regexp: 5.0.0 + estree-walker: 3.0.3 + local-pkg: 1.1.2 + magic-string: 0.30.21 + mlly: 1.8.2 + pathe: 2.0.3 + picomatch: 4.0.4 + pkg-types: 2.3.1 + scule: 1.3.0 + strip-literal: 3.1.0 + tinyglobby: 0.2.17 + unplugin: 3.0.0 + unplugin-utils: 0.3.1 + optionalDependencies: + oxc-parser: 0.134.0 + rolldown: 1.0.0-rc.18 + optional: true + universal-github-app-jwt@2.2.2: {} universal-user-agent@7.0.3: {} @@ -12177,6 +12555,8 @@ snapshots: optionalDependencies: typescript: 6.0.3 + walk-up-path@4.0.0: {} + webidl-conversions@3.0.1: {} webpack-virtual-modules@0.6.2: {} @@ -12274,3 +12654,5 @@ snapshots: readable-stream: 4.7.0 zod@3.25.76: {} + + zod@4.4.3: {} diff --git a/server/api/repos/[id]/disable.post.ts b/server/api/repos/[id]/disable.post.ts index 795ce97..f619542 100644 --- a/server/api/repos/[id]/disable.post.ts +++ b/server/api/repos/[id]/disable.post.ts @@ -1,7 +1,7 @@ import { and, eq } from 'drizzle-orm' import { repoMapping } from '#server/db/schema' import { useDb } from '#server/utils/db' -import { requireSession } from '#server/utils/server-session' +import { requireSessionAndMappingId } from '#server/utils/repo-route' /** * Pause sync for one mapping. The worker checks `disabledAt` on every push @@ -9,11 +9,7 @@ import { requireSession } from '#server/utils/server-session' * the prior state. */ export default defineEventHandler(async event => { - const session = await requireSession(event) - const mappingId = Number(getRouterParam(event, 'id')) - if (!Number.isFinite(mappingId)) { - throw createError({ statusCode: 400, statusMessage: 'invalid mapping id' }) - } + const { session, mappingId } = await requireSessionAndMappingId(event) const db = useDb() const updated = await db.update(repoMapping) diff --git a/server/api/repos/[id]/enable.post.ts b/server/api/repos/[id]/enable.post.ts index 8311fb2..662e0fc 100644 --- a/server/api/repos/[id]/enable.post.ts +++ b/server/api/repos/[id]/enable.post.ts @@ -1,15 +1,11 @@ import { and, eq } from 'drizzle-orm' import { repoMapping } from '#server/db/schema' import { useDb } from '#server/utils/db' -import { requireSession } from '#server/utils/server-session' +import { requireSessionAndMappingId } from '#server/utils/repo-route' /** Clear `disabledAt`, resuming sync for this mapping. */ export default defineEventHandler(async event => { - const session = await requireSession(event) - const mappingId = Number(getRouterParam(event, 'id')) - if (!Number.isFinite(mappingId)) { - throw createError({ statusCode: 400, statusMessage: 'invalid mapping id' }) - } + const { session, mappingId } = await requireSessionAndMappingId(event) const db = useDb() const updated = await db.update(repoMapping) diff --git a/server/api/repos/[id]/resync.post.ts b/server/api/repos/[id]/resync.post.ts index d2a9385..7dc58ed 100644 --- a/server/api/repos/[id]/resync.post.ts +++ b/server/api/repos/[id]/resync.post.ts @@ -2,7 +2,7 @@ import { and, eq } from 'drizzle-orm' import { repoMapping } from '#server/db/schema' import { useDb } from '#server/utils/db' import { enqueue } from '#server/utils/queue' -import { requireSession } from '#server/utils/server-session' +import { requireSessionAndMappingId } from '#server/utils/repo-route' /** * Enqueue a forced `tangled.create-repo` job for one mapping. The handler @@ -10,11 +10,7 @@ import { requireSession } from '#server/utils/server-session' * flag tells it to re-run the enrolment flow. */ export default defineEventHandler(async event => { - const session = await requireSession(event) - const mappingId = Number(getRouterParam(event, 'id')) - if (!Number.isFinite(mappingId)) { - throw createError({ statusCode: 400, statusMessage: 'invalid mapping id' }) - } + const { session, mappingId } = await requireSessionAndMappingId(event) const db = useDb() const rows = await db.select({ diff --git a/server/db/schema.ts b/server/db/schema.ts index be427f0..a2f1291 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -130,11 +130,10 @@ export const webhookEvent = pgTable('webhook_event', { check('webhook_event_source_chk', sql`${table.source} in ('github','tangled')`), ]) -// AT Protocol OAuth stores. The values are encrypted at rest (libsodium sealed -// box) because they contain access tokens, refresh tokens, and the DPoP private -// key for the user's PDS. The encryption layer wraps the OAuth library's store -// interface (encrypt in set, decrypt in get); see commit 9 (`feat: generate -// per-install ssh key and publish publickey record`) for the shared helper. +// AT Protocol OAuth stores. The values are encrypted at rest because they +// contain access tokens, refresh tokens, and the DPoP private key for the +// user's PDS. The encryption layer wraps the OAuth library's store interface +// (encrypt in set, decrypt in get). export const atprotoState = pgTable('atproto_state', { key: text('key').primaryKey(), valueCiphertext: bytea('value_ciphertext').notNull(), diff --git a/server/utils/encryption.ts b/server/utils/encryption.ts index d30f72b..608ad55 100644 --- a/server/utils/encryption.ts +++ b/server/utils/encryption.ts @@ -7,7 +7,7 @@ import { xchacha20poly1305 } from '@noble/ciphers/chacha.js' * before it lands in the DB: AT Proto session blobs, SSH private keys. * * The KEK is held only in env. If it's lost, every encrypted row becomes - * unreadable. KEK rotation is a future concern \u2014 see PLAN.md. + * unreadable. KEK rotation is a future concern; see PLAN.md. */ const NONCE_BYTES = 24 let cachedKey: Uint8Array | undefined diff --git a/server/utils/github-app.ts b/server/utils/github-app.ts index f49c198..3cea855 100644 --- a/server/utils/github-app.ts +++ b/server/utils/github-app.ts @@ -32,6 +32,12 @@ export async function installationOctokit(installationId: number): Promise { + const { token } = (await octokit.auth({ type: 'installation' })) as { token: string } + return token +} + function requireOAuthApp(): App { if (!process.env.NUXT_GITHUB_APP_CLIENT_ID || !process.env.NUXT_GITHUB_APP_CLIENT_SECRET) { throw createError({ diff --git a/server/utils/job-handlers.ts b/server/utils/job-handlers.ts index 40272d1..d95d4ed 100644 --- a/server/utils/job-handlers.ts +++ b/server/utils/job-handlers.ts @@ -1,3 +1,4 @@ +import type { OAuthSession } from '@atproto/oauth-client-node' import { and, eq, sql } from 'drizzle-orm' import { repoMapping, userIdentity } from '../db/schema' import { useOAuthClient } from './atproto-oauth' @@ -10,21 +11,7 @@ import { syncPush, type PushPayload } from './sync-push' import { generateAndPublishKey, rotateKey } from './tangled-pubkey' import { enrollRepo, syncRepoMetadata } from './tangled-repo' -/** - * Map of job kind → handler. Each commit fills in its slice: - * - 'github.push' → commit 12 (sync push events) - * - 'github.create' / 'github.delete' → this commit (branch/tag ref ops) - * - 'github.repository' → metadata sync + lifecycle (edited, - * renamed, privatized, publicized, - * transferred, deleted) - * - 'github.installation_repositories' → commit 10 (fan-out enrolment) - * - 'tangled.backfill-installation' → commit 10 (paginate + fan-out) - * - 'tangled.create-repo' → commit 10 (per-repo enrolment) - * - 'atproto.publish-pubkey' → commit 9 - * - * Unknown kinds throw so they surface as job failures rather than silent - * acknowledgement. - */ +/** Job kinds `dispatch` understands; anything else throws as a job failure. */ const KNOWN_KINDS = new Set([ 'github.push', 'github.create', @@ -220,18 +207,8 @@ export async function dispatch(envelope: JobEnvelope): Promise { if (envelope.kind === 'tangled.create-repo') { const { installationId, githubRepoId, force } = createRepoPayload(envelope.payload) - - // Find the user identity bound to this install. If OAuth hasn't completed - // yet, drop this job silently \u2014 OAuth callback re-enqueues for all - // accessible repos at completion time, so we'll get a fresh trigger. - const db = useDb() - const identity = await db.select({ did: userIdentity.did }) - .from(userIdentity) - .where(sql`${userIdentity.installationId} = ${installationId}`) - if (identity.length === 0) return - - const client = await useOAuthClient() - const session = await client.restore(identity[0]!.did) + const session = await restoreSessionForInstallation(installationId) + if (!session) return await enrollRepo({ oauthSession: session, installationId, githubRepoId, force }) return } @@ -295,8 +272,6 @@ export async function dispatch(envelope: JobEnvelope): Promise { await handleRepositoryEvent(repositoryPayload(envelope.payload)) return } - - // Other kinds: still no-op until handlers land in their commits. } async function handleRepositoryEvent(payload: RepositoryPayload): Promise { @@ -388,14 +363,24 @@ async function handleRepositoryEvent(payload: RepositoryPayload): Promise } async function runMetadataSync(installationId: number, githubRepoId: number): Promise { + const session = await restoreSessionForInstallation(installationId) + if (!session) return + await syncRepoMetadata({ oauthSession: session, installationId, githubRepoId }) +} + +/** + * Restore the OAuth session for the user identity bound to `installationId`, + * or null if OAuth hasn't completed yet. The OAuth callback re-enqueues work + * for all accessible repos on completion, so a null here is a benign drop: a + * fresh trigger arrives once the identity exists. + */ +async function restoreSessionForInstallation(installationId: number): Promise { const db = useDb() const identity = await db.select({ did: userIdentity.did }) .from(userIdentity) .where(sql`${userIdentity.installationId} = ${installationId}`) - // No tangled identity yet — OAuth callback will backfill on completion. - if (identity.length === 0) return + if (identity.length === 0) return null const client = await useOAuthClient() - const session = await client.restore(identity[0]!.did) - await syncRepoMetadata({ oauthSession: session, installationId, githubRepoId }) + return client.restore(identity[0]!.did) } diff --git a/server/utils/repo-mapping.ts b/server/utils/repo-mapping.ts new file mode 100644 index 0000000..a469b29 --- /dev/null +++ b/server/utils/repo-mapping.ts @@ -0,0 +1,101 @@ +import { and, eq, sql } from 'drizzle-orm' +import { repoMapping } from '../db/schema' +import { useDb } from './db' +import { RemoteRejectedError } from './git-wire/errors' + +export interface ActiveMapping { + id: number + githubFullName: string + tangledRepoDid: string + knot: string + lastSyncedRefs: Record +} + +export type SkipReason = 'no-mapping' | 'disabled' + +/** + * Load the `repo_mapping` row for `(installationId, githubRepoId)` and confirm + * it's ready to sync. Returns `{ skip }` when the row is missing, disabled, or + * hasn't completed enrolment (no `tangledRepoDid`/`knot` yet). + */ +export async function loadActiveMapping( + installationId: number, + githubRepoId: number, +): Promise<{ mapping: ActiveMapping } | { skip: SkipReason }> { + const db = useDb() + const rows = await db.select().from(repoMapping).where( + and( + eq(repoMapping.installationId, installationId), + eq(repoMapping.githubRepoId, githubRepoId), + ), + ).limit(1) + + if (rows.length === 0) return { skip: 'no-mapping' } + const row = rows[0]! + + if (row.disabledAt) return { skip: 'disabled' } + if (!row.tangledRepoDid || !row.knot) return { skip: 'no-mapping' } + + return { + mapping: { + id: row.id, + githubFullName: row.githubFullName, + tangledRepoDid: row.tangledRepoDid, + knot: row.knot, + // eslint-disable-next-line ts/no-unsafe-type-assertion -- jsonb column is typed `unknown` + lastSyncedRefs: (row.lastSyncedRefs ?? {}) as Record, + }, + } +} + +/** Record the synced tip for one ref in the `lastSyncedRefs` jsonb map. */ +export async function setLastSyncedRef(mappingId: number, fullRef: string, sha: string): Promise { + const db = useDb() + await db.update(repoMapping) + .set({ + lastSyncedRefs: sql`jsonb_set(${repoMapping.lastSyncedRefs}, ${`{${jsonbPathElement(fullRef)}}`}::text[], ${`"${sha}"`}::jsonb, true)`, + updatedAt: new Date(), + }) + .where(eq(repoMapping.id, mappingId)) +} + +/** Drop one ref from the `lastSyncedRefs` jsonb map. No-op if absent. */ +export async function clearLastSyncedRef(mappingId: number, fullRef: string): Promise { + const db = useDb() + await db.update(repoMapping) + .set({ + lastSyncedRefs: sql`${repoMapping.lastSyncedRefs} - ${fullRef}`, + updatedAt: new Date(), + }) + .where(eq(repoMapping.id, mappingId)) +} + +/** Mark a mapping `status='error'` so the worker stops retrying. */ +export async function markMappingError(mappingId: number, message: string): Promise { + const db = useDb() + await db.update(repoMapping) + .set({ status: 'error', lastError: message, updatedAt: new Date() }) + .where(eq(repoMapping.id, mappingId)) +} + +/** Human-readable `lastError` text for a terminal knot rejection. */ +export function terminalRejectionMessage(err: RemoteRejectedError): string { + if (err.reason === 'too-big') return `pack exceeded the configured size limit; stopping sync (${err.message})` + if (err.reason === 'auth-rejected') return 'knot rejected our ssh key; stopping sync' + return 'knot reports repo no longer exists; stopping sync' +} + +/** + * True for knot rejections we treat as terminal: the repo is gone, our key is + * rejected, or the pack blew the size cap. Callers mark the mapping `error` + * and stop retrying; anything else re-throws for the queue's backoff. + */ +export function isTerminalRejection(err: unknown): err is RemoteRejectedError { + return err instanceof RemoteRejectedError + && (err.reason === 'repo-gone' || err.reason === 'auth-rejected' || err.reason === 'too-big') +} + +/** jsonb path array element for a ref, escaping embedded quotes. */ +function jsonbPathElement(ref: string): string { + return `"${ref.replaceAll('"', '\\"')}"` +} diff --git a/server/utils/repo-route.ts b/server/utils/repo-route.ts new file mode 100644 index 0000000..3168eea --- /dev/null +++ b/server/utils/repo-route.ts @@ -0,0 +1,20 @@ +import type { H3Event } from 'h3' +import type { SynchubAccount } from './server-session' +import { requireSession } from './server-session' + +/** + * Shared preamble for `/api/repos/[id]/*` handlers: require an authenticated + * session and parse the `:id` route param to a mapping id, throwing a 400 if + * it isn't a finite number. Ownership is enforced downstream by scoping the + * query to `session.installationId`. + */ +export async function requireSessionAndMappingId( + event: H3Event, +): Promise<{ session: SynchubAccount, mappingId: number }> { + const session = await requireSession(event) + const mappingId = Number(getRouterParam(event, 'id')) + if (!Number.isFinite(mappingId)) { + throw createError({ statusCode: 400, statusMessage: 'invalid mapping id' }) + } + return { session, mappingId } +} diff --git a/server/utils/sync-push-host.ts b/server/utils/sync-push-host.ts index 3feec73..43d51c8 100644 --- a/server/utils/sync-push-host.ts +++ b/server/utils/sync-push-host.ts @@ -7,7 +7,7 @@ * * The official UI does this same mapping in * `appview/pages/templates/repo/empty.html`. If tangled adds more - * appview-hosted knots in future this'll need updating \u2014 see PLAN.md + * appview-hosted knots in future this'll need updating; see PLAN.md * "Deferred / follow-ups". */ export function sshHostForKnot(knot: string): string { diff --git a/server/utils/sync-push.ts b/server/utils/sync-push.ts index faf7c9b..74ab025 100644 --- a/server/utils/sync-push.ts +++ b/server/utils/sync-push.ts @@ -1,8 +1,5 @@ -import { and, eq, sql } from 'drizzle-orm' -import { repoMapping } from '../db/schema' -import { useDb } from './db' -import { RemoteRejectedError } from './git-wire/errors' -import { installationOctokit } from './github-app' +import { installationOctokit, installationToken } from './github-app' +import { isTerminalRejection, loadActiveMapping, markMappingError, setLastSyncedRef, terminalRejectionMessage } from './repo-mapping' import { splicePush } from './splice' const ZERO_SHA = '0000000000000000000000000000000000000000' @@ -40,71 +37,38 @@ export interface PushResult { * the queue retries with backoff; the retry re-reads the knot's tip. */ export async function syncPush(payload: PushPayload): Promise { - const db = useDb() - - const mapping = await db.select().from(repoMapping).where( - and( - eq(repoMapping.installationId, payload.installationId), - eq(repoMapping.githubRepoId, payload.githubRepoId), - ), - ).limit(1) - if (mapping.length === 0) return { status: 'skipped', reason: 'no-mapping' } - const row = mapping[0]! - - if (row.disabledAt) return { status: 'skipped', reason: 'disabled' } - if (!row.tangledRepoDid || !row.knot) return { status: 'skipped', reason: 'no-mapping' } + const loaded = await loadActiveMapping(payload.installationId, payload.githubRepoId) + if ('skip' in loaded) return { status: 'skipped', reason: loaded.skip } + const { mapping } = loaded if (payload.after === ZERO_SHA) return { status: 'skipped', reason: 'deletion' } - const lastSynced = (row.lastSyncedRefs as Record)[payload.ref] - if (lastSynced === payload.after) return { status: 'skipped', reason: 'already-synced' } + if (mapping.lastSyncedRefs[payload.ref] === payload.after) { + return { status: 'skipped', reason: 'already-synced' } + } const octokit = await installationOctokit(payload.installationId) - const { token } = (await octokit.auth({ type: 'installation' })) as { token: string } + const token = await installationToken(octokit) try { const result = await splicePush({ installationId: payload.installationId, - repoFullName: row.githubFullName, - knot: row.knot, - repoDid: row.tangledRepoDid, + repoFullName: mapping.githubFullName, + knot: mapping.knot, + repoDid: mapping.tangledRepoDid, ref: payload.ref, want: payload.after, token, }) - await db.update(repoMapping) - .set({ - lastSyncedRefs: sql`jsonb_set(${repoMapping.lastSyncedRefs}, ${`{${jsonbPath(payload.ref)}}`}::text[], ${`"${result.sha}"`}::jsonb, true)`, - updatedAt: new Date(), - }) - .where(eq(repoMapping.id, row.id)) - + await setLastSyncedRef(mapping.id, payload.ref, result.sha) return { status: 'synced' } } catch (err) { - if (err instanceof RemoteRejectedError && (err.reason === 'repo-gone' || err.reason === 'auth-rejected' || err.reason === 'too-big')) { - await markMappingError(row.id, terminalMessage(err)) + if (isTerminalRejection(err)) { + await markMappingError(mapping.id, terminalRejectionMessage(err)) return { status: 'skipped', reason: 'repo-gone' } } throw err } } - -function terminalMessage(err: RemoteRejectedError): string { - if (err.reason === 'too-big') return `pack exceeded the configured size limit; stopping sync (${err.message})` - if (err.reason === 'auth-rejected') return 'knot rejected our ssh key; stopping sync' - return 'knot reports repo no longer exists; stopping sync' -} - -/** jsonb_set path argument: `refs/heads/main` becomes a single text array element. */ -function jsonbPath(ref: string): string { - return `"${ref.replaceAll('"', '\\"')}"` -} - -async function markMappingError(mappingId: number, message: string): Promise { - const db = useDb() - await db.update(repoMapping) - .set({ status: 'error', lastError: message, updatedAt: new Date() }) - .where(eq(repoMapping.id, mappingId)) -} diff --git a/server/utils/sync-ref.ts b/server/utils/sync-ref.ts index c2b371d..c07cc02 100644 --- a/server/utils/sync-ref.ts +++ b/server/utils/sync-ref.ts @@ -1,9 +1,7 @@ -import { and, eq, sql } from 'drizzle-orm' -import { repoMapping } from '../db/schema' -import { useDb } from './db' import { RemoteRejectedError, WireError } from './git-wire/errors' import { fetchAdvertisement } from './git-wire/upload-pack' -import { installationOctokit } from './github-app' +import { installationOctokit, installationToken } from './github-app' +import { clearLastSyncedRef, isTerminalRejection, loadActiveMapping, markMappingError, setLastSyncedRef } from './repo-mapping' import { spliceDelete, splicePush } from './splice' export type RefType = 'branch' | 'tag' @@ -42,13 +40,14 @@ export async function syncCreateRef(payload: CreateRefPayload): Promise { - const db = useDb() - const rows = await db.select().from(repoMapping).where( - and( - eq(repoMapping.installationId, installationId), - eq(repoMapping.githubRepoId, githubRepoId), - ), - ).limit(1) - - if (rows.length === 0) return { skip: { status: 'skipped', reason: 'no-mapping' } } - const row = rows[0]! - - if (row.disabledAt) return { skip: { status: 'skipped', reason: 'disabled' } } - if (!row.tangledRepoDid || !row.knot) return { skip: { status: 'skipped', reason: 'no-mapping' } } - - return { - id: row.id, - githubFullName: row.githubFullName, - tangledRepoDid: row.tangledRepoDid, - knot: row.knot, - } -} - -async function updateLastSyncedRef(mappingId: number, fullRef: string, sha: string): Promise { - const db = useDb() - await db.update(repoMapping) - .set({ - lastSyncedRefs: sql`jsonb_set(${repoMapping.lastSyncedRefs}, ${`{${jsonbPath(fullRef)}}`}::text[], ${`"${sha}"`}::jsonb, true)`, - updatedAt: new Date(), - }) - .where(eq(repoMapping.id, mappingId)) -} - -async function clearLastSyncedRef(mappingId: number, fullRef: string): Promise { - const db = useDb() - // jsonb minus text removes a top-level key. Safe no-op if absent. - await db.update(repoMapping) - .set({ - lastSyncedRefs: sql`${repoMapping.lastSyncedRefs} - ${fullRef}`, - updatedAt: new Date(), - }) - .where(eq(repoMapping.id, mappingId)) -} - -async function markMappingError(mappingId: number, message: string): Promise { - const db = useDb() - await db.update(repoMapping) - .set({ status: 'error', lastError: message, updatedAt: new Date() }) - .where(eq(repoMapping.id, mappingId)) -} - -function jsonbPath(ref: string): string { - return `"${ref.replaceAll('"', '\\"')}"` -} diff --git a/server/utils/tangled-pubkey.ts b/server/utils/tangled-pubkey.ts index a08e31c..e71bb55 100644 --- a/server/utils/tangled-pubkey.ts +++ b/server/utils/tangled-pubkey.ts @@ -9,14 +9,52 @@ import { generateKeypair } from './ssh-keypair' const PUBKEY_LEXICON = 'sh.tangled.publicKey' +/** HTTP status off an unknown thrown value, or undefined if it has none. */ +function errorStatus(err: unknown): number | undefined { + return err && typeof err === 'object' && 'status' in err && typeof err.status === 'number' + ? err.status + : undefined +} + +/** + * Delete one `sh.tangled.publicKey` record from `did`'s PDS, treating an + * already-gone record (404) as success. Any other PDS error re-throws. + */ +async function deletePubkeyRecord(session: OAuthSession, did: string, rkey: string): Promise { + const agent = new Agent(session) + try { + await agent.com.atproto.repo.deleteRecord({ repo: did, collection: PUBKEY_LEXICON, rkey }) + } + catch (err) { + if (errorStatus(err) !== 404) throw err + } +} + +/** + * Restore `did`'s OAuth session and delete one `sh.tangled.publicKey` record, + * never throwing: a 404 is already-gone, and any other failure (including a + * session that can no longer be restored) is logged so the caller's cleanup + * proceeds. + */ +async function bestEffortRevoke(did: string, rkey: string, installationId: number): Promise { + const client = await useOAuthClient() + try { + const session = await client.restore(did) + await deletePubkeyRecord(session, did, rkey) + } + catch (err) { + console.error(`failed to revoke publicKey record for did ${did} (installation ${installationId})`, err) + } +} + /** * Generate a per-install SSH keypair, write the public half to the user's PDS * as a `sh.tangled.publicKey` record, and persist the encrypted private half * + the resulting record key in the `ssh_key` table. * * If a row already exists for `(installation_id, did)` we no-op. Rotation is a - * separate, explicit dashboard action (commit 16-ish) that re-runs this with - * the existing record then deletes the old one. + * separate, explicit dashboard action (`rotateKey`) that deletes the existing + * record then re-runs this. */ export async function generateAndPublishKey(opts: { oauthSession: OAuthSession @@ -37,7 +75,7 @@ export async function generateAndPublishKey(opts: { const keypair = generateKeypair(keyName) // Publish to PDS first. If this fails, we surface the error and leave no - // half-state in the DB \u2014 the caller can retry. + // half-state in the DB; the caller can retry. const agent = new Agent(opts.oauthSession) const result = await agent.com.atproto.repo.createRecord({ repo: did, @@ -93,23 +131,7 @@ export async function rotateKey(opts: { if (existing.length > 0) { const row = existing[0]! if (row.rkey) { - const agent = new Agent(opts.oauthSession) - try { - await agent.com.atproto.repo.deleteRecord({ - repo: did, - collection: PUBKEY_LEXICON, - rkey: row.rkey, - }) - } - catch (err) { - // If the record is already gone (404) we can safely continue; any - // other error means the PDS rejected the delete and we should bail - // rather than leave the user with two records. - const status = err && typeof err === 'object' && 'status' in err && typeof err.status === 'number' - ? err.status - : undefined - if (status !== 404) throw err - } + await deletePubkeyRecord(opts.oauthSession, did, row.rkey) } await db.delete(sshKey).where(sql`${sshKey.id} = ${row.id}`) } @@ -134,28 +156,10 @@ export async function revokeKeysForInstallation(installationId: number): Promise .from(sshKey) .where(sql`${sshKey.installationId} = ${installationId}`) - const client = await useOAuthClient() - for (const row of rows) { if (!row.rkey) continue - try { - // eslint-disable-next-line no-await-in-loop -- one PDS session per row - const session = await client.restore(row.did) - const agent = new Agent(session) - // eslint-disable-next-line no-await-in-loop -- sequential PDS deletes - await agent.com.atproto.repo.deleteRecord({ - repo: row.did, - collection: PUBKEY_LEXICON, - rkey: row.rkey, - }) - } - catch (err) { - const status = err && typeof err === 'object' && 'status' in err && typeof err.status === 'number' - ? err.status - : undefined - if (status === 404) continue - console.error(`failed to revoke publicKey record for did ${row.did} (installation ${installationId})`, err) - } + // eslint-disable-next-line no-await-in-loop -- one PDS session per row + await bestEffortRevoke(row.did, row.rkey, installationId) } } @@ -175,29 +179,10 @@ export async function revokeKeyForInstallationDid(installationId: number, did: s .from(sshKey) .where(sql`${sshKey.installationId} = ${installationId} AND ${sshKey.did} = ${did}`) - const client = await useOAuthClient() - for (const row of rows) { if (row.rkey) { - try { - // eslint-disable-next-line no-await-in-loop -- one PDS session per row - const session = await client.restore(did) - const agent = new Agent(session) - // eslint-disable-next-line no-await-in-loop -- sequential PDS deletes - await agent.com.atproto.repo.deleteRecord({ - repo: did, - collection: PUBKEY_LEXICON, - rkey: row.rkey, - }) - } - catch (err) { - const status = err && typeof err === 'object' && 'status' in err && typeof err.status === 'number' - ? err.status - : undefined - if (status !== 404) { - console.error(`failed to revoke publicKey record for did ${did} (installation ${installationId})`, err) - } - } + // eslint-disable-next-line no-await-in-loop -- one PDS session per row + await bestEffortRevoke(did, row.rkey, installationId) } // eslint-disable-next-line no-await-in-loop -- sequential row deletes await db.delete(sshKey).where(sql`${sshKey.id} = ${row.id}`) diff --git a/test/unit/sync-push.spec.ts b/test/unit/sync-push.spec.ts index 77460df..dbc2649 100644 --- a/test/unit/sync-push.spec.ts +++ b/test/unit/sync-push.spec.ts @@ -18,6 +18,7 @@ vi.mock('../../server/utils/splice', () => ({ vi.mock('../../server/utils/github-app', () => ({ installationOctokit: async () => ({ auth: octokitAuthMock }), + installationToken: async () => (await octokitAuthMock({ type: 'installation' })).token, })) const { syncPush } = await import('../../server/utils/sync-push') diff --git a/test/unit/sync-ref.spec.ts b/test/unit/sync-ref.spec.ts index c4d8afc..e8eb1e7 100644 --- a/test/unit/sync-ref.spec.ts +++ b/test/unit/sync-ref.spec.ts @@ -25,6 +25,7 @@ vi.mock('../../server/utils/splice', () => ({ vi.mock('../../server/utils/github-app', () => ({ installationOctokit: async () => ({ auth: octokitAuthMock }), + installationToken: async () => (await octokitAuthMock({ type: 'installation' })).token, })) const { syncCreateRef, syncDeleteRef } = await import('../../server/utils/sync-ref')