A lexicon-driven AppView for ATProto. happyview.dev
backfill firehose jetstream atproto appview oauth lexicon
8
fork

Configure Feed

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

feat: switch to AIP OAuth with PKCE and improve lexicon UX

Trezy a6483c3b fe98dad9

+420 -494
+9 -2
docker-compose.yml
··· 25 25 STORAGE_BACKEND: postgres 26 26 EXTERNAL_BASE: http://localhost:8080 27 27 DPOP_NONCE_SEED: ${DPOP_NONCE_SEED} 28 - HTTP_PORT: 8080 28 + OAUTH_SIGNING_KEYS: ${OAUTH_SIGNING_KEYS} 29 + ATPROTO_OAUTH_SIGNING_KEYS: ${ATPROTO_OAUTH_SIGNING_KEYS} 30 + ENABLE_CLIENT_API: "true" 31 + PORT: 8080 29 32 depends_on: 30 33 postgres: 31 34 condition: service_healthy ··· 43 46 - cargo-target:/app/target 44 47 environment: 45 48 DATABASE_URL: postgres://happyview:happyview@postgres/happyview 46 - AIP_URL: http://aip:8080 49 + AIP_URL: https://aip.gamesgamesgamesgames.games 50 + PORT: 3000 47 51 depends_on: 48 52 postgres: 49 53 condition: service_healthy ··· 61 65 - web-node-modules:/app/node_modules 62 66 environment: 63 67 - HOSTNAME=0.0.0.0 68 + - API_URL=http://happyview:3000 69 + - AIP_PROXY_URL=https://aip.gamesgamesgamesgames.games 70 + - NEXT_PUBLIC_AIP_URL=https://aip.gamesgamesgamesgames.games 64 71 65 72 volumes: 66 73 pgdata:
+7 -3
web/next.config.ts
··· 1 1 import type { NextConfig } from "next"; 2 2 3 + const apiBase = process.env.API_URL || "http://localhost:3000"; 4 + const aipBase = process.env.AIP_PROXY_URL || "http://localhost:8080"; 5 + 3 6 const nextConfig: NextConfig = { 4 7 reactCompiler: true, 5 8 images: { unoptimized: true }, ··· 9 12 nextConfig.output = "export"; 10 13 } else { 11 14 nextConfig.rewrites = async () => [ 12 - { source: "/admin/:path*", destination: "http://localhost:3000/admin/:path*" }, 13 - { source: "/xrpc/:path*", destination: "http://localhost:3000/xrpc/:path*" }, 14 - { source: "/health", destination: "http://localhost:3000/health" }, 15 + { source: "/admin/:path*", destination: `${apiBase}/admin/:path*` }, 16 + { source: "/xrpc/:path*", destination: `${apiBase}/xrpc/:path*` }, 17 + { source: "/health", destination: `${apiBase}/health` }, 18 + { source: "/aip/:path*", destination: `${aipBase}/:path*` }, 15 19 ]; 16 20 } 17 21
-386
web/package-lock.json
··· 8 8 "name": "web", 9 9 "version": "0.1.0", 10 10 "dependencies": { 11 - "@atproto/oauth-client-browser": "^0.3.40", 12 11 "@dnd-kit/core": "^6.3.1", 13 12 "@dnd-kit/modifiers": "^9.0.0", 14 13 "@dnd-kit/sortable": "^10.0.0", ··· 78 77 "nup": "bin/nup.mjs" 79 78 } 80 79 }, 81 - "node_modules/@atproto-labs/did-resolver": { 82 - "version": "0.2.6", 83 - "resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.2.6.tgz", 84 - "integrity": "sha512-2K1bC04nI2fmgNcvof+yA28IhGlpWn2JKYlPa7To9JTKI45FINCGkQSGiL2nyXlyzDJJ34fZ1aq6/IRFIOIiqg==", 85 - "license": "MIT", 86 - "dependencies": { 87 - "@atproto-labs/fetch": "0.2.3", 88 - "@atproto-labs/pipe": "0.1.1", 89 - "@atproto-labs/simple-store": "0.3.0", 90 - "@atproto-labs/simple-store-memory": "0.1.4", 91 - "@atproto/did": "0.3.0", 92 - "zod": "^3.23.8" 93 - } 94 - }, 95 - "node_modules/@atproto-labs/did-resolver/node_modules/zod": { 96 - "version": "3.25.76", 97 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 98 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 99 - "license": "MIT", 100 - "funding": { 101 - "url": "https://github.com/sponsors/colinhacks" 102 - } 103 - }, 104 - "node_modules/@atproto-labs/fetch": { 105 - "version": "0.2.3", 106 - "resolved": "https://registry.npmjs.org/@atproto-labs/fetch/-/fetch-0.2.3.tgz", 107 - "integrity": "sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw==", 108 - "license": "MIT", 109 - "dependencies": { 110 - "@atproto-labs/pipe": "0.1.1" 111 - } 112 - }, 113 - "node_modules/@atproto-labs/handle-resolver": { 114 - "version": "0.3.6", 115 - "resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver/-/handle-resolver-0.3.6.tgz", 116 - "integrity": "sha512-qnSTXvOBNj1EHhp2qTWSX8MS5q3AwYU5LKlt5fBvSbCjgmTr2j0URHCv+ydrwO55KvsojIkTMgeMOh4YuY4fCA==", 117 - "license": "MIT", 118 - "dependencies": { 119 - "@atproto-labs/simple-store": "0.3.0", 120 - "@atproto-labs/simple-store-memory": "0.1.4", 121 - "@atproto/did": "0.3.0", 122 - "zod": "^3.23.8" 123 - } 124 - }, 125 - "node_modules/@atproto-labs/handle-resolver/node_modules/zod": { 126 - "version": "3.25.76", 127 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 128 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 129 - "license": "MIT", 130 - "funding": { 131 - "url": "https://github.com/sponsors/colinhacks" 132 - } 133 - }, 134 - "node_modules/@atproto-labs/identity-resolver": { 135 - "version": "0.3.6", 136 - "resolved": "https://registry.npmjs.org/@atproto-labs/identity-resolver/-/identity-resolver-0.3.6.tgz", 137 - "integrity": "sha512-qoWqBDRobln0NR8L8dQjSp79E0chGkBhibEgxQa2f9WD+JbJdjQ0YvwwO5yeQn05pJoJmAwmI2wyJ45zjU7aWg==", 138 - "license": "MIT", 139 - "dependencies": { 140 - "@atproto-labs/did-resolver": "0.2.6", 141 - "@atproto-labs/handle-resolver": "0.3.6" 142 - } 143 - }, 144 - "node_modules/@atproto-labs/pipe": { 145 - "version": "0.1.1", 146 - "resolved": "https://registry.npmjs.org/@atproto-labs/pipe/-/pipe-0.1.1.tgz", 147 - "integrity": "sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg==", 148 - "license": "MIT" 149 - }, 150 - "node_modules/@atproto-labs/simple-store": { 151 - "version": "0.3.0", 152 - "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store/-/simple-store-0.3.0.tgz", 153 - "integrity": "sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ==", 154 - "license": "MIT" 155 - }, 156 - "node_modules/@atproto-labs/simple-store-memory": { 157 - "version": "0.1.4", 158 - "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store-memory/-/simple-store-memory-0.1.4.tgz", 159 - "integrity": "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw==", 160 - "license": "MIT", 161 - "dependencies": { 162 - "@atproto-labs/simple-store": "0.3.0", 163 - "lru-cache": "^10.2.0" 164 - } 165 - }, 166 - "node_modules/@atproto-labs/simple-store-memory/node_modules/lru-cache": { 167 - "version": "10.4.3", 168 - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", 169 - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", 170 - "license": "ISC" 171 - }, 172 - "node_modules/@atproto/common-web": { 173 - "version": "0.4.16", 174 - "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.16.tgz", 175 - "integrity": "sha512-Ufvaff5JgxUyUyTAG0/3o7ltpy3lnZ1DvLjyAnvAf+hHfiK7OMQg+8byr+orN+KP9MtIQaRTsCgYPX+PxMKUoA==", 176 - "license": "MIT", 177 - "dependencies": { 178 - "@atproto/lex-data": "^0.0.11", 179 - "@atproto/lex-json": "^0.0.11", 180 - "@atproto/syntax": "^0.4.3", 181 - "zod": "^3.23.8" 182 - } 183 - }, 184 - "node_modules/@atproto/common-web/node_modules/zod": { 185 - "version": "3.25.76", 186 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 187 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 188 - "license": "MIT", 189 - "funding": { 190 - "url": "https://github.com/sponsors/colinhacks" 191 - } 192 - }, 193 - "node_modules/@atproto/did": { 194 - "version": "0.3.0", 195 - "resolved": "https://registry.npmjs.org/@atproto/did/-/did-0.3.0.tgz", 196 - "integrity": "sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA==", 197 - "license": "MIT", 198 - "dependencies": { 199 - "zod": "^3.23.8" 200 - } 201 - }, 202 - "node_modules/@atproto/did/node_modules/zod": { 203 - "version": "3.25.76", 204 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 205 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 206 - "license": "MIT", 207 - "funding": { 208 - "url": "https://github.com/sponsors/colinhacks" 209 - } 210 - }, 211 - "node_modules/@atproto/jwk": { 212 - "version": "0.6.0", 213 - "resolved": "https://registry.npmjs.org/@atproto/jwk/-/jwk-0.6.0.tgz", 214 - "integrity": "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw==", 215 - "license": "MIT", 216 - "dependencies": { 217 - "multiformats": "^9.9.0", 218 - "zod": "^3.23.8" 219 - } 220 - }, 221 - "node_modules/@atproto/jwk-jose": { 222 - "version": "0.1.11", 223 - "resolved": "https://registry.npmjs.org/@atproto/jwk-jose/-/jwk-jose-0.1.11.tgz", 224 - "integrity": "sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q==", 225 - "license": "MIT", 226 - "dependencies": { 227 - "@atproto/jwk": "0.6.0", 228 - "jose": "^5.2.0" 229 - } 230 - }, 231 - "node_modules/@atproto/jwk-jose/node_modules/jose": { 232 - "version": "5.10.0", 233 - "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", 234 - "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", 235 - "license": "MIT", 236 - "funding": { 237 - "url": "https://github.com/sponsors/panva" 238 - } 239 - }, 240 - "node_modules/@atproto/jwk-webcrypto": { 241 - "version": "0.2.0", 242 - "resolved": "https://registry.npmjs.org/@atproto/jwk-webcrypto/-/jwk-webcrypto-0.2.0.tgz", 243 - "integrity": "sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg==", 244 - "license": "MIT", 245 - "dependencies": { 246 - "@atproto/jwk": "0.6.0", 247 - "@atproto/jwk-jose": "0.1.11", 248 - "zod": "^3.23.8" 249 - } 250 - }, 251 - "node_modules/@atproto/jwk-webcrypto/node_modules/zod": { 252 - "version": "3.25.76", 253 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 254 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 255 - "license": "MIT", 256 - "funding": { 257 - "url": "https://github.com/sponsors/colinhacks" 258 - } 259 - }, 260 - "node_modules/@atproto/jwk/node_modules/zod": { 261 - "version": "3.25.76", 262 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 263 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 264 - "license": "MIT", 265 - "funding": { 266 - "url": "https://github.com/sponsors/colinhacks" 267 - } 268 - }, 269 - "node_modules/@atproto/lex-data": { 270 - "version": "0.0.11", 271 - "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.11.tgz", 272 - "integrity": "sha512-4+KTtHdqwlhiTKA7D4SACea4jprsNpCQsNALW09wsZ6IHhCDGO5tr1cmV+QnLYe3G3mu1E1yXHXbPUHrUUDT/A==", 273 - "license": "MIT", 274 - "dependencies": { 275 - "multiformats": "^9.9.0", 276 - "tslib": "^2.8.1", 277 - "uint8arrays": "3.0.0", 278 - "unicode-segmenter": "^0.14.0" 279 - } 280 - }, 281 - "node_modules/@atproto/lex-json": { 282 - "version": "0.0.11", 283 - "resolved": "https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.0.11.tgz", 284 - "integrity": "sha512-2IExAoQ4KsR5fyPa1JjIvtR316PvdgRH/l3BVGLBd3cSxM3m5MftIv1B6qZ9HjNiK60SgkWp0mi9574bTNDhBQ==", 285 - "license": "MIT", 286 - "dependencies": { 287 - "@atproto/lex-data": "^0.0.11", 288 - "tslib": "^2.8.1" 289 - } 290 - }, 291 - "node_modules/@atproto/lexicon": { 292 - "version": "0.6.1", 293 - "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.6.1.tgz", 294 - "integrity": "sha512-/vI1kVlY50Si+5MXpvOucelnYwb0UJ6Qto5mCp+7Q5C+Jtp+SoSykAPVvjVtTnQUH2vrKOFOwpb3C375vSKzXw==", 295 - "license": "MIT", 296 - "dependencies": { 297 - "@atproto/common-web": "^0.4.13", 298 - "@atproto/syntax": "^0.4.3", 299 - "iso-datestring-validator": "^2.2.2", 300 - "multiformats": "^9.9.0", 301 - "zod": "^3.23.8" 302 - } 303 - }, 304 - "node_modules/@atproto/lexicon/node_modules/zod": { 305 - "version": "3.25.76", 306 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 307 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 308 - "license": "MIT", 309 - "funding": { 310 - "url": "https://github.com/sponsors/colinhacks" 311 - } 312 - }, 313 - "node_modules/@atproto/oauth-client": { 314 - "version": "0.5.14", 315 - "resolved": "https://registry.npmjs.org/@atproto/oauth-client/-/oauth-client-0.5.14.tgz", 316 - "integrity": "sha512-sPH+vcdq9maTEAhJI0HzmFcFAMrkCS19np+RUssNkX6kS8Xr3OYr57tvYRCbkcnIyYTfYcxKQgpwHKx3RVEaYw==", 317 - "license": "MIT", 318 - "dependencies": { 319 - "@atproto-labs/did-resolver": "0.2.6", 320 - "@atproto-labs/fetch": "0.2.3", 321 - "@atproto-labs/handle-resolver": "0.3.6", 322 - "@atproto-labs/identity-resolver": "0.3.6", 323 - "@atproto-labs/simple-store": "0.3.0", 324 - "@atproto-labs/simple-store-memory": "0.1.4", 325 - "@atproto/did": "0.3.0", 326 - "@atproto/jwk": "0.6.0", 327 - "@atproto/oauth-types": "0.6.2", 328 - "@atproto/xrpc": "0.7.7", 329 - "core-js": "^3", 330 - "multiformats": "^9.9.0", 331 - "zod": "^3.23.8" 332 - } 333 - }, 334 - "node_modules/@atproto/oauth-client-browser": { 335 - "version": "0.3.40", 336 - "resolved": "https://registry.npmjs.org/@atproto/oauth-client-browser/-/oauth-client-browser-0.3.40.tgz", 337 - "integrity": "sha512-AlvHf1DYFRHw+J8uALUMhpclTbUTTkvLqzQeGBdAXxSGP8OefsOXXaSiY5Mh6zAYxek95/ypxYzNYncasgnMWg==", 338 - "license": "MIT", 339 - "dependencies": { 340 - "@atproto-labs/did-resolver": "0.2.6", 341 - "@atproto-labs/handle-resolver": "0.3.6", 342 - "@atproto-labs/simple-store": "0.3.0", 343 - "@atproto/did": "0.3.0", 344 - "@atproto/jwk": "0.6.0", 345 - "@atproto/jwk-webcrypto": "0.2.0", 346 - "@atproto/oauth-client": "0.5.14", 347 - "@atproto/oauth-types": "0.6.2", 348 - "core-js": "^3" 349 - } 350 - }, 351 - "node_modules/@atproto/oauth-client/node_modules/zod": { 352 - "version": "3.25.76", 353 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 354 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 355 - "license": "MIT", 356 - "funding": { 357 - "url": "https://github.com/sponsors/colinhacks" 358 - } 359 - }, 360 - "node_modules/@atproto/oauth-types": { 361 - "version": "0.6.2", 362 - "resolved": "https://registry.npmjs.org/@atproto/oauth-types/-/oauth-types-0.6.2.tgz", 363 - "integrity": "sha512-2cuboM4RQBCYR8NQC5uGRkW6KgCgKyq/B5/+tnMmWZYtZGVUQvsUWQHK/ZiMCnVXbcDNtc/RIEJQJDZ8FXMoxg==", 364 - "license": "MIT", 365 - "dependencies": { 366 - "@atproto/did": "0.3.0", 367 - "@atproto/jwk": "0.6.0", 368 - "zod": "^3.23.8" 369 - } 370 - }, 371 - "node_modules/@atproto/oauth-types/node_modules/zod": { 372 - "version": "3.25.76", 373 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 374 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 375 - "license": "MIT", 376 - "funding": { 377 - "url": "https://github.com/sponsors/colinhacks" 378 - } 379 - }, 380 - "node_modules/@atproto/syntax": { 381 - "version": "0.4.3", 382 - "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.3.tgz", 383 - "integrity": "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==", 384 - "license": "MIT", 385 - "dependencies": { 386 - "tslib": "^2.8.1" 387 - } 388 - }, 389 - "node_modules/@atproto/xrpc": { 390 - "version": "0.7.7", 391 - "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.7.7.tgz", 392 - "integrity": "sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==", 393 - "license": "MIT", 394 - "dependencies": { 395 - "@atproto/lexicon": "^0.6.0", 396 - "zod": "^3.23.8" 397 - } 398 - }, 399 - "node_modules/@atproto/xrpc/node_modules/zod": { 400 - "version": "3.25.76", 401 - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 402 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 403 - "license": "MIT", 404 - "funding": { 405 - "url": "https://github.com/sponsors/colinhacks" 406 - } 407 - }, 408 80 "node_modules/@babel/code-frame": { 409 81 "version": "7.29.0", 410 82 "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", ··· 436 108 "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", 437 109 "dev": true, 438 110 "license": "MIT", 439 - "peer": true, 440 111 "dependencies": { 441 112 "@babel/code-frame": "^7.29.0", 442 113 "@babel/generator": "^7.29.0", ··· 865 536 "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", 866 537 "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", 867 538 "license": "MIT", 868 - "peer": true, 869 539 "dependencies": { 870 540 "@dnd-kit/accessibility": "^3.1.1", 871 541 "@dnd-kit/utilities": "^3.2.2", ··· 1073 743 "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", 1074 744 "dev": true, 1075 745 "license": "MIT", 1076 - "peer": true, 1077 746 "engines": { 1078 747 "node": ">=12" 1079 748 }, ··· 2269 1938 "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", 2270 1939 "dev": true, 2271 1940 "license": "MIT", 2272 - "peer": true, 2273 1941 "engines": { 2274 1942 "node": "^14.21.3 || >=16" 2275 1943 }, ··· 4428 4096 "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", 4429 4097 "dev": true, 4430 4098 "license": "MIT", 4431 - "peer": true, 4432 4099 "dependencies": { 4433 4100 "undici-types": "~6.21.0" 4434 4101 } ··· 4439 4106 "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", 4440 4107 "devOptional": true, 4441 4108 "license": "MIT", 4442 - "peer": true, 4443 4109 "dependencies": { 4444 4110 "csstype": "^3.2.2" 4445 4111 } ··· 4450 4116 "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", 4451 4117 "devOptional": true, 4452 4118 "license": "MIT", 4453 - "peer": true, 4454 4119 "peerDependencies": { 4455 4120 "@types/react": "^19.2.0" 4456 4121 } ··· 4514 4179 "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", 4515 4180 "dev": true, 4516 4181 "license": "MIT", 4517 - "peer": true, 4518 4182 "dependencies": { 4519 4183 "@typescript-eslint/scope-manager": "8.55.0", 4520 4184 "@typescript-eslint/types": "8.55.0", ··· 5028 4692 "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", 5029 4693 "dev": true, 5030 4694 "license": "MIT", 5031 - "peer": true, 5032 4695 "bin": { 5033 4696 "acorn": "bin/acorn" 5034 4697 }, ··· 5415 5078 "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", 5416 5079 "devOptional": true, 5417 5080 "license": "MIT", 5418 - "peer": true, 5419 5081 "dependencies": { 5420 5082 "@babel/types": "^7.26.0" 5421 5083 } ··· 5505 5167 } 5506 5168 ], 5507 5169 "license": "MIT", 5508 - "peer": true, 5509 5170 "dependencies": { 5510 5171 "baseline-browser-mapping": "^2.9.0", 5511 5172 "caniuse-lite": "^1.0.30001759", ··· 5880 5541 "license": "MIT", 5881 5542 "engines": { 5882 5543 "node": ">=6.6.0" 5883 - } 5884 - }, 5885 - "node_modules/core-js": { 5886 - "version": "3.48.0", 5887 - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz", 5888 - "integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==", 5889 - "hasInstallScript": true, 5890 - "license": "MIT", 5891 - "funding": { 5892 - "type": "opencollective", 5893 - "url": "https://opencollective.com/core-js" 5894 5544 } 5895 5545 }, 5896 5546 "node_modules/cors": { ··· 6682 6332 "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", 6683 6333 "dev": true, 6684 6334 "license": "MIT", 6685 - "peer": true, 6686 6335 "dependencies": { 6687 6336 "@eslint-community/eslint-utils": "^4.8.0", 6688 6337 "@eslint-community/regexpp": "^4.12.1", ··· 6868 6517 "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", 6869 6518 "dev": true, 6870 6519 "license": "MIT", 6871 - "peer": true, 6872 6520 "dependencies": { 6873 6521 "@rtsao/scc": "^1.1.0", 6874 6522 "array-includes": "^3.1.9", ··· 7188 6836 "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", 7189 6837 "dev": true, 7190 6838 "license": "MIT", 7191 - "peer": true, 7192 6839 "dependencies": { 7193 6840 "accepts": "^2.0.0", 7194 6841 "body-parser": "^2.2.1", ··· 7927 7574 "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", 7928 7575 "dev": true, 7929 7576 "license": "MIT", 7930 - "peer": true, 7931 7577 "engines": { 7932 7578 "node": ">=16.9.0" 7933 7579 } ··· 8684 8330 "dev": true, 8685 8331 "license": "ISC" 8686 8332 }, 8687 - "node_modules/iso-datestring-validator": { 8688 - "version": "2.2.2", 8689 - "resolved": "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz", 8690 - "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==", 8691 - "license": "MIT" 8692 - }, 8693 8333 "node_modules/iterator.prototype": { 8694 8334 "version": "1.1.5", 8695 8335 "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", ··· 9491 9131 "url": "https://opencollective.com/express" 9492 9132 } 9493 9133 }, 9494 - "node_modules/multiformats": { 9495 - "version": "9.9.0", 9496 - "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", 9497 - "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 9498 - "license": "(Apache-2.0 AND MIT)" 9499 - }, 9500 9134 "node_modules/mute-stream": { 9501 9135 "version": "2.0.0", 9502 9136 "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", ··· 10458 10092 "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", 10459 10093 "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", 10460 10094 "license": "MIT", 10461 - "peer": true, 10462 10095 "engines": { 10463 10096 "node": ">=0.10.0" 10464 10097 } ··· 10468 10101 "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", 10469 10102 "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", 10470 10103 "license": "MIT", 10471 - "peer": true, 10472 10104 "dependencies": { 10473 10105 "scheduler": "^0.27.0" 10474 10106 }, ··· 11740 11372 "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", 11741 11373 "dev": true, 11742 11374 "license": "MIT", 11743 - "peer": true, 11744 11375 "engines": { 11745 11376 "node": ">=12" 11746 11377 }, ··· 11998 11629 "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 11999 11630 "dev": true, 12000 11631 "license": "Apache-2.0", 12001 - "peer": true, 12002 11632 "bin": { 12003 11633 "tsc": "bin/tsc", 12004 11634 "tsserver": "bin/tsserver" ··· 12031 11661 "typescript": ">=4.8.4 <6.0.0" 12032 11662 } 12033 11663 }, 12034 - "node_modules/uint8arrays": { 12035 - "version": "3.0.0", 12036 - "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz", 12037 - "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==", 12038 - "license": "MIT", 12039 - "dependencies": { 12040 - "multiformats": "^9.4.2" 12041 - } 12042 - }, 12043 11664 "node_modules/unbox-primitive": { 12044 11665 "version": "1.1.0", 12045 11666 "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", ··· 12064 11685 "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", 12065 11686 "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", 12066 11687 "dev": true, 12067 - "license": "MIT" 12068 - }, 12069 - "node_modules/unicode-segmenter": { 12070 - "version": "0.14.5", 12071 - "resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.5.tgz", 12072 - "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==", 12073 11688 "license": "MIT" 12074 11689 }, 12075 11690 "node_modules/unicorn-magic": { ··· 12649 12264 "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", 12650 12265 "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", 12651 12266 "license": "MIT", 12652 - "peer": true, 12653 12267 "funding": { 12654 12268 "url": "https://github.com/sponsors/colinhacks" 12655 12269 }
-1
web/package.json
··· 9 9 "lint": "eslint" 10 10 }, 11 11 "dependencies": { 12 - "@atproto/oauth-client-browser": "^0.3.40", 13 12 "@dnd-kit/core": "^6.3.1", 14 13 "@dnd-kit/modifiers": "^9.0.0", 15 14 "@dnd-kit/sortable": "^10.0.0",
+54 -24
web/src/app/(dashboard)/lexicons/page.tsx
··· 1 1 "use client" 2 2 3 - import { useCallback, useEffect, useState } from "react" 3 + import { useCallback, useEffect, useMemo, useState } from "react" 4 4 5 5 import { useAuth } from "@/lib/auth-context" 6 6 import { ··· 26 26 } from "@/components/ui/dialog" 27 27 import { Input } from "@/components/ui/input" 28 28 import { Label } from "@/components/ui/label" 29 + import { 30 + Select, 31 + SelectContent, 32 + SelectItem, 33 + SelectTrigger, 34 + SelectValue, 35 + } from "@/components/ui/select" 29 36 import { Switch } from "@/components/ui/switch" 30 37 import { 31 38 Table, ··· 167 174 const [error, setError] = useState<string | null>(null) 168 175 const [open, setOpen] = useState(false) 169 176 177 + const mainType = useMemo(() => { 178 + try { 179 + const parsed = JSON.parse(json) 180 + return parsed?.defs?.main?.type as string | undefined 181 + } catch { 182 + return undefined 183 + } 184 + }, [json]) 185 + 186 + const showTargetCollection = mainType === "query" || mainType === "procedure" 187 + const showAction = mainType === "procedure" 188 + 170 189 async function handleUpload() { 171 190 setError(null) 172 191 try { ··· 174 193 await uploadLexicon(getToken, { 175 194 lexicon_json: lexiconJson, 176 195 backfill, 177 - target_collection: targetCollection || undefined, 178 - action: action || undefined, 196 + target_collection: showTargetCollection 197 + ? targetCollection || undefined 198 + : undefined, 199 + action: showAction ? action || undefined : undefined, 179 200 }) 180 201 setJson("") 181 202 setTargetCollection("") ··· 200 221 Paste the lexicon JSON document below. 201 222 </DialogDescription> 202 223 </DialogHeader> 203 - <div className="flex flex-col gap-4"> 224 + <div className="flex min-w-0 flex-col gap-4 overflow-hidden"> 204 225 {error && <p className="text-destructive text-sm">{error}</p>} 205 226 <div className="flex flex-col gap-2"> 206 227 <Label htmlFor="lexicon-json">Lexicon JSON</Label> ··· 213 234 placeholder='{"lexicon": 1, "id": "com.example.record", ...}' 214 235 /> 215 236 </div> 216 - <div className="flex flex-col gap-2"> 217 - <Label htmlFor="target-collection"> 218 - Target Collection (optional) 219 - </Label> 220 - <Input 221 - id="target-collection" 222 - value={targetCollection} 223 - onChange={(e) => setTargetCollection(e.target.value)} 224 - placeholder="com.example.record" 225 - /> 226 - </div> 227 - <div className="flex flex-col gap-2"> 228 - <Label htmlFor="action">Action (optional)</Label> 229 - <Input 230 - id="action" 231 - value={action} 232 - onChange={(e) => setAction(e.target.value)} 233 - placeholder="create, put, or leave empty for auto" 234 - /> 235 - </div> 237 + {showTargetCollection && ( 238 + <div className="flex flex-col gap-2"> 239 + <Label htmlFor="target-collection"> 240 + Target Collection (optional) 241 + </Label> 242 + <Input 243 + id="target-collection" 244 + value={targetCollection} 245 + onChange={(e) => setTargetCollection(e.target.value)} 246 + placeholder="com.example.record" 247 + /> 248 + </div> 249 + )} 250 + {showAction && ( 251 + <div className="flex flex-col gap-2"> 252 + <Label htmlFor="action">Action (optional)</Label> 253 + <Select value={action} onValueChange={setAction}> 254 + <SelectTrigger id="action" className="w-full"> 255 + <SelectValue placeholder="Upsert (default)" /> 256 + </SelectTrigger> 257 + <SelectContent> 258 + <SelectItem value="upsert">Upsert (default)</SelectItem> 259 + <SelectItem value="create">Create</SelectItem> 260 + <SelectItem value="update">Update</SelectItem> 261 + <SelectItem value="delete">Delete</SelectItem> 262 + </SelectContent> 263 + </Select> 264 + </div> 265 + )} 236 266 <div className="flex items-center gap-2"> 237 267 <Switch 238 268 id="backfill"
+138 -13
web/src/app/(dashboard)/network-lexicons/page.tsx
··· 1 1 "use client" 2 2 3 - import { useCallback, useEffect, useState } from "react" 3 + import { useCallback, useEffect, useRef, useState } from "react" 4 4 5 5 import { useAuth } from "@/lib/auth-context" 6 6 import { ··· 122 122 ) 123 123 } 124 124 125 + // Extract the authority domain from an NSID. 126 + // e.g. "games.gamesgamesgamesgames.createGame" → "gamesgamesgamesgames.games" 127 + function nsidToDomain(nsid: string): string | null { 128 + const parts = nsid.split(".") 129 + if (parts.length < 3) return null 130 + const authority = parts.slice(0, -1).reverse() 131 + return authority.join(".") 132 + } 133 + 134 + // Resolve an NSID to its lexicon's main def type by fetching from the network. 135 + async function resolveNsidType( 136 + nsid: string, 137 + signal: AbortSignal 138 + ): Promise<string | undefined> { 139 + const domain = nsidToDomain(nsid) 140 + if (!domain) return undefined 141 + 142 + // Resolve handle → DID 143 + let did: string | undefined 144 + try { 145 + const resp = await fetch( 146 + `https://${domain}/.well-known/atproto-did`, 147 + { signal } 148 + ) 149 + if (resp.ok) did = (await resp.text()).trim() 150 + } catch (e) { 151 + if (signal.aborted) return undefined 152 + } 153 + 154 + if (!did) { 155 + try { 156 + const resp = await fetch( 157 + `https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(domain)}`, 158 + { signal } 159 + ) 160 + if (resp.ok) { 161 + const data = await resp.json() 162 + did = data.did 163 + } 164 + } catch (e) { 165 + if (signal.aborted) return undefined 166 + } 167 + } 168 + 169 + if (!did) return undefined 170 + 171 + // Resolve DID → PDS endpoint 172 + let pdsEndpoint: string | undefined 173 + try { 174 + const resp = await fetch( 175 + `https://plc.directory/${encodeURIComponent(did)}`, 176 + { signal } 177 + ) 178 + if (resp.ok) { 179 + const doc = await resp.json() 180 + const services = doc.service as 181 + | { id: string; serviceEndpoint: string }[] 182 + | undefined 183 + pdsEndpoint = services?.find( 184 + (s) => s.id === "#atproto_pds" 185 + )?.serviceEndpoint 186 + } 187 + } catch (e) { 188 + if (signal.aborted) return undefined 189 + } 190 + 191 + if (!pdsEndpoint) return undefined 192 + 193 + // Fetch lexicon record from PDS 194 + try { 195 + const resp = await fetch( 196 + `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=com.atproto.lexicon.schema&rkey=${encodeURIComponent(nsid)}`, 197 + { signal } 198 + ) 199 + if (resp.ok) { 200 + const data = await resp.json() 201 + return data.value?.defs?.main?.type as string | undefined 202 + } 203 + } catch { 204 + // Best-effort resolution 205 + } 206 + 207 + return undefined 208 + } 209 + 125 210 function AddDialog({ 126 211 getToken, 127 212 onSuccess, ··· 131 216 }) { 132 217 const [nsid, setNsid] = useState("") 133 218 const [targetCollection, setTargetCollection] = useState("") 219 + const [mainType, setMainType] = useState<string | undefined>() 220 + const [resolving, setResolving] = useState(false) 134 221 const [error, setError] = useState<string | null>(null) 135 222 const [open, setOpen] = useState(false) 223 + const abortRef = useRef<AbortController | null>(null) 224 + 225 + // Debounced NSID resolution 226 + useEffect(() => { 227 + abortRef.current?.abort() 228 + setMainType(undefined) 229 + 230 + // Need at least 3 segments (e.g. "com.example.thing") 231 + if (nsid.split(".").length < 3) return 232 + 233 + const debounce = setTimeout(() => { 234 + const controller = new AbortController() 235 + abortRef.current = controller 236 + setResolving(true) 237 + 238 + resolveNsidType(nsid, controller.signal) 239 + .then((type) => { 240 + if (!controller.signal.aborted) setMainType(type) 241 + }) 242 + .finally(() => { 243 + if (!controller.signal.aborted) setResolving(false) 244 + }) 245 + }, 500) 246 + 247 + return () => clearTimeout(debounce) 248 + }, [nsid]) 249 + 250 + const showTargetCollection = mainType === "query" || mainType === "procedure" 136 251 137 252 async function handleAdd() { 138 253 setError(null) 139 254 try { 140 255 await addNetworkLexicon(getToken, { 141 256 nsid, 142 - target_collection: targetCollection || undefined, 257 + target_collection: showTargetCollection 258 + ? targetCollection || undefined 259 + : undefined, 143 260 }) 144 261 setNsid("") 145 262 setTargetCollection("") 263 + setMainType(undefined) 146 264 setOpen(false) 147 265 onSuccess() 148 266 } catch (e: unknown) { ··· 172 290 onChange={(e) => setNsid(e.target.value)} 173 291 placeholder="com.example.record" 174 292 /> 293 + {resolving && ( 294 + <p className="text-muted-foreground text-xs"> 295 + Resolving lexicon... 296 + </p> 297 + )} 175 298 </div> 176 - <div className="flex flex-col gap-2"> 177 - <Label htmlFor="nl-target-collection"> 178 - Target Collection (optional) 179 - </Label> 180 - <Input 181 - id="nl-target-collection" 182 - value={targetCollection} 183 - onChange={(e) => setTargetCollection(e.target.value)} 184 - placeholder="com.example.record" 185 - /> 186 - </div> 299 + {showTargetCollection && ( 300 + <div className="flex flex-col gap-2"> 301 + <Label htmlFor="nl-target-collection"> 302 + Target Collection (optional) 303 + </Label> 304 + <Input 305 + id="nl-target-collection" 306 + value={targetCollection} 307 + onChange={(e) => setTargetCollection(e.target.value)} 308 + placeholder="com.example.record" 309 + /> 310 + </div> 311 + )} 187 312 </div> 188 313 <DialogFooter> 189 314 <DialogClose asChild>
+1 -1
web/src/components/ui/dialog.tsx
··· 61 61 <DialogPrimitive.Content 62 62 data-slot="dialog-content" 63 63 className={cn( 64 - "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg", 64 + "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] max-h-[calc(100vh-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg", 65 65 className 66 66 )} 67 67 {...props}
+211 -64
web/src/lib/auth-context.tsx
··· 5 5 useCallback, 6 6 useContext, 7 7 useEffect, 8 - useRef, 9 8 useState, 10 9 } from "react" 11 - import type { BrowserOAuthClient, OAuthSession } from "@atproto/oauth-client-browser" 12 10 13 11 interface AuthContextType { 14 12 did: string | null ··· 28 26 error: null, 29 27 }) 30 28 29 + // AIP URL for browser redirects (authorization endpoint) 30 + const AIP_URL = process.env.NEXT_PUBLIC_AIP_URL || "" 31 + 32 + // PKCE helpers 33 + 34 + function base64urlEncode(buffer: ArrayBuffer): string { 35 + const bytes = new Uint8Array(buffer) 36 + let binary = "" 37 + for (const b of bytes) binary += String.fromCharCode(b) 38 + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") 39 + } 40 + 41 + function generateRandomString(byteLength: number): string { 42 + const array = new Uint8Array(byteLength) 43 + crypto.getRandomValues(array) 44 + return base64urlEncode(array.buffer as ArrayBuffer) 45 + } 46 + 47 + async function generateCodeChallenge(verifier: string): Promise<string> { 48 + const encoder = new TextEncoder() 49 + const hash = await crypto.subtle.digest("SHA-256", encoder.encode(verifier)) 50 + return base64urlEncode(hash) 51 + } 52 + 53 + // Dynamic client registration with AIP. 54 + // Caches the client_id in localStorage so we only register once. 55 + async function getOrRegisterClient(redirectUri: string): Promise<string> { 56 + const cacheKey = `oauth_client_id:${AIP_URL}:${redirectUri}` 57 + const cached = localStorage.getItem(cacheKey) 58 + if (cached) return cached 59 + 60 + const resp = await fetch("/aip/oauth/clients/register", { 61 + method: "POST", 62 + headers: { "Content-Type": "application/json" }, 63 + body: JSON.stringify({ 64 + redirect_uris: [redirectUri], 65 + grant_types: ["authorization_code"], 66 + response_types: ["code"], 67 + token_endpoint_auth_method: "none", 68 + application_type: "native", 69 + client_name: "HappyView Admin", 70 + }), 71 + }) 72 + 73 + if (!resp.ok) { 74 + const text = await resp.text() 75 + throw new Error(`Client registration failed: ${text}`) 76 + } 77 + 78 + const data = await resp.json() 79 + const clientId: string = data.client_id 80 + localStorage.setItem(cacheKey, clientId) 81 + return clientId 82 + } 83 + 31 84 export function AuthProvider({ children }: { children: React.ReactNode }) { 32 - const [session, setSession] = useState<OAuthSession | null>(null) 85 + const [accessToken, setAccessToken] = useState<string | null>(null) 86 + const [did, setDid] = useState<string | null>(null) 33 87 const [loading, setLoading] = useState(true) 34 88 const [error, setError] = useState<string | null>(null) 35 - const clientRef = useRef<BrowserOAuthClient | null>(null) 36 89 37 90 useEffect(() => { 91 + // ATProto loopback client IDs require 127.0.0.1, not localhost. 92 + if (window.location.hostname === "localhost") { 93 + window.location.hostname = "127.0.0.1" 94 + return 95 + } 96 + 38 97 let cancelled = false 39 98 40 99 async function init() { 41 100 try { 42 - const { 43 - BrowserOAuthClient: Client, 44 - atprotoLoopbackClientMetadata, 45 - buildAtprotoLoopbackClientId, 46 - } = await import("@atproto/oauth-client-browser") 47 - 48 - const isLocalhost = 49 - window.location.hostname === "localhost" || 50 - window.location.hostname === "127.0.0.1" 51 - 52 - let client: InstanceType<typeof Client> 101 + const params = new URLSearchParams(window.location.search) 102 + const code = params.get("code") 103 + const state = params.get("state") 53 104 54 - if (isLocalhost) { 55 - const port = window.location.port 56 - ? `:${window.location.port}` 57 - : "" 58 - const clientId = buildAtprotoLoopbackClientId({ 59 - redirect_uris: [`http://127.0.0.1${port}/`], 60 - }) 61 - client = new Client({ 62 - handleResolver: "https://bsky.social", 63 - clientMetadata: atprotoLoopbackClientMetadata(clientId), 105 + if (code && state) { 106 + await handleOAuthCallback(code, state, cancelled, { 107 + setAccessToken, 108 + setDid, 64 109 }) 65 110 } else { 66 - client = await Client.load({ 67 - clientId: `${window.location.origin}/oauth/client-metadata.json`, 68 - handleResolver: "https://bsky.social", 69 - }) 70 - } 71 - 72 - clientRef.current = client 73 - 74 - const result = await client.init() 75 - if (!cancelled && result?.session) { 76 - setSession(result.session) 111 + // Restore session from storage 112 + const savedToken = sessionStorage.getItem("oauth_access_token") 113 + const savedDid = sessionStorage.getItem("oauth_did") 114 + if (savedToken && savedDid && !cancelled) { 115 + setAccessToken(savedToken) 116 + setDid(savedDid) 117 + } 77 118 } 78 119 } catch (e) { 79 120 if (!cancelled) { ··· 92 133 }, []) 93 134 94 135 const getToken = useCallback(async (): Promise<string | null> => { 95 - if (!session) return null 96 - try { 97 - // Access the protected getTokenSet method to extract the raw access 98 - // token. The admin API validates tokens via AIP's userinfo endpoint 99 - // using plain Bearer auth, so we need the raw JWT. 100 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 101 - const tokenSet = await (session as any).getTokenSet("auto") 102 - return tokenSet.access_token 103 - } catch { 104 - return null 136 + return accessToken 137 + }, [accessToken]) 138 + 139 + const login = useCallback(async (handle: string) => { 140 + if (!AIP_URL) { 141 + throw new Error("AIP URL not configured (set NEXT_PUBLIC_AIP_URL)") 105 142 } 106 - }, [session]) 107 143 108 - const login = useCallback(async (handle: string) => { 109 - const client = clientRef.current 110 - if (!client) return 111 144 setError(null) 112 - try { 113 - await client.signIn(handle, { 114 - scope: "atproto", 115 - }) 116 - } catch (e) { 117 - setError(e instanceof Error ? e.message : String(e)) 118 - throw e 119 - } 145 + 146 + const redirectUri = `${window.location.origin}/` 147 + const clientId = await getOrRegisterClient(redirectUri) 148 + 149 + const codeVerifier = generateRandomString(32) 150 + const codeChallenge = await generateCodeChallenge(codeVerifier) 151 + const state = generateRandomString(16) 152 + 153 + sessionStorage.setItem("oauth_code_verifier", codeVerifier) 154 + sessionStorage.setItem("oauth_state", state) 155 + sessionStorage.setItem("oauth_client_id", clientId) 156 + 157 + const params = new URLSearchParams({ 158 + response_type: "code", 159 + client_id: clientId, 160 + redirect_uri: redirectUri, 161 + code_challenge: codeChallenge, 162 + code_challenge_method: "S256", 163 + state, 164 + scope: "atproto", 165 + login_hint: handle, 166 + }) 167 + 168 + window.location.href = `${AIP_URL}/oauth/authorize?${params.toString()}` 120 169 }, []) 121 170 122 171 const logout = useCallback(async () => { 123 - if (session) { 172 + const clientId = sessionStorage.getItem("oauth_client_id") 173 + if (accessToken && clientId) { 124 174 try { 125 - await session.signOut() 175 + await fetch("/aip/oauth/revoke", { 176 + method: "POST", 177 + headers: { "Content-Type": "application/x-www-form-urlencoded" }, 178 + body: new URLSearchParams({ 179 + token: accessToken, 180 + client_id: clientId, 181 + }).toString(), 182 + }) 126 183 } catch { 127 - // Ignore sign-out errors 184 + // Best-effort revocation 128 185 } 129 186 } 130 - setSession(null) 131 - }, [session]) 187 + setAccessToken(null) 188 + setDid(null) 189 + sessionStorage.removeItem("oauth_access_token") 190 + sessionStorage.removeItem("oauth_did") 191 + sessionStorage.removeItem("oauth_client_id") 192 + }, [accessToken]) 132 193 133 194 if (loading) return null 134 195 135 196 return ( 136 197 <AuthContext.Provider 137 198 value={{ 138 - did: session?.did ?? null, 199 + did, 139 200 getToken, 140 201 login, 141 202 logout, ··· 146 207 {children} 147 208 </AuthContext.Provider> 148 209 ) 210 + } 211 + 212 + async function handleOAuthCallback( 213 + code: string, 214 + state: string, 215 + cancelled: boolean, 216 + setters: { 217 + setAccessToken: (t: string) => void 218 + setDid: (d: string) => void 219 + } 220 + ) { 221 + const savedState = sessionStorage.getItem("oauth_state") 222 + if (state !== savedState) { 223 + throw new Error("OAuth state mismatch") 224 + } 225 + 226 + const codeVerifier = sessionStorage.getItem("oauth_code_verifier") 227 + if (!codeVerifier) { 228 + throw new Error("Missing PKCE code verifier") 229 + } 230 + 231 + const clientId = sessionStorage.getItem("oauth_client_id") 232 + if (!clientId) { 233 + throw new Error("Missing OAuth client ID") 234 + } 235 + 236 + // Verify issuer if present in callback 237 + const params = new URLSearchParams(window.location.search) 238 + const iss = params.get("iss") 239 + if (iss) { 240 + const savedIssuer = sessionStorage.getItem("oauth_issuer") 241 + if (savedIssuer && iss !== savedIssuer) { 242 + throw new Error("OAuth issuer mismatch") 243 + } 244 + } 245 + 246 + const redirectUri = `${window.location.origin}/` 247 + 248 + // Token exchange via proxied path (avoids CORS) 249 + const resp = await fetch("/aip/oauth/token", { 250 + method: "POST", 251 + headers: { "Content-Type": "application/x-www-form-urlencoded" }, 252 + body: new URLSearchParams({ 253 + grant_type: "authorization_code", 254 + code, 255 + redirect_uri: redirectUri, 256 + client_id: clientId, 257 + code_verifier: codeVerifier, 258 + }).toString(), 259 + }) 260 + 261 + if (!resp.ok) { 262 + const text = await resp.text() 263 + throw new Error(`Token exchange failed: ${text}`) 264 + } 265 + 266 + const tokens = await resp.json() 267 + 268 + // Clean URL and session storage 269 + window.history.replaceState({}, "", window.location.pathname) 270 + sessionStorage.removeItem("oauth_state") 271 + sessionStorage.removeItem("oauth_code_verifier") 272 + sessionStorage.removeItem("oauth_issuer") 273 + 274 + if (cancelled) return 275 + 276 + const accessToken: string = tokens.access_token 277 + setters.setAccessToken(accessToken) 278 + 279 + // Get DID from token response or userinfo 280 + let userDid: string | undefined = tokens.sub 281 + if (!userDid) { 282 + const userinfoResp = await fetch("/aip/oauth/userinfo", { 283 + headers: { Authorization: `Bearer ${accessToken}` }, 284 + }) 285 + if (userinfoResp.ok) { 286 + const info = await userinfoResp.json() 287 + userDid = info.sub 288 + } 289 + } 290 + 291 + if (userDid) { 292 + setters.setDid(userDid) 293 + sessionStorage.setItem("oauth_did", userDid) 294 + } 295 + sessionStorage.setItem("oauth_access_token", accessToken) 149 296 } 150 297 151 298 export function useAuth() {