mirror your GitHub repos to tangled.org automatically
1

Configure Feed

Select the types of activity you want to include in your feed.

refactor: add knip and add shared helpers

+655 -259
+25
knip.json
··· 1 + { 2 + "$schema": "https://unpkg.com/knip@6/schema.json", 3 + "entry": [ 4 + "scripts/*.ts", 5 + "test/utils/**/*.ts" 6 + ], 7 + "project": [ 8 + "server/**/*.ts", 9 + "app/**/*.{ts,vue}", 10 + "scripts/**/*.ts", 11 + "test/**/*.ts", 12 + "tests/**/*.ts" 13 + ], 14 + "ignoreDependencies": [ 15 + "rolldown", 16 + "vue-router", 17 + "@stylistic/eslint-plugin", 18 + "h3" 19 + ], 20 + "ignoreBinaries": [ 21 + "ssh-keygen", 22 + "git-receive-pack", 23 + "git-upload-pack" 24 + ] 25 + }
+3 -2
package.json
··· 32 32 "test:nuxt": "vp test --project nuxt", 33 33 "test:browser": "playwright test", 34 34 "test:browser:ui": "playwright test --ui", 35 - "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'" 35 + "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'", 36 + "knip": "knip" 36 37 }, 37 38 "dependencies": { 38 39 "@atcute/tid": "^1.1.2", ··· 46 47 "@nuxt/scripts": "^1.2.1", 47 48 "@nuxtjs/html-validator": "^2.1.0", 48 49 "@octokit/app": "^16.1.2", 49 - "@octokit/auth-app": "^8.2.0", 50 50 "@octokit/webhooks-methods": "^6.0.0", 51 51 "drizzle-orm": "^0.45.2", 52 52 "nuxt": "^4.4.8", ··· 69 69 "@vue/test-utils": "2.4.10", 70 70 "drizzle-kit": "^0.31.10", 71 71 "happy-dom": "20.9.0", 72 + "knip": "^6.16.1", 72 73 "nano-staged": "^1.0.2", 73 74 "playwright-core": "^1.59.1", 74 75 "simple-git-hooks": "2.13.1",
+396 -14
pnpm-lock.yaml
··· 45 45 '@octokit/app': 46 46 specifier: ^16.1.2 47 47 version: 16.1.2 48 - '@octokit/auth-app': 49 - specifier: ^8.2.0 50 - version: 8.2.0 51 48 '@octokit/webhooks-methods': 52 49 specifier: ^6.0.0 53 50 version: 6.0.0 ··· 59 56 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) 60 57 nuxt-og-image: 61 58 specifier: ^6.4.11 62 - version: 6.5.3(7c45f4c9f7a120d9de0460f4082612ea) 59 + version: 6.5.3(778382f6defca828f628d461f7cab343) 63 60 rolldown: 64 61 specifier: ^1.0.0-rc.18 65 62 version: 1.0.0-rc.18 ··· 109 106 happy-dom: 110 107 specifier: 20.9.0 111 108 version: 20.9.0 109 + knip: 110 + specifier: ^6.16.1 111 + version: 6.16.1 112 112 nano-staged: 113 113 specifier: ^1.0.2 114 114 version: 1.0.2 ··· 1964 1964 '@oxc-project/types@0.134.0': 1965 1965 resolution: {integrity: sha512-T0xuRRKrQFmocH8y+jGfpmSkGcheaJExY9lEihmR1Gm2aH+75B8CzgU2rABRQSzzDxLjZ15Sc0bRVLj5lVeNXQ==} 1966 1966 1967 + '@oxc-resolver/binding-android-arm-eabi@11.20.0': 1968 + resolution: {integrity: sha512-IjfWOXRgJFNdORDl+Uf1aibNgZY2guOD3zmOhx1BGVb/MIiqlFTdmjpQNplSN58lhWehnX4UNqC3QwpUo8pjJg==} 1969 + cpu: [arm] 1970 + os: [android] 1971 + 1972 + '@oxc-resolver/binding-android-arm64@11.20.0': 1973 + resolution: {integrity: sha512-QqslZAuFQG8Q9xm7JuIn8JUbvywhSBMVhuQHtYW+auirZJloS41oxUUaBXk7uUhZJgp44c5zQLeVvmFaDQB+2Q==} 1974 + cpu: [arm64] 1975 + os: [android] 1976 + 1977 + '@oxc-resolver/binding-darwin-arm64@11.20.0': 1978 + resolution: {integrity: sha512-MUcavykj2ewlR+kc5arpg4tC2RvzJkUxWtNv74pf7lcNk00GpIpN43vXMj+j6r4eMmfZhlb8hueKoIb8e9kAGQ==} 1979 + cpu: [arm64] 1980 + os: [darwin] 1981 + 1982 + '@oxc-resolver/binding-darwin-x64@11.20.0': 1983 + resolution: {integrity: sha512-BGB16nRUK5Etiv//ihPyzj8Lj1px0mhh4YIfe0FDf045ywknfSm0GEbiRESpr6Q4K82AvnyaRIhhluHByvS4bg==} 1984 + cpu: [x64] 1985 + os: [darwin] 1986 + 1987 + '@oxc-resolver/binding-freebsd-x64@11.20.0': 1988 + resolution: {integrity: sha512-JZgtePaqj3qmD5XFHJaSLWzHRxQu0LaPkdoM1KJXYADvAaa83ijXHclV3ej3CueeW0wxfIAbGCZVP45J0CA7uQ==} 1989 + cpu: [x64] 1990 + os: [freebsd] 1991 + 1992 + '@oxc-resolver/binding-linux-arm-gnueabihf@11.20.0': 1993 + resolution: {integrity: sha512-hOQ/p3ry3v3SchUBXicrrnszaI/UmYzM4wtS4RGfwgVUX7a+HbyQSzJ5aOzu+o6XZkFkS3ZXN4PZAzhOb77OSg==} 1994 + cpu: [arm] 1995 + os: [linux] 1996 + 1997 + '@oxc-resolver/binding-linux-arm-musleabihf@11.20.0': 1998 + resolution: {integrity: sha512-2ArPksaw0AqeuGBfoS715VF+JvJQAhD2niWgjE5hVO+L+nAfikVQopvngCMX9x4BD8itWoQ3dnikrQyl5Ho5Jg==} 1999 + cpu: [arm] 2000 + os: [linux] 2001 + 2002 + '@oxc-resolver/binding-linux-arm64-gnu@11.20.0': 2003 + resolution: {integrity: sha512-0bJnmYFp62JdZ4nVMDUZ/C58BCZOCcqgKtnUlp7L9Ojf/czIN+3j72YlLPeWLkzlr6SlYvIQA4SGV/HyO0d+qg==} 2004 + cpu: [arm64] 2005 + os: [linux] 2006 + libc: [glibc] 2007 + 2008 + '@oxc-resolver/binding-linux-arm64-musl@11.20.0': 2009 + resolution: {integrity: sha512-wKHHzPKZo7Ufhv/Bt6yxT7FOgnIgW4gwXcJUipkShGp68W3wGVqvr1Sr0fY65lN0Oy6y41+g2kIDvkgZaMMUkw==} 2010 + cpu: [arm64] 2011 + os: [linux] 2012 + libc: [musl] 2013 + 2014 + '@oxc-resolver/binding-linux-ppc64-gnu@11.20.0': 2015 + resolution: {integrity: sha512-RN8goF7Ie0B79L4i4G6OeBocTgSC56vJbQ65VJje+oXnldVpLnOU7j/AQ/dP94TcCS+Yh6WG8u3Qt4ETteXFNQ==} 2016 + cpu: [ppc64] 2017 + os: [linux] 2018 + libc: [glibc] 2019 + 2020 + '@oxc-resolver/binding-linux-riscv64-gnu@11.20.0': 2021 + resolution: {integrity: sha512-5l1yU6/xQEqLZRzxqmMxJfWPslpwCmBsdDGaBvABPehxquCXDC7dd7oraNdKSJUMDXSM7VvVj8H2D2FTjU7oWw==} 2022 + cpu: [riscv64] 2023 + os: [linux] 2024 + libc: [glibc] 2025 + 2026 + '@oxc-resolver/binding-linux-riscv64-musl@11.20.0': 2027 + resolution: {integrity: sha512-xHEvkbgz6UC+A3JOyDQy76LkUaxsNSfIr3/GV8slwZsnuooJiIB34gzJfsyvR4JdCYNUUPsRJc/w/oWkODu+hg==} 2028 + cpu: [riscv64] 2029 + os: [linux] 2030 + libc: [musl] 2031 + 2032 + '@oxc-resolver/binding-linux-s390x-gnu@11.20.0': 2033 + resolution: {integrity: sha512-aWPDUUmSeyHvlW+SoEUd+JIJsQhVhu6a5tBpDRMu058naPAchTgAVGCFy35zjbnFlt0i8hLWziff6HX0D3LU4g==} 2034 + cpu: [s390x] 2035 + os: [linux] 2036 + libc: [glibc] 2037 + 2038 + '@oxc-resolver/binding-linux-x64-gnu@11.20.0': 2039 + resolution: {integrity: sha512-x2YeSimvhJjKLVD8KSu8f/rqU1potcdEMkApIPJqjZWN7c2Fpt4g2X32WDg1p+XDAmyT7nuQGe0vnhvXeLbH+g==} 2040 + cpu: [x64] 2041 + os: [linux] 2042 + libc: [glibc] 2043 + 2044 + '@oxc-resolver/binding-linux-x64-musl@11.20.0': 2045 + resolution: {integrity: sha512-kcRLEIxpZefeYfLChjpgFf3ilBzRDZ+yobMrpRsQlSrxuFGtm3U6PMU7AaEpMqo3NfDGVyJJseAjnRLzMFHjwQ==} 2046 + cpu: [x64] 2047 + os: [linux] 2048 + libc: [musl] 2049 + 2050 + '@oxc-resolver/binding-openharmony-arm64@11.20.0': 2051 + resolution: {integrity: sha512-HHcfnApSZGtKhTiHqe8OZruOZe5XuFQH5/E0Yhj3u8fnFvzkM4/k6WjacUf4SvA0SPEAbfbgYmVPuo0VX/fIBQ==} 2052 + cpu: [arm64] 2053 + os: [openharmony] 2054 + 2055 + '@oxc-resolver/binding-wasm32-wasi@11.20.0': 2056 + resolution: {integrity: sha512-Tn0y1XOFYHNfK1wp1Z5QK8Rcld/bsOwRISQXfqAZ5IBpv8Gz1IvV39fUWNprqNdRizgcvFhOzWwFun2zkJsyBg==} 2057 + engines: {node: '>=14.0.0'} 2058 + cpu: [wasm32] 2059 + 2060 + '@oxc-resolver/binding-win32-arm64-msvc@11.20.0': 2061 + resolution: {integrity: sha512-qPi25YNPe4YenS8MgsQU2+bIFHxxpLx1LVna2444cEHqNPhNjvWf9zqj4aWE43H9LpAsTmkkAlA3eL5ElBU3mA==} 2062 + cpu: [arm64] 2063 + os: [win32] 2064 + 2065 + '@oxc-resolver/binding-win32-x64-msvc@11.20.0': 2066 + resolution: {integrity: sha512-Wb14jWEW8huH6It9F6sXd9vrYmIS7pMrgkU6sxpLxkP+9z+wRgs71hUEhRpcn8FOXAFa27FVWfY2tRpbfTzfLw==} 2067 + cpu: [x64] 2068 + os: [win32] 2069 + 1967 2070 '@oxc-transform/binding-android-arm-eabi@0.133.0': 1968 2071 resolution: {integrity: sha512-2A79NBpyBKgHJ0FwgC8D1hzp3x2ujyvqq/kG+M76YyDMMkxLhX6A3vjnAnfEKycOoZxuKhwYu8BF9hKq67ykIA==} 1969 2072 engines: {node: ^20.19.0 || >=22.12.0} ··· 4122 4225 fastq@1.20.1: 4123 4226 resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} 4124 4227 4228 + fd-package-json@2.0.0: 4229 + resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} 4230 + 4125 4231 fdir@6.5.0: 4126 4232 resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} 4127 4233 engines: {node: '>=12.0.0'} ··· 4173 4279 foreground-child@3.3.1: 4174 4280 resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} 4175 4281 engines: {node: '>=14'} 4282 + 4283 + formatly@0.3.0: 4284 + resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} 4285 + engines: {node: '>=18.3.0'} 4286 + hasBin: true 4176 4287 4177 4288 fraction.js@5.3.4: 4178 4289 resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} ··· 4539 4650 resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==} 4540 4651 engines: {node: '>= 8'} 4541 4652 4653 + knip@6.16.1: 4654 + resolution: {integrity: sha512-TKMn1rxgH6h9vXR9Y0B+Cq7AdPTr9EI02IwoT65NzqYUkvoDQAaJ/aPybiFpAhZ1px6cNYYwXf86iHkBgzCo9w==} 4655 + engines: {node: ^20.19.0 || >=22.12.0} 4656 + hasBin: true 4657 + 4542 4658 knitwork@1.3.0: 4543 4659 resolution: {integrity: sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==} 4544 4660 ··· 4977 5093 oxc-parser@0.134.0: 4978 5094 resolution: {integrity: sha512-Hs8fRG6A94BzMrMkGOtrUS7JQjmslfF+IvIXslf3QURzK3ud0QmFJRiYZjTe4TzAQnTfvlk4AwZnqIbrUjiE4w==} 4979 5095 engines: {node: ^20.19.0 || >=22.12.0} 5096 + 5097 + oxc-resolver@11.20.0: 5098 + resolution: {integrity: sha512-CblytBiV/a/ZXY34dsVU2NxhIOxMXst8CvDCtyBelVITgd7PLrKzbEbA6oKLdPjvDKDzCiW48qzmzZ+mYaqn+g==} 4980 5099 4981 5100 oxc-transform@0.133.0: 4982 5101 resolution: {integrity: sha512-9lt2b+hkG6yqe0fUDMHhMk7rgI9uTjNxU9wauQiYnHzc4kZI8JP/OhBqXTIJQTrqRJ8CkSH3O5AhQ13ke28yNg==} ··· 5507 5626 resolution: {integrity: sha512-KAkBqZl3c2GvNgNhcoyJae1aKldDW0LO279wF9bk1PnluRTETKBq0WyzRXxEhoQLk56yHaOY4JCBEKDuJIET5g==} 5508 5627 engines: {node: '>=20.0.0'} 5509 5628 5629 + smol-toml@1.6.1: 5630 + resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} 5631 + engines: {node: '>= 18'} 5632 + 5510 5633 source-map-js@1.2.1: 5511 5634 resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} 5512 5635 engines: {node: '>=0.10.0'} ··· 5576 5699 strip-final-newline@3.0.0: 5577 5700 resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} 5578 5701 engines: {node: '>=12'} 5702 + 5703 + strip-json-comments@5.0.3: 5704 + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} 5705 + engines: {node: '>=14.16'} 5579 5706 5580 5707 strip-literal@3.1.0: 5581 5708 resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} ··· 5715 5842 5716 5843 ultrahtml@1.6.0: 5717 5844 resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==} 5845 + 5846 + unbash@3.0.0: 5847 + resolution: {integrity: sha512-FeFPZ/WFT0mbRCuydiZzpPFlrYN8ZUpphQKoq4EeElVIYjYyGzPMxQR/simUwCOJIyVhpFk4RbtyO7RuMpMnHA==} 5848 + engines: {node: '>=14'} 5718 5849 5719 5850 unconfig-core@7.5.0: 5720 5851 resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} ··· 6049 6180 typescript: 6050 6181 optional: true 6051 6182 6183 + walk-up-path@4.0.0: 6184 + resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} 6185 + engines: {node: 20 || >=22} 6186 + 6052 6187 webidl-conversions@3.0.1: 6053 6188 resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} 6054 6189 ··· 6153 6288 6154 6289 zod@3.25.76: 6155 6290 resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} 6291 + 6292 + zod@4.4.3: 6293 + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} 6156 6294 6157 6295 snapshots: 6158 6296 ··· 7954 8092 7955 8093 '@oxc-project/types@0.134.0': {} 7956 8094 8095 + '@oxc-resolver/binding-android-arm-eabi@11.20.0': 8096 + optional: true 8097 + 8098 + '@oxc-resolver/binding-android-arm64@11.20.0': 8099 + optional: true 8100 + 8101 + '@oxc-resolver/binding-darwin-arm64@11.20.0': 8102 + optional: true 8103 + 8104 + '@oxc-resolver/binding-darwin-x64@11.20.0': 8105 + optional: true 8106 + 8107 + '@oxc-resolver/binding-freebsd-x64@11.20.0': 8108 + optional: true 8109 + 8110 + '@oxc-resolver/binding-linux-arm-gnueabihf@11.20.0': 8111 + optional: true 8112 + 8113 + '@oxc-resolver/binding-linux-arm-musleabihf@11.20.0': 8114 + optional: true 8115 + 8116 + '@oxc-resolver/binding-linux-arm64-gnu@11.20.0': 8117 + optional: true 8118 + 8119 + '@oxc-resolver/binding-linux-arm64-musl@11.20.0': 8120 + optional: true 8121 + 8122 + '@oxc-resolver/binding-linux-ppc64-gnu@11.20.0': 8123 + optional: true 8124 + 8125 + '@oxc-resolver/binding-linux-riscv64-gnu@11.20.0': 8126 + optional: true 8127 + 8128 + '@oxc-resolver/binding-linux-riscv64-musl@11.20.0': 8129 + optional: true 8130 + 8131 + '@oxc-resolver/binding-linux-s390x-gnu@11.20.0': 8132 + optional: true 8133 + 8134 + '@oxc-resolver/binding-linux-x64-gnu@11.20.0': 8135 + optional: true 8136 + 8137 + '@oxc-resolver/binding-linux-x64-musl@11.20.0': 8138 + optional: true 8139 + 8140 + '@oxc-resolver/binding-openharmony-arm64@11.20.0': 8141 + optional: true 8142 + 8143 + '@oxc-resolver/binding-wasm32-wasi@11.20.0': 8144 + dependencies: 8145 + '@emnapi/core': 1.10.0 8146 + '@emnapi/runtime': 1.10.0 8147 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) 8148 + optional: true 8149 + 8150 + '@oxc-resolver/binding-win32-arm64-msvc@11.20.0': 8151 + optional: true 8152 + 8153 + '@oxc-resolver/binding-win32-x64-msvc@11.20.0': 8154 + optional: true 8155 + 7957 8156 '@oxc-transform/binding-android-arm-eabi@0.133.0': 7958 8157 optional: true 7959 8158 ··· 9784 9983 dependencies: 9785 9984 reusify: 1.1.0 9786 9985 9986 + fd-package-json@2.0.0: 9987 + dependencies: 9988 + walk-up-path: 4.0.0 9989 + 9787 9990 fdir@6.5.0(picomatch@4.0.4): 9788 9991 optionalDependencies: 9789 9992 picomatch: 4.0.4 ··· 9866 10069 dependencies: 9867 10070 cross-spawn: 7.0.6 9868 10071 signal-exit: 4.1.0 10072 + 10073 + formatly@0.3.0: 10074 + dependencies: 10075 + fd-package-json: 2.0.0 9869 10076 9870 10077 fraction.js@5.3.4: {} 9871 10078 ··· 10221 10428 10222 10429 klona@2.0.6: {} 10223 10430 10431 + knip@6.16.1: 10432 + dependencies: 10433 + fdir: 6.5.0(picomatch@4.0.4) 10434 + formatly: 0.3.0 10435 + get-tsconfig: 4.14.0 10436 + jiti: 2.7.0 10437 + oxc-parser: 0.133.0 10438 + oxc-resolver: 11.20.0 10439 + picomatch: 4.0.4 10440 + smol-toml: 1.6.1 10441 + strip-json-comments: 5.0.3 10442 + tinyglobby: 0.2.17 10443 + unbash: 3.0.0 10444 + yaml: 2.9.0 10445 + zod: 4.4.3 10446 + 10224 10447 knitwork@1.3.0: {} 10225 10448 10226 10449 launch-editor@2.13.2: ··· 10555 10778 - supports-color 10556 10779 - uploadthing 10557 10780 10781 + 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): 10782 + dependencies: 10783 + '@cloudflare/kv-asset-handler': 0.4.2 10784 + '@rollup/plugin-alias': 6.0.0(rollup@4.60.2) 10785 + '@rollup/plugin-commonjs': 29.0.2(rollup@4.60.2) 10786 + '@rollup/plugin-inject': 5.0.5(rollup@4.60.2) 10787 + '@rollup/plugin-json': 6.1.0(rollup@4.60.2) 10788 + '@rollup/plugin-node-resolve': 16.0.3(rollup@4.60.2) 10789 + '@rollup/plugin-replace': 6.0.3(rollup@4.60.2) 10790 + '@rollup/plugin-terser': 1.0.0(rollup@4.60.2) 10791 + '@vercel/nft': 1.5.0(rollup@4.60.2) 10792 + archiver: 7.0.1 10793 + c12: 3.3.4(magicast@0.5.3) 10794 + chokidar: 5.0.0 10795 + citty: 0.2.2 10796 + compatx: 0.2.0 10797 + confbox: 0.2.4 10798 + consola: 3.4.2 10799 + cookie-es: 2.0.1 10800 + croner: 10.0.1 10801 + crossws: 0.3.5 10802 + 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)) 10803 + defu: 6.1.7 10804 + destr: 2.0.5 10805 + dot-prop: 10.1.0 10806 + esbuild: 0.28.0 10807 + escape-string-regexp: 5.0.0 10808 + etag: 1.8.1 10809 + exsolve: 1.0.8 10810 + globby: 16.2.0 10811 + gzip-size: 7.0.0 10812 + h3: 1.15.11 10813 + hookable: 5.5.3 10814 + httpxy: 0.5.1 10815 + ioredis: 5.10.1 10816 + jiti: 2.7.0 10817 + klona: 2.0.6 10818 + knitwork: 1.3.0 10819 + listhen: 1.10.0(srvx@0.11.15) 10820 + magic-string: 0.30.21 10821 + magicast: 0.5.3 10822 + mime: 4.1.0 10823 + mlly: 1.8.2 10824 + node-fetch-native: 1.6.7 10825 + node-mock-http: 1.0.4 10826 + ofetch: 1.5.1 10827 + ohash: 2.0.11 10828 + pathe: 2.0.3 10829 + perfect-debounce: 2.1.0 10830 + pkg-types: 2.3.1 10831 + pretty-bytes: 7.1.0 10832 + radix3: 1.1.2 10833 + rollup: 4.60.2 10834 + rollup-plugin-visualizer: 7.0.1(rolldown@1.0.0-rc.18)(rollup@4.60.2) 10835 + scule: 1.3.0 10836 + semver: 7.8.4 10837 + serve-placeholder: 2.0.2 10838 + serve-static: 2.2.1 10839 + source-map: 0.7.6 10840 + std-env: 4.1.0 10841 + ufo: 1.6.4 10842 + ultrahtml: 1.6.0 10843 + uncrypto: 0.1.3 10844 + unctx: 2.5.0 10845 + unenv: 2.0.0-rc.24 10846 + unimport: 6.3.0(oxc-parser@0.134.0)(rolldown@1.0.0-rc.18) 10847 + unplugin-utils: 0.3.1 10848 + 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) 10849 + untyped: 2.0.0 10850 + unwasm: 0.5.3 10851 + youch: 4.1.1 10852 + youch-core: 0.3.3 10853 + transitivePeerDependencies: 10854 + - '@azure/app-configuration' 10855 + - '@azure/cosmos' 10856 + - '@azure/data-tables' 10857 + - '@azure/identity' 10858 + - '@azure/keyvault-secrets' 10859 + - '@azure/storage-blob' 10860 + - '@capacitor/preferences' 10861 + - '@deno/kv' 10862 + - '@electric-sql/pglite' 10863 + - '@libsql/client' 10864 + - '@netlify/blobs' 10865 + - '@planetscale/database' 10866 + - '@upstash/redis' 10867 + - '@vercel/blob' 10868 + - '@vercel/functions' 10869 + - '@vercel/kv' 10870 + - aws4fetch 10871 + - bare-abort-controller 10872 + - bare-buffer 10873 + - better-sqlite3 10874 + - drizzle-orm 10875 + - encoding 10876 + - idb-keyval 10877 + - mysql2 10878 + - oxc-parser 10879 + - react-native-b4a 10880 + - rolldown 10881 + - sqlite3 10882 + - srvx 10883 + - supports-color 10884 + - uploadthing 10885 + optional: true 10886 + 10558 10887 node-addon-api@7.1.1: {} 10559 10888 10560 10889 node-fetch-native@1.6.7: {} ··· 10594 10923 dependencies: 10595 10924 boolbase: 1.0.0 10596 10925 10597 - nuxt-og-image@6.5.3(7c45f4c9f7a120d9de0460f4082612ea): 10926 + nuxt-og-image@6.5.3(778382f6defca828f628d461f7cab343): 10598 10927 dependencies: 10599 10928 '@clack/prompts': 1.5.1 10600 10929 '@nuxt/kit': 4.4.8(magicast@0.5.3) ··· 10612 10941 magic-string: 0.30.21 10613 10942 magicast: 0.5.3 10614 10943 mocked-exports: 0.1.1 10615 - nuxt-site-config: 4.0.8(d03ab0b60595d2cc94d63040f8689603) 10616 - nuxtseo-shared: 5.2.5(7f88583bab06a429a9cb0767d714ae42) 10944 + nuxt-site-config: 4.0.8(4a76482caed7f2aef5522e947723b6aa) 10945 + nuxtseo-shared: 5.2.5(8f0621ef11216476fa76fba6dad1aaac) 10617 10946 nypm: 0.6.6 10618 10947 ofetch: 1.5.1 10619 10948 ohash: 2.0.11 ··· 10634 10963 '@resvg/resvg-js': 2.6.2 10635 10964 '@resvg/resvg-wasm': 2.6.2 10636 10965 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)) 10637 - 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) 10966 + 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) 10638 10967 playwright-core: 1.59.1 10639 10968 sharp: 0.34.5 10640 10969 unifont: 0.7.4 10641 - zod: 3.25.76 10970 + zod: 4.4.3 10642 10971 transitivePeerDependencies: 10643 10972 - '@nuxt/schema' 10644 10973 - nuxt ··· 10657 10986 - magicast 10658 10987 - vue 10659 10988 10660 - nuxt-site-config@4.0.8(d03ab0b60595d2cc94d63040f8689603): 10989 + nuxt-site-config@4.0.8(4a76482caed7f2aef5522e947723b6aa): 10661 10990 dependencies: 10662 10991 '@nuxt/kit': 4.4.8(magicast@0.5.3) 10663 10992 h3: 1.15.11 10664 10993 nuxt-site-config-kit: 4.0.8(magicast@0.5.3)(vue@3.5.33(typescript@6.0.3)) 10665 - nuxtseo-shared: 5.2.5(7f88583bab06a429a9cb0767d714ae42) 10994 + nuxtseo-shared: 5.2.5(8f0621ef11216476fa76fba6dad1aaac) 10666 10995 pathe: 2.0.3 10667 10996 pkg-types: 2.3.1 10668 10997 site-config-stack: 4.0.8(vue@3.5.33(typescript@6.0.3)) ··· 10809 11138 - xml2js 10810 11139 - yaml 10811 11140 10812 - nuxtseo-shared@5.2.5(7f88583bab06a429a9cb0767d714ae42): 11141 + nuxtseo-shared@5.2.5(8f0621ef11216476fa76fba6dad1aaac): 10813 11142 dependencies: 10814 11143 '@clack/prompts': 1.5.1 10815 11144 '@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 11157 ufo: 1.6.4 10829 11158 vue: 3.5.33(typescript@6.0.3) 10830 11159 optionalDependencies: 10831 - nuxt-site-config: 4.0.8(d03ab0b60595d2cc94d63040f8689603) 10832 - zod: 3.25.76 11160 + nuxt-site-config: 4.0.8(4a76482caed7f2aef5522e947723b6aa) 11161 + zod: 4.4.3 10833 11162 transitivePeerDependencies: 10834 11163 - magicast 10835 11164 - vite ··· 10960 11289 '@oxc-parser/binding-win32-ia32-msvc': 0.134.0 10961 11290 '@oxc-parser/binding-win32-x64-msvc': 0.134.0 10962 11291 11292 + oxc-resolver@11.20.0: 11293 + optionalDependencies: 11294 + '@oxc-resolver/binding-android-arm-eabi': 11.20.0 11295 + '@oxc-resolver/binding-android-arm64': 11.20.0 11296 + '@oxc-resolver/binding-darwin-arm64': 11.20.0 11297 + '@oxc-resolver/binding-darwin-x64': 11.20.0 11298 + '@oxc-resolver/binding-freebsd-x64': 11.20.0 11299 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.20.0 11300 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.20.0 11301 + '@oxc-resolver/binding-linux-arm64-gnu': 11.20.0 11302 + '@oxc-resolver/binding-linux-arm64-musl': 11.20.0 11303 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.20.0 11304 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.20.0 11305 + '@oxc-resolver/binding-linux-riscv64-musl': 11.20.0 11306 + '@oxc-resolver/binding-linux-s390x-gnu': 11.20.0 11307 + '@oxc-resolver/binding-linux-x64-gnu': 11.20.0 11308 + '@oxc-resolver/binding-linux-x64-musl': 11.20.0 11309 + '@oxc-resolver/binding-openharmony-arm64': 11.20.0 11310 + '@oxc-resolver/binding-wasm32-wasi': 11.20.0 11311 + '@oxc-resolver/binding-win32-arm64-msvc': 11.20.0 11312 + '@oxc-resolver/binding-win32-x64-msvc': 11.20.0 11313 + 10963 11314 oxc-transform@0.133.0: 10964 11315 optionalDependencies: 10965 11316 '@oxc-transform/binding-android-arm-eabi': 0.133.0 ··· 11570 11921 11571 11922 smob@1.6.1: {} 11572 11923 11924 + smol-toml@1.6.1: {} 11925 + 11573 11926 source-map-js@1.2.1: {} 11574 11927 11575 11928 source-map-support@0.5.21: ··· 11643 11996 ansi-regex: 6.2.2 11644 11997 11645 11998 strip-final-newline@3.0.0: {} 11999 + 12000 + strip-json-comments@5.0.3: {} 11646 12001 11647 12002 strip-literal@3.1.0: 11648 12003 dependencies: ··· 11778 12133 multiformats: 9.9.0 11779 12134 11780 12135 ultrahtml@1.6.0: {} 12136 + 12137 + unbash@3.0.0: {} 11781 12138 11782 12139 unconfig-core@7.5.0: 11783 12140 dependencies: ··· 11847 12204 oxc-parser: 0.133.0 11848 12205 rolldown: 1.0.0-rc.18 11849 12206 12207 + unimport@6.3.0(oxc-parser@0.134.0)(rolldown@1.0.0-rc.18): 12208 + dependencies: 12209 + acorn: 8.16.0 12210 + escape-string-regexp: 5.0.0 12211 + estree-walker: 3.0.3 12212 + local-pkg: 1.1.2 12213 + magic-string: 0.30.21 12214 + mlly: 1.8.2 12215 + pathe: 2.0.3 12216 + picomatch: 4.0.4 12217 + pkg-types: 2.3.1 12218 + scule: 1.3.0 12219 + strip-literal: 3.1.0 12220 + tinyglobby: 0.2.17 12221 + unplugin: 3.0.0 12222 + unplugin-utils: 0.3.1 12223 + optionalDependencies: 12224 + oxc-parser: 0.134.0 12225 + rolldown: 1.0.0-rc.18 12226 + optional: true 12227 + 11850 12228 universal-github-app-jwt@2.2.2: {} 11851 12229 11852 12230 universal-user-agent@7.0.3: {} ··· 12177 12555 optionalDependencies: 12178 12556 typescript: 6.0.3 12179 12557 12558 + walk-up-path@4.0.0: {} 12559 + 12180 12560 webidl-conversions@3.0.1: {} 12181 12561 12182 12562 webpack-virtual-modules@0.6.2: {} ··· 12274 12654 readable-stream: 4.7.0 12275 12655 12276 12656 zod@3.25.76: {} 12657 + 12658 + zod@4.4.3: {}
+2 -6
server/api/repos/[id]/disable.post.ts
··· 1 1 import { and, eq } from 'drizzle-orm' 2 2 import { repoMapping } from '#server/db/schema' 3 3 import { useDb } from '#server/utils/db' 4 - import { requireSession } from '#server/utils/server-session' 4 + import { requireSessionAndMappingId } from '#server/utils/repo-route' 5 5 6 6 /** 7 7 * Pause sync for one mapping. The worker checks `disabledAt` on every push ··· 9 9 * the prior state. 10 10 */ 11 11 export default defineEventHandler(async event => { 12 - const session = await requireSession(event) 13 - const mappingId = Number(getRouterParam(event, 'id')) 14 - if (!Number.isFinite(mappingId)) { 15 - throw createError({ statusCode: 400, statusMessage: 'invalid mapping id' }) 16 - } 12 + const { session, mappingId } = await requireSessionAndMappingId(event) 17 13 18 14 const db = useDb() 19 15 const updated = await db.update(repoMapping)
+2 -6
server/api/repos/[id]/enable.post.ts
··· 1 1 import { and, eq } from 'drizzle-orm' 2 2 import { repoMapping } from '#server/db/schema' 3 3 import { useDb } from '#server/utils/db' 4 - import { requireSession } from '#server/utils/server-session' 4 + import { requireSessionAndMappingId } from '#server/utils/repo-route' 5 5 6 6 /** Clear `disabledAt`, resuming sync for this mapping. */ 7 7 export default defineEventHandler(async event => { 8 - const session = await requireSession(event) 9 - const mappingId = Number(getRouterParam(event, 'id')) 10 - if (!Number.isFinite(mappingId)) { 11 - throw createError({ statusCode: 400, statusMessage: 'invalid mapping id' }) 12 - } 8 + const { session, mappingId } = await requireSessionAndMappingId(event) 13 9 14 10 const db = useDb() 15 11 const updated = await db.update(repoMapping)
+2 -6
server/api/repos/[id]/resync.post.ts
··· 2 2 import { repoMapping } from '#server/db/schema' 3 3 import { useDb } from '#server/utils/db' 4 4 import { enqueue } from '#server/utils/queue' 5 - import { requireSession } from '#server/utils/server-session' 5 + import { requireSessionAndMappingId } from '#server/utils/repo-route' 6 6 7 7 /** 8 8 * Enqueue a forced `tangled.create-repo` job for one mapping. The handler ··· 10 10 * flag tells it to re-run the enrolment flow. 11 11 */ 12 12 export default defineEventHandler(async event => { 13 - const session = await requireSession(event) 14 - const mappingId = Number(getRouterParam(event, 'id')) 15 - if (!Number.isFinite(mappingId)) { 16 - throw createError({ statusCode: 400, statusMessage: 'invalid mapping id' }) 17 - } 13 + const { session, mappingId } = await requireSessionAndMappingId(event) 18 14 19 15 const db = useDb() 20 16 const rows = await db.select({
+4 -5
server/db/schema.ts
··· 130 130 check('webhook_event_source_chk', sql`${table.source} in ('github','tangled')`), 131 131 ]) 132 132 133 - // AT Protocol OAuth stores. The values are encrypted at rest (libsodium sealed 134 - // box) because they contain access tokens, refresh tokens, and the DPoP private 135 - // key for the user's PDS. The encryption layer wraps the OAuth library's store 136 - // interface (encrypt in set, decrypt in get); see commit 9 (`feat: generate 137 - // per-install ssh key and publish publickey record`) for the shared helper. 133 + // AT Protocol OAuth stores. The values are encrypted at rest because they 134 + // contain access tokens, refresh tokens, and the DPoP private key for the 135 + // user's PDS. The encryption layer wraps the OAuth library's store interface 136 + // (encrypt in set, decrypt in get). 138 137 export const atprotoState = pgTable('atproto_state', { 139 138 key: text('key').primaryKey(), 140 139 valueCiphertext: bytea('value_ciphertext').notNull(),
+1 -1
server/utils/encryption.ts
··· 7 7 * before it lands in the DB: AT Proto session blobs, SSH private keys. 8 8 * 9 9 * The KEK is held only in env. If it's lost, every encrypted row becomes 10 - * unreadable. KEK rotation is a future concern \u2014 see PLAN.md. 10 + * unreadable. KEK rotation is a future concern; see PLAN.md. 11 11 */ 12 12 const NONCE_BYTES = 24 13 13 let cachedKey: Uint8Array | undefined
+6
server/utils/github-app.ts
··· 32 32 return app.getInstallationOctokit(installationId) 33 33 } 34 34 35 + /** Mint a short-lived installation access token for authenticating git fetches. */ 36 + export async function installationToken(octokit: InstallationOctokit): Promise<string> { 37 + const { token } = (await octokit.auth({ type: 'installation' })) as { token: string } 38 + return token 39 + } 40 + 35 41 function requireOAuthApp(): App { 36 42 if (!process.env.NUXT_GITHUB_APP_CLIENT_ID || !process.env.NUXT_GITHUB_APP_CLIENT_SECRET) { 37 43 throw createError({
+18 -33
server/utils/job-handlers.ts
··· 1 + import type { OAuthSession } from '@atproto/oauth-client-node' 1 2 import { and, eq, sql } from 'drizzle-orm' 2 3 import { repoMapping, userIdentity } from '../db/schema' 3 4 import { useOAuthClient } from './atproto-oauth' ··· 10 11 import { generateAndPublishKey, rotateKey } from './tangled-pubkey' 11 12 import { enrollRepo, syncRepoMetadata } from './tangled-repo' 12 13 13 - /** 14 - * Map of job kind → handler. Each commit fills in its slice: 15 - * - 'github.push' → commit 12 (sync push events) 16 - * - 'github.create' / 'github.delete' → this commit (branch/tag ref ops) 17 - * - 'github.repository' → metadata sync + lifecycle (edited, 18 - * renamed, privatized, publicized, 19 - * transferred, deleted) 20 - * - 'github.installation_repositories' → commit 10 (fan-out enrolment) 21 - * - 'tangled.backfill-installation' → commit 10 (paginate + fan-out) 22 - * - 'tangled.create-repo' → commit 10 (per-repo enrolment) 23 - * - 'atproto.publish-pubkey' → commit 9 24 - * 25 - * Unknown kinds throw so they surface as job failures rather than silent 26 - * acknowledgement. 27 - */ 14 + /** Job kinds `dispatch` understands; anything else throws as a job failure. */ 28 15 const KNOWN_KINDS = new Set([ 29 16 'github.push', 30 17 'github.create', ··· 220 207 221 208 if (envelope.kind === 'tangled.create-repo') { 222 209 const { installationId, githubRepoId, force } = createRepoPayload(envelope.payload) 223 - 224 - // Find the user identity bound to this install. If OAuth hasn't completed 225 - // yet, drop this job silently \u2014 OAuth callback re-enqueues for all 226 - // accessible repos at completion time, so we'll get a fresh trigger. 227 - const db = useDb() 228 - const identity = await db.select({ did: userIdentity.did }) 229 - .from(userIdentity) 230 - .where(sql`${userIdentity.installationId} = ${installationId}`) 231 - if (identity.length === 0) return 232 - 233 - const client = await useOAuthClient() 234 - const session = await client.restore(identity[0]!.did) 210 + const session = await restoreSessionForInstallation(installationId) 211 + if (!session) return 235 212 await enrollRepo({ oauthSession: session, installationId, githubRepoId, force }) 236 213 return 237 214 } ··· 295 272 await handleRepositoryEvent(repositoryPayload(envelope.payload)) 296 273 return 297 274 } 298 - 299 - // Other kinds: still no-op until handlers land in their commits. 300 275 } 301 276 302 277 async function handleRepositoryEvent(payload: RepositoryPayload): Promise<void> { ··· 388 363 } 389 364 390 365 async function runMetadataSync(installationId: number, githubRepoId: number): Promise<void> { 366 + const session = await restoreSessionForInstallation(installationId) 367 + if (!session) return 368 + await syncRepoMetadata({ oauthSession: session, installationId, githubRepoId }) 369 + } 370 + 371 + /** 372 + * Restore the OAuth session for the user identity bound to `installationId`, 373 + * or null if OAuth hasn't completed yet. The OAuth callback re-enqueues work 374 + * for all accessible repos on completion, so a null here is a benign drop: a 375 + * fresh trigger arrives once the identity exists. 376 + */ 377 + async function restoreSessionForInstallation(installationId: number): Promise<OAuthSession | null> { 391 378 const db = useDb() 392 379 const identity = await db.select({ did: userIdentity.did }) 393 380 .from(userIdentity) 394 381 .where(sql`${userIdentity.installationId} = ${installationId}`) 395 - // No tangled identity yet — OAuth callback will backfill on completion. 396 - if (identity.length === 0) return 382 + if (identity.length === 0) return null 397 383 398 384 const client = await useOAuthClient() 399 - const session = await client.restore(identity[0]!.did) 400 - await syncRepoMetadata({ oauthSession: session, installationId, githubRepoId }) 385 + return client.restore(identity[0]!.did) 401 386 }
+101
server/utils/repo-mapping.ts
··· 1 + import { and, eq, sql } from 'drizzle-orm' 2 + import { repoMapping } from '../db/schema' 3 + import { useDb } from './db' 4 + import { RemoteRejectedError } from './git-wire/errors' 5 + 6 + export interface ActiveMapping { 7 + id: number 8 + githubFullName: string 9 + tangledRepoDid: string 10 + knot: string 11 + lastSyncedRefs: Record<string, string> 12 + } 13 + 14 + export type SkipReason = 'no-mapping' | 'disabled' 15 + 16 + /** 17 + * Load the `repo_mapping` row for `(installationId, githubRepoId)` and confirm 18 + * it's ready to sync. Returns `{ skip }` when the row is missing, disabled, or 19 + * hasn't completed enrolment (no `tangledRepoDid`/`knot` yet). 20 + */ 21 + export async function loadActiveMapping( 22 + installationId: number, 23 + githubRepoId: number, 24 + ): Promise<{ mapping: ActiveMapping } | { skip: SkipReason }> { 25 + const db = useDb() 26 + const rows = await db.select().from(repoMapping).where( 27 + and( 28 + eq(repoMapping.installationId, installationId), 29 + eq(repoMapping.githubRepoId, githubRepoId), 30 + ), 31 + ).limit(1) 32 + 33 + if (rows.length === 0) return { skip: 'no-mapping' } 34 + const row = rows[0]! 35 + 36 + if (row.disabledAt) return { skip: 'disabled' } 37 + if (!row.tangledRepoDid || !row.knot) return { skip: 'no-mapping' } 38 + 39 + return { 40 + mapping: { 41 + id: row.id, 42 + githubFullName: row.githubFullName, 43 + tangledRepoDid: row.tangledRepoDid, 44 + knot: row.knot, 45 + // eslint-disable-next-line ts/no-unsafe-type-assertion -- jsonb column is typed `unknown` 46 + lastSyncedRefs: (row.lastSyncedRefs ?? {}) as Record<string, string>, 47 + }, 48 + } 49 + } 50 + 51 + /** Record the synced tip for one ref in the `lastSyncedRefs` jsonb map. */ 52 + export async function setLastSyncedRef(mappingId: number, fullRef: string, sha: string): Promise<void> { 53 + const db = useDb() 54 + await db.update(repoMapping) 55 + .set({ 56 + lastSyncedRefs: sql`jsonb_set(${repoMapping.lastSyncedRefs}, ${`{${jsonbPathElement(fullRef)}}`}::text[], ${`"${sha}"`}::jsonb, true)`, 57 + updatedAt: new Date(), 58 + }) 59 + .where(eq(repoMapping.id, mappingId)) 60 + } 61 + 62 + /** Drop one ref from the `lastSyncedRefs` jsonb map. No-op if absent. */ 63 + export async function clearLastSyncedRef(mappingId: number, fullRef: string): Promise<void> { 64 + const db = useDb() 65 + await db.update(repoMapping) 66 + .set({ 67 + lastSyncedRefs: sql`${repoMapping.lastSyncedRefs} - ${fullRef}`, 68 + updatedAt: new Date(), 69 + }) 70 + .where(eq(repoMapping.id, mappingId)) 71 + } 72 + 73 + /** Mark a mapping `status='error'` so the worker stops retrying. */ 74 + export async function markMappingError(mappingId: number, message: string): Promise<void> { 75 + const db = useDb() 76 + await db.update(repoMapping) 77 + .set({ status: 'error', lastError: message, updatedAt: new Date() }) 78 + .where(eq(repoMapping.id, mappingId)) 79 + } 80 + 81 + /** Human-readable `lastError` text for a terminal knot rejection. */ 82 + export function terminalRejectionMessage(err: RemoteRejectedError): string { 83 + if (err.reason === 'too-big') return `pack exceeded the configured size limit; stopping sync (${err.message})` 84 + if (err.reason === 'auth-rejected') return 'knot rejected our ssh key; stopping sync' 85 + return 'knot reports repo no longer exists; stopping sync' 86 + } 87 + 88 + /** 89 + * True for knot rejections we treat as terminal: the repo is gone, our key is 90 + * rejected, or the pack blew the size cap. Callers mark the mapping `error` 91 + * and stop retrying; anything else re-throws for the queue's backoff. 92 + */ 93 + export function isTerminalRejection(err: unknown): err is RemoteRejectedError { 94 + return err instanceof RemoteRejectedError 95 + && (err.reason === 'repo-gone' || err.reason === 'auth-rejected' || err.reason === 'too-big') 96 + } 97 + 98 + /** jsonb path array element for a ref, escaping embedded quotes. */ 99 + function jsonbPathElement(ref: string): string { 100 + return `"${ref.replaceAll('"', '\\"')}"` 101 + }
+20
server/utils/repo-route.ts
··· 1 + import type { H3Event } from 'h3' 2 + import type { SynchubAccount } from './server-session' 3 + import { requireSession } from './server-session' 4 + 5 + /** 6 + * Shared preamble for `/api/repos/[id]/*` handlers: require an authenticated 7 + * session and parse the `:id` route param to a mapping id, throwing a 400 if 8 + * it isn't a finite number. Ownership is enforced downstream by scoping the 9 + * query to `session.installationId`. 10 + */ 11 + export async function requireSessionAndMappingId( 12 + event: H3Event, 13 + ): Promise<{ session: SynchubAccount, mappingId: number }> { 14 + const session = await requireSession(event) 15 + const mappingId = Number(getRouterParam(event, 'id')) 16 + if (!Number.isFinite(mappingId)) { 17 + throw createError({ statusCode: 400, statusMessage: 'invalid mapping id' }) 18 + } 19 + return { session, mappingId } 20 + }
+1 -1
server/utils/sync-push-host.ts
··· 7 7 * 8 8 * The official UI does this same mapping in 9 9 * `appview/pages/templates/repo/empty.html`. If tangled adds more 10 - * appview-hosted knots in future this'll need updating \u2014 see PLAN.md 10 + * appview-hosted knots in future this'll need updating; see PLAN.md 11 11 * "Deferred / follow-ups". 12 12 */ 13 13 export function sshHostForKnot(knot: string): string {
+15 -51
server/utils/sync-push.ts
··· 1 - import { and, eq, sql } from 'drizzle-orm' 2 - import { repoMapping } from '../db/schema' 3 - import { useDb } from './db' 4 - import { RemoteRejectedError } from './git-wire/errors' 5 - import { installationOctokit } from './github-app' 1 + import { installationOctokit, installationToken } from './github-app' 2 + import { isTerminalRejection, loadActiveMapping, markMappingError, setLastSyncedRef, terminalRejectionMessage } from './repo-mapping' 6 3 import { splicePush } from './splice' 7 4 8 5 const ZERO_SHA = '0000000000000000000000000000000000000000' ··· 40 37 * the queue retries with backoff; the retry re-reads the knot's tip. 41 38 */ 42 39 export async function syncPush(payload: PushPayload): Promise<PushResult> { 43 - const db = useDb() 44 - 45 - const mapping = await db.select().from(repoMapping).where( 46 - and( 47 - eq(repoMapping.installationId, payload.installationId), 48 - eq(repoMapping.githubRepoId, payload.githubRepoId), 49 - ), 50 - ).limit(1) 51 - if (mapping.length === 0) return { status: 'skipped', reason: 'no-mapping' } 52 - const row = mapping[0]! 53 - 54 - if (row.disabledAt) return { status: 'skipped', reason: 'disabled' } 55 - if (!row.tangledRepoDid || !row.knot) return { status: 'skipped', reason: 'no-mapping' } 40 + const loaded = await loadActiveMapping(payload.installationId, payload.githubRepoId) 41 + if ('skip' in loaded) return { status: 'skipped', reason: loaded.skip } 42 + const { mapping } = loaded 56 43 57 44 if (payload.after === ZERO_SHA) return { status: 'skipped', reason: 'deletion' } 58 45 59 - const lastSynced = (row.lastSyncedRefs as Record<string, string>)[payload.ref] 60 - if (lastSynced === payload.after) return { status: 'skipped', reason: 'already-synced' } 46 + if (mapping.lastSyncedRefs[payload.ref] === payload.after) { 47 + return { status: 'skipped', reason: 'already-synced' } 48 + } 61 49 62 50 const octokit = await installationOctokit(payload.installationId) 63 - const { token } = (await octokit.auth({ type: 'installation' })) as { token: string } 51 + const token = await installationToken(octokit) 64 52 65 53 try { 66 54 const result = await splicePush({ 67 55 installationId: payload.installationId, 68 - repoFullName: row.githubFullName, 69 - knot: row.knot, 70 - repoDid: row.tangledRepoDid, 56 + repoFullName: mapping.githubFullName, 57 + knot: mapping.knot, 58 + repoDid: mapping.tangledRepoDid, 71 59 ref: payload.ref, 72 60 want: payload.after, 73 61 token, 74 62 }) 75 63 76 - await db.update(repoMapping) 77 - .set({ 78 - lastSyncedRefs: sql`jsonb_set(${repoMapping.lastSyncedRefs}, ${`{${jsonbPath(payload.ref)}}`}::text[], ${`"${result.sha}"`}::jsonb, true)`, 79 - updatedAt: new Date(), 80 - }) 81 - .where(eq(repoMapping.id, row.id)) 82 - 64 + await setLastSyncedRef(mapping.id, payload.ref, result.sha) 83 65 return { status: 'synced' } 84 66 } 85 67 catch (err) { 86 - if (err instanceof RemoteRejectedError && (err.reason === 'repo-gone' || err.reason === 'auth-rejected' || err.reason === 'too-big')) { 87 - await markMappingError(row.id, terminalMessage(err)) 68 + if (isTerminalRejection(err)) { 69 + await markMappingError(mapping.id, terminalRejectionMessage(err)) 88 70 return { status: 'skipped', reason: 'repo-gone' } 89 71 } 90 72 throw err 91 73 } 92 74 } 93 - 94 - function terminalMessage(err: RemoteRejectedError): string { 95 - if (err.reason === 'too-big') return `pack exceeded the configured size limit; stopping sync (${err.message})` 96 - if (err.reason === 'auth-rejected') return 'knot rejected our ssh key; stopping sync' 97 - return 'knot reports repo no longer exists; stopping sync' 98 - } 99 - 100 - /** jsonb_set path argument: `refs/heads/main` becomes a single text array element. */ 101 - function jsonbPath(ref: string): string { 102 - return `"${ref.replaceAll('"', '\\"')}"` 103 - } 104 - 105 - async function markMappingError(mappingId: number, message: string): Promise<void> { 106 - const db = useDb() 107 - await db.update(repoMapping) 108 - .set({ status: 'error', lastError: message, updatedAt: new Date() }) 109 - .where(eq(repoMapping.id, mappingId)) 110 - }
+11 -73
server/utils/sync-ref.ts
··· 1 - import { and, eq, sql } from 'drizzle-orm' 2 - import { repoMapping } from '../db/schema' 3 - import { useDb } from './db' 4 1 import { RemoteRejectedError, WireError } from './git-wire/errors' 5 2 import { fetchAdvertisement } from './git-wire/upload-pack' 6 - import { installationOctokit } from './github-app' 3 + import { installationOctokit, installationToken } from './github-app' 4 + import { clearLastSyncedRef, isTerminalRejection, loadActiveMapping, markMappingError, setLastSyncedRef } from './repo-mapping' 7 5 import { spliceDelete, splicePush } from './splice' 8 6 9 7 export type RefType = 'branch' | 'tag' ··· 42 40 return { status: 'skipped', reason: 'not-branch-or-tag' } 43 41 } 44 42 45 - const mapping = await loadActiveMapping(payload.installationId, payload.githubRepoId) 46 - if ('skip' in mapping) return mapping.skip 43 + const loaded = await loadActiveMapping(payload.installationId, payload.githubRepoId) 44 + if ('skip' in loaded) return { status: 'skipped', reason: loaded.skip } 45 + const { mapping } = loaded 47 46 48 47 const fullRef = qualifyRef(payload.refType, payload.ref) 49 48 50 49 const octokit = await installationOctokit(payload.installationId) 51 - const { token } = (await octokit.auth({ type: 'installation' })) as { token: string } 50 + const token = await installationToken(octokit) 52 51 53 52 const adv = await fetchAdvertisement(mapping.githubFullName, token) 54 53 const want = adv.refs.get(fullRef) ··· 68 67 want, 69 68 token, 70 69 }) 71 - await updateLastSyncedRef(mapping.id, fullRef, result.sha) 70 + await setLastSyncedRef(mapping.id, fullRef, result.sha) 72 71 return { status: 'synced' } 73 72 } 74 73 catch (err) { 75 - if (err instanceof RemoteRejectedError && (err.reason === 'repo-gone' || err.reason === 'auth-rejected' || err.reason === 'too-big')) { 74 + if (isTerminalRejection(err)) { 76 75 await markMappingError(mapping.id, 'knot reports repo no longer exists; stopping sync') 77 76 return { status: 'skipped', reason: 'repo-gone' } 78 77 } ··· 96 95 return { status: 'skipped', reason: 'not-branch-or-tag' } 97 96 } 98 97 99 - const mapping = await loadActiveMapping(payload.installationId, payload.githubRepoId) 100 - if ('skip' in mapping) return mapping.skip 98 + const loaded = await loadActiveMapping(payload.installationId, payload.githubRepoId) 99 + if ('skip' in loaded) return { status: 'skipped', reason: loaded.skip } 100 + const { mapping } = loaded 101 101 102 102 const fullRef = qualifyRef(payload.refType, payload.ref) 103 103 ··· 123 123 function qualifyRef(refType: RefType, ref: string): string { 124 124 return refType === 'tag' ? `refs/tags/${ref}` : `refs/heads/${ref}` 125 125 } 126 - 127 - type ActiveMapping = { 128 - id: number 129 - githubFullName: string 130 - tangledRepoDid: string 131 - knot: string 132 - } | { skip: RefResult } 133 - 134 - async function loadActiveMapping(installationId: number, githubRepoId: number): Promise<ActiveMapping> { 135 - const db = useDb() 136 - const rows = await db.select().from(repoMapping).where( 137 - and( 138 - eq(repoMapping.installationId, installationId), 139 - eq(repoMapping.githubRepoId, githubRepoId), 140 - ), 141 - ).limit(1) 142 - 143 - if (rows.length === 0) return { skip: { status: 'skipped', reason: 'no-mapping' } } 144 - const row = rows[0]! 145 - 146 - if (row.disabledAt) return { skip: { status: 'skipped', reason: 'disabled' } } 147 - if (!row.tangledRepoDid || !row.knot) return { skip: { status: 'skipped', reason: 'no-mapping' } } 148 - 149 - return { 150 - id: row.id, 151 - githubFullName: row.githubFullName, 152 - tangledRepoDid: row.tangledRepoDid, 153 - knot: row.knot, 154 - } 155 - } 156 - 157 - async function updateLastSyncedRef(mappingId: number, fullRef: string, sha: string): Promise<void> { 158 - const db = useDb() 159 - await db.update(repoMapping) 160 - .set({ 161 - lastSyncedRefs: sql`jsonb_set(${repoMapping.lastSyncedRefs}, ${`{${jsonbPath(fullRef)}}`}::text[], ${`"${sha}"`}::jsonb, true)`, 162 - updatedAt: new Date(), 163 - }) 164 - .where(eq(repoMapping.id, mappingId)) 165 - } 166 - 167 - async function clearLastSyncedRef(mappingId: number, fullRef: string): Promise<void> { 168 - const db = useDb() 169 - // jsonb minus text removes a top-level key. Safe no-op if absent. 170 - await db.update(repoMapping) 171 - .set({ 172 - lastSyncedRefs: sql`${repoMapping.lastSyncedRefs} - ${fullRef}`, 173 - updatedAt: new Date(), 174 - }) 175 - .where(eq(repoMapping.id, mappingId)) 176 - } 177 - 178 - async function markMappingError(mappingId: number, message: string): Promise<void> { 179 - const db = useDb() 180 - await db.update(repoMapping) 181 - .set({ status: 'error', lastError: message, updatedAt: new Date() }) 182 - .where(eq(repoMapping.id, mappingId)) 183 - } 184 - 185 - function jsonbPath(ref: string): string { 186 - return `"${ref.replaceAll('"', '\\"')}"` 187 - }
+46 -61
server/utils/tangled-pubkey.ts
··· 9 9 10 10 const PUBKEY_LEXICON = 'sh.tangled.publicKey' 11 11 12 + /** HTTP status off an unknown thrown value, or undefined if it has none. */ 13 + function errorStatus(err: unknown): number | undefined { 14 + return err && typeof err === 'object' && 'status' in err && typeof err.status === 'number' 15 + ? err.status 16 + : undefined 17 + } 18 + 19 + /** 20 + * Delete one `sh.tangled.publicKey` record from `did`'s PDS, treating an 21 + * already-gone record (404) as success. Any other PDS error re-throws. 22 + */ 23 + async function deletePubkeyRecord(session: OAuthSession, did: string, rkey: string): Promise<void> { 24 + const agent = new Agent(session) 25 + try { 26 + await agent.com.atproto.repo.deleteRecord({ repo: did, collection: PUBKEY_LEXICON, rkey }) 27 + } 28 + catch (err) { 29 + if (errorStatus(err) !== 404) throw err 30 + } 31 + } 32 + 33 + /** 34 + * Restore `did`'s OAuth session and delete one `sh.tangled.publicKey` record, 35 + * never throwing: a 404 is already-gone, and any other failure (including a 36 + * session that can no longer be restored) is logged so the caller's cleanup 37 + * proceeds. 38 + */ 39 + async function bestEffortRevoke(did: string, rkey: string, installationId: number): Promise<void> { 40 + const client = await useOAuthClient() 41 + try { 42 + const session = await client.restore(did) 43 + await deletePubkeyRecord(session, did, rkey) 44 + } 45 + catch (err) { 46 + console.error(`failed to revoke publicKey record for did ${did} (installation ${installationId})`, err) 47 + } 48 + } 49 + 12 50 /** 13 51 * Generate a per-install SSH keypair, write the public half to the user's PDS 14 52 * as a `sh.tangled.publicKey` record, and persist the encrypted private half 15 53 * + the resulting record key in the `ssh_key` table. 16 54 * 17 55 * If a row already exists for `(installation_id, did)` we no-op. Rotation is a 18 - * separate, explicit dashboard action (commit 16-ish) that re-runs this with 19 - * the existing record then deletes the old one. 56 + * separate, explicit dashboard action (`rotateKey`) that deletes the existing 57 + * record then re-runs this. 20 58 */ 21 59 export async function generateAndPublishKey(opts: { 22 60 oauthSession: OAuthSession ··· 37 75 const keypair = generateKeypair(keyName) 38 76 39 77 // Publish to PDS first. If this fails, we surface the error and leave no 40 - // half-state in the DB \u2014 the caller can retry. 78 + // half-state in the DB; the caller can retry. 41 79 const agent = new Agent(opts.oauthSession) 42 80 const result = await agent.com.atproto.repo.createRecord({ 43 81 repo: did, ··· 93 131 if (existing.length > 0) { 94 132 const row = existing[0]! 95 133 if (row.rkey) { 96 - const agent = new Agent(opts.oauthSession) 97 - try { 98 - await agent.com.atproto.repo.deleteRecord({ 99 - repo: did, 100 - collection: PUBKEY_LEXICON, 101 - rkey: row.rkey, 102 - }) 103 - } 104 - catch (err) { 105 - // If the record is already gone (404) we can safely continue; any 106 - // other error means the PDS rejected the delete and we should bail 107 - // rather than leave the user with two records. 108 - const status = err && typeof err === 'object' && 'status' in err && typeof err.status === 'number' 109 - ? err.status 110 - : undefined 111 - if (status !== 404) throw err 112 - } 134 + await deletePubkeyRecord(opts.oauthSession, did, row.rkey) 113 135 } 114 136 await db.delete(sshKey).where(sql`${sshKey.id} = ${row.id}`) 115 137 } ··· 134 156 .from(sshKey) 135 157 .where(sql`${sshKey.installationId} = ${installationId}`) 136 158 137 - const client = await useOAuthClient() 138 - 139 159 for (const row of rows) { 140 160 if (!row.rkey) continue 141 - try { 142 - // eslint-disable-next-line no-await-in-loop -- one PDS session per row 143 - const session = await client.restore(row.did) 144 - const agent = new Agent(session) 145 - // eslint-disable-next-line no-await-in-loop -- sequential PDS deletes 146 - await agent.com.atproto.repo.deleteRecord({ 147 - repo: row.did, 148 - collection: PUBKEY_LEXICON, 149 - rkey: row.rkey, 150 - }) 151 - } 152 - catch (err) { 153 - const status = err && typeof err === 'object' && 'status' in err && typeof err.status === 'number' 154 - ? err.status 155 - : undefined 156 - if (status === 404) continue 157 - console.error(`failed to revoke publicKey record for did ${row.did} (installation ${installationId})`, err) 158 - } 161 + // eslint-disable-next-line no-await-in-loop -- one PDS session per row 162 + await bestEffortRevoke(row.did, row.rkey, installationId) 159 163 } 160 164 } 161 165 ··· 175 179 .from(sshKey) 176 180 .where(sql`${sshKey.installationId} = ${installationId} AND ${sshKey.did} = ${did}`) 177 181 178 - const client = await useOAuthClient() 179 - 180 182 for (const row of rows) { 181 183 if (row.rkey) { 182 - try { 183 - // eslint-disable-next-line no-await-in-loop -- one PDS session per row 184 - const session = await client.restore(did) 185 - const agent = new Agent(session) 186 - // eslint-disable-next-line no-await-in-loop -- sequential PDS deletes 187 - await agent.com.atproto.repo.deleteRecord({ 188 - repo: did, 189 - collection: PUBKEY_LEXICON, 190 - rkey: row.rkey, 191 - }) 192 - } 193 - catch (err) { 194 - const status = err && typeof err === 'object' && 'status' in err && typeof err.status === 'number' 195 - ? err.status 196 - : undefined 197 - if (status !== 404) { 198 - console.error(`failed to revoke publicKey record for did ${did} (installation ${installationId})`, err) 199 - } 200 - } 184 + // eslint-disable-next-line no-await-in-loop -- one PDS session per row 185 + await bestEffortRevoke(did, row.rkey, installationId) 201 186 } 202 187 // eslint-disable-next-line no-await-in-loop -- sequential row deletes 203 188 await db.delete(sshKey).where(sql`${sshKey.id} = ${row.id}`)
+1
test/unit/sync-push.spec.ts
··· 18 18 19 19 vi.mock('../../server/utils/github-app', () => ({ 20 20 installationOctokit: async () => ({ auth: octokitAuthMock }), 21 + installationToken: async () => (await octokitAuthMock({ type: 'installation' })).token, 21 22 })) 22 23 23 24 const { syncPush } = await import('../../server/utils/sync-push')
+1
test/unit/sync-ref.spec.ts
··· 25 25 26 26 vi.mock('../../server/utils/github-app', () => ({ 27 27 installationOctokit: async () => ({ auth: octokitAuthMock }), 28 + installationToken: async () => (await octokitAuthMock({ type: 'installation' })).token, 28 29 })) 29 30 30 31 const { syncCreateRef, syncDeleteRef } = await import('../../server/utils/sync-ref')