social components inlay-proto.up.railway.app/
atproto components sdui

personalized components

+638 -30
+9 -1
generated/at/inlay/component.defs.ts
··· 103 103 * DID of the service hosting this component 104 104 */ 105 105 did: l.DidString; 106 + 107 + /** 108 + * When true, host sends a signed JWT identifying the viewer with each call. 109 + */ 110 + personalized?: boolean; 106 111 }; 107 112 108 113 export type { BodyExternal }; ··· 111 116 const bodyExternal = l.typedObject<BodyExternal>( 112 117 $nsid, 113 118 "bodyExternal", 114 - l.object({ did: l.string({ format: "did" }) }) 119 + l.object({ 120 + did: l.string({ format: "did" }), 121 + personalized: l.optional(l.boolean()), 122 + }) 115 123 ); 116 124 117 125 export { bodyExternal };
+4
lexicons/at/inlay/component.json
··· 64 64 "type": "string", 65 65 "format": "did", 66 66 "description": "DID of the service hosting this component" 67 + }, 68 + "personalized": { 69 + "type": "boolean", 70 + "description": "When true, host sends a signed JWT identifying the viewer with each call." 67 71 } 68 72 } 69 73 },
+267 -14
package-lock.json
··· 77 77 "@atproto-labs/pipe": "0.1.1" 78 78 } 79 79 }, 80 + "node_modules/@atproto-labs/fetch-node": { 81 + "version": "0.2.0", 82 + "resolved": "https://registry.npmjs.org/@atproto-labs/fetch-node/-/fetch-node-0.2.0.tgz", 83 + "integrity": "sha512-Krq09nH/aeoiU2s9xdHA0FjTEFWG9B5FFenipv1iRixCcPc7V3DhTNDawxG9gI8Ny0k4dBVS9WTRN/IDzBx86Q==", 84 + "license": "MIT", 85 + "dependencies": { 86 + "@atproto-labs/fetch": "0.2.3", 87 + "@atproto-labs/pipe": "0.1.1", 88 + "ipaddr.js": "^2.1.0", 89 + "undici": "^6.14.1" 90 + }, 91 + "engines": { 92 + "node": ">=18.7.0" 93 + } 94 + }, 95 + "node_modules/@atproto-labs/handle-resolver": { 96 + "version": "0.3.6", 97 + "resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver/-/handle-resolver-0.3.6.tgz", 98 + "integrity": "sha512-qnSTXvOBNj1EHhp2qTWSX8MS5q3AwYU5LKlt5fBvSbCjgmTr2j0URHCv+ydrwO55KvsojIkTMgeMOh4YuY4fCA==", 99 + "license": "MIT", 100 + "dependencies": { 101 + "@atproto-labs/simple-store": "0.3.0", 102 + "@atproto-labs/simple-store-memory": "0.1.4", 103 + "@atproto/did": "0.3.0", 104 + "zod": "^3.23.8" 105 + } 106 + }, 107 + "node_modules/@atproto-labs/handle-resolver-node": { 108 + "version": "0.1.25", 109 + "resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver-node/-/handle-resolver-node-0.1.25.tgz", 110 + "integrity": "sha512-NY9WYM2VLd3IuMGRkkmvGBg8xqVEaK/fitv1vD8SMXqFTekdpjOLCCyv7EFtqVHouzmDcL83VOvWRfHVa8V9Yw==", 111 + "license": "MIT", 112 + "dependencies": { 113 + "@atproto-labs/fetch-node": "0.2.0", 114 + "@atproto-labs/handle-resolver": "0.3.6", 115 + "@atproto/did": "0.3.0" 116 + }, 117 + "engines": { 118 + "node": ">=18.7.0" 119 + } 120 + }, 121 + "node_modules/@atproto-labs/identity-resolver": { 122 + "version": "0.3.6", 123 + "resolved": "https://registry.npmjs.org/@atproto-labs/identity-resolver/-/identity-resolver-0.3.6.tgz", 124 + "integrity": "sha512-qoWqBDRobln0NR8L8dQjSp79E0chGkBhibEgxQa2f9WD+JbJdjQ0YvwwO5yeQn05pJoJmAwmI2wyJ45zjU7aWg==", 125 + "license": "MIT", 126 + "dependencies": { 127 + "@atproto-labs/did-resolver": "0.2.6", 128 + "@atproto-labs/handle-resolver": "0.3.6" 129 + } 130 + }, 80 131 "node_modules/@atproto-labs/pipe": { 81 132 "version": "0.1.1", 82 133 "resolved": "https://registry.npmjs.org/@atproto-labs/pipe/-/pipe-0.1.1.tgz", ··· 117 168 } 118 169 }, 119 170 "node_modules/@atproto/common-web": { 120 - "version": "0.4.17", 121 - "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.17.tgz", 122 - "integrity": "sha512-sfxD8NGxyoxhxmM9EUshEFbWcJ3+JHEOZF4Quk6HsCh1UxpHBmLabT/vEsAkDWl+C/8U0ine0+c/gHyE/OZiQQ==", 171 + "version": "0.4.18", 172 + "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.18.tgz", 173 + "integrity": "sha512-ilImzP+9N/mtse440kN60pGrEzG7wi4xsV13nGeLrS+Zocybc/ISOpKlbZM13o+twPJ+Q7veGLw9CtGg0GAFoQ==", 123 174 "license": "MIT", 124 175 "dependencies": { 125 - "@atproto/lex-data": "^0.0.12", 126 - "@atproto/lex-json": "^0.0.12", 127 - "@atproto/syntax": "^0.4.3", 176 + "@atproto/lex-data": "^0.0.13", 177 + "@atproto/lex-json": "^0.0.13", 178 + "@atproto/syntax": "^0.5.0", 128 179 "zod": "^3.23.8" 129 180 } 130 181 }, 182 + "node_modules/@atproto/common-web/node_modules/@atproto/lex-data": { 183 + "version": "0.0.13", 184 + "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.13.tgz", 185 + "integrity": "sha512-7Z7RwZ1Y/JzBF/Tcn/I4UJ/vIGfh5zn1zjv0KX+flke2JtgFkSE8uh2hOtqgBQMNqE3zdJFM+dcSWln86hR3MQ==", 186 + "license": "MIT", 187 + "dependencies": { 188 + "multiformats": "^9.9.0", 189 + "tslib": "^2.8.1", 190 + "uint8arrays": "3.0.0", 191 + "unicode-segmenter": "^0.14.0" 192 + } 193 + }, 194 + "node_modules/@atproto/common-web/node_modules/@atproto/lex-json": { 195 + "version": "0.0.13", 196 + "resolved": "https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.0.13.tgz", 197 + "integrity": "sha512-hwLhkKaIHulGJpt0EfXAEWdrxqM2L1tV/tvilzhMp3QxPqYgXchFnrfVmLsyFDx6P6qkH1GsX/XC2V36U0UlPQ==", 198 + "license": "MIT", 199 + "dependencies": { 200 + "@atproto/lex-data": "^0.0.13", 201 + "tslib": "^2.8.1" 202 + } 203 + }, 204 + "node_modules/@atproto/common-web/node_modules/@atproto/syntax": { 205 + "version": "0.5.0", 206 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.5.0.tgz", 207 + "integrity": "sha512-UA2DSpGdOQzUQ4gi5SH+NEJz/YR3a3Fg3y2oh+xETDSiTRmA4VhHRCojhXAVsBxUT6EnItw190C/KN+DWW90kw==", 208 + "license": "MIT", 209 + "dependencies": { 210 + "tslib": "^2.8.1" 211 + } 212 + }, 131 213 "node_modules/@atproto/crypto": { 132 214 "version": "0.4.5", 133 215 "resolved": "https://registry.npmjs.org/@atproto/crypto/-/crypto-0.4.5.tgz", ··· 148 230 "integrity": "sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA==", 149 231 "license": "MIT", 150 232 "dependencies": { 233 + "zod": "^3.23.8" 234 + } 235 + }, 236 + "node_modules/@atproto/jwk": { 237 + "version": "0.6.0", 238 + "resolved": "https://registry.npmjs.org/@atproto/jwk/-/jwk-0.6.0.tgz", 239 + "integrity": "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw==", 240 + "license": "MIT", 241 + "dependencies": { 242 + "multiformats": "^9.9.0", 243 + "zod": "^3.23.8" 244 + } 245 + }, 246 + "node_modules/@atproto/jwk-jose": { 247 + "version": "0.1.11", 248 + "resolved": "https://registry.npmjs.org/@atproto/jwk-jose/-/jwk-jose-0.1.11.tgz", 249 + "integrity": "sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q==", 250 + "license": "MIT", 251 + "dependencies": { 252 + "@atproto/jwk": "0.6.0", 253 + "jose": "^5.2.0" 254 + } 255 + }, 256 + "node_modules/@atproto/jwk-webcrypto": { 257 + "version": "0.2.0", 258 + "resolved": "https://registry.npmjs.org/@atproto/jwk-webcrypto/-/jwk-webcrypto-0.2.0.tgz", 259 + "integrity": "sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg==", 260 + "license": "MIT", 261 + "dependencies": { 262 + "@atproto/jwk": "0.6.0", 263 + "@atproto/jwk-jose": "0.1.11", 151 264 "zod": "^3.23.8" 152 265 } 153 266 }, ··· 284 397 } 285 398 }, 286 399 "node_modules/@atproto/lexicon": { 287 - "version": "0.6.1", 288 - "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.6.1.tgz", 289 - "integrity": "sha512-/vI1kVlY50Si+5MXpvOucelnYwb0UJ6Qto5mCp+7Q5C+Jtp+SoSykAPVvjVtTnQUH2vrKOFOwpb3C375vSKzXw==", 400 + "version": "0.6.2", 401 + "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.6.2.tgz", 402 + "integrity": "sha512-p3Ly6hinVZW0ETuAXZMeUGwuMm3g8HvQMQ41yyEE6AL0hAkfeKFaZKos6BdBrr6CjkpbrDZqE8M+5+QOceysMw==", 290 403 "license": "MIT", 291 404 "dependencies": { 292 - "@atproto/common-web": "^0.4.13", 293 - "@atproto/syntax": "^0.4.3", 405 + "@atproto/common-web": "^0.4.18", 406 + "@atproto/syntax": "^0.5.0", 294 407 "iso-datestring-validator": "^2.2.2", 295 408 "multiformats": "^9.9.0", 296 409 "zod": "^3.23.8" 297 410 } 298 411 }, 412 + "node_modules/@atproto/lexicon/node_modules/@atproto/syntax": { 413 + "version": "0.5.0", 414 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.5.0.tgz", 415 + "integrity": "sha512-UA2DSpGdOQzUQ4gi5SH+NEJz/YR3a3Fg3y2oh+xETDSiTRmA4VhHRCojhXAVsBxUT6EnItw190C/KN+DWW90kw==", 416 + "license": "MIT", 417 + "dependencies": { 418 + "tslib": "^2.8.1" 419 + } 420 + }, 421 + "node_modules/@atproto/oauth-client": { 422 + "version": "0.6.0", 423 + "resolved": "https://registry.npmjs.org/@atproto/oauth-client/-/oauth-client-0.6.0.tgz", 424 + "integrity": "sha512-F7ZTKzFptXgyihMkd7QTdRSkrh4XqrS+qTw+V81k5Q6Bh3MB1L3ypvfSJ6v7SSUJa6XxoZYJTCahHC1e+ndE6Q==", 425 + "license": "MIT", 426 + "dependencies": { 427 + "@atproto-labs/did-resolver": "^0.2.6", 428 + "@atproto-labs/fetch": "^0.2.3", 429 + "@atproto-labs/handle-resolver": "^0.3.6", 430 + "@atproto-labs/identity-resolver": "^0.3.6", 431 + "@atproto-labs/simple-store": "^0.3.0", 432 + "@atproto-labs/simple-store-memory": "^0.1.4", 433 + "@atproto/did": "^0.3.0", 434 + "@atproto/jwk": "^0.6.0", 435 + "@atproto/oauth-types": "^0.6.3", 436 + "@atproto/xrpc": "^0.7.7", 437 + "core-js": "^3", 438 + "multiformats": "^9.9.0", 439 + "zod": "^3.23.8" 440 + } 441 + }, 442 + "node_modules/@atproto/oauth-client-node": { 443 + "version": "0.3.17", 444 + "resolved": "https://registry.npmjs.org/@atproto/oauth-client-node/-/oauth-client-node-0.3.17.tgz", 445 + "integrity": "sha512-67LNuKAlC35Exe7CB5S0QCAnEqr6fKV9Nvp64jAHFof1N+Vc9Ltt1K9oekE5Ctf7dvpGByrHRF0noUw9l9sWLA==", 446 + "license": "MIT", 447 + "dependencies": { 448 + "@atproto-labs/did-resolver": "^0.2.6", 449 + "@atproto-labs/handle-resolver-node": "^0.1.25", 450 + "@atproto-labs/simple-store": "^0.3.0", 451 + "@atproto/did": "^0.3.0", 452 + "@atproto/jwk": "^0.6.0", 453 + "@atproto/jwk-jose": "^0.1.11", 454 + "@atproto/jwk-webcrypto": "^0.2.0", 455 + "@atproto/oauth-client": "^0.6.0", 456 + "@atproto/oauth-types": "^0.6.3" 457 + }, 458 + "engines": { 459 + "node": ">=18.7.0" 460 + } 461 + }, 462 + "node_modules/@atproto/oauth-types": { 463 + "version": "0.6.3", 464 + "resolved": "https://registry.npmjs.org/@atproto/oauth-types/-/oauth-types-0.6.3.tgz", 465 + "integrity": "sha512-jdKuoPknJuh/WjI+mYk7agSbx9mNVMbS6Dr3k1z2YMY2oRiCQjxYBuo4MLKATbxj05nMQaZRWlHRUazoAu5Cng==", 466 + "license": "MIT", 467 + "dependencies": { 468 + "@atproto/did": "^0.3.0", 469 + "@atproto/jwk": "^0.6.0", 470 + "zod": "^3.23.8" 471 + } 472 + }, 299 473 "node_modules/@atproto/repo": { 300 474 "version": "0.8.12", 301 475 "resolved": "https://registry.npmjs.org/@atproto/repo/-/repo-0.8.12.tgz", ··· 323 497 "license": "MIT", 324 498 "dependencies": { 325 499 "tslib": "^2.8.1" 500 + } 501 + }, 502 + "node_modules/@atproto/xrpc": { 503 + "version": "0.7.7", 504 + "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.7.7.tgz", 505 + "integrity": "sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==", 506 + "license": "MIT", 507 + "dependencies": { 508 + "@atproto/lexicon": "^0.6.0", 509 + "zod": "^3.23.8" 326 510 } 327 511 }, 328 512 "node_modules/@atui/invalidator": { ··· 1864 2048 "dev": true, 1865 2049 "license": "MIT" 1866 2050 }, 2051 + "node_modules/cookie": { 2052 + "version": "0.7.2", 2053 + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", 2054 + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", 2055 + "license": "MIT", 2056 + "engines": { 2057 + "node": ">= 0.6" 2058 + } 2059 + }, 1867 2060 "node_modules/core-js": { 1868 2061 "version": "3.48.0", 1869 2062 "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz", ··· 2723 2916 "url": "https://opencollective.com/ioredis" 2724 2917 } 2725 2918 }, 2919 + "node_modules/ipaddr.js": { 2920 + "version": "2.3.0", 2921 + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", 2922 + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", 2923 + "license": "MIT", 2924 + "engines": { 2925 + "node": ">= 10" 2926 + } 2927 + }, 2928 + "node_modules/iron-session": { 2929 + "version": "8.0.4", 2930 + "resolved": "https://registry.npmjs.org/iron-session/-/iron-session-8.0.4.tgz", 2931 + "integrity": "sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA==", 2932 + "funding": [ 2933 + "https://github.com/sponsors/vvo", 2934 + "https://github.com/sponsors/brc-dd" 2935 + ], 2936 + "license": "MIT", 2937 + "dependencies": { 2938 + "cookie": "^0.7.2", 2939 + "iron-webcrypto": "^1.2.1", 2940 + "uncrypto": "^0.1.3" 2941 + } 2942 + }, 2943 + "node_modules/iron-webcrypto": { 2944 + "version": "1.2.1", 2945 + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", 2946 + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", 2947 + "license": "MIT", 2948 + "funding": { 2949 + "url": "https://github.com/sponsors/brc-dd" 2950 + } 2951 + }, 2726 2952 "node_modules/is-docker": { 2727 2953 "version": "2.2.1", 2728 2954 "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", ··· 2830 3056 "resolved": "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz", 2831 3057 "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==", 2832 3058 "license": "MIT" 3059 + }, 3060 + "node_modules/jose": { 3061 + "version": "5.10.0", 3062 + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", 3063 + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", 3064 + "license": "MIT", 3065 + "funding": { 3066 + "url": "https://github.com/sponsors/panva" 3067 + } 2833 3068 }, 2834 3069 "node_modules/js-yaml": { 2835 3070 "version": "4.1.1", ··· 4104 4339 "multiformats": "^9.4.2" 4105 4340 } 4106 4341 }, 4342 + "node_modules/uncrypto": { 4343 + "version": "0.1.3", 4344 + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", 4345 + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", 4346 + "license": "MIT" 4347 + }, 4348 + "node_modules/undici": { 4349 + "version": "6.23.0", 4350 + "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", 4351 + "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", 4352 + "license": "MIT", 4353 + "engines": { 4354 + "node": ">=18.17" 4355 + } 4356 + }, 4107 4357 "node_modules/undici-types": { 4108 4358 "version": "7.18.2", 4109 4359 "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", ··· 4364 4614 } 4365 4615 }, 4366 4616 "packages/@inlay/render": { 4367 - "version": "0.0.2", 4617 + "version": "0.0.7", 4368 4618 "dependencies": { 4369 4619 "@atproto/lexicon": "^0.6.1", 4370 4620 "@atproto/syntax": "^0.4.3" ··· 4373 4623 "typescript": "^5.9.0" 4374 4624 }, 4375 4625 "peerDependencies": { 4376 - "@inlay/core": ">=0.0.13" 4626 + "@inlay/core": "*" 4377 4627 } 4378 4628 }, 4379 4629 "proto": { 4380 4630 "dependencies": { 4631 + "@atproto/lex-resolver": "^0.0.15", 4632 + "@atproto/oauth-client-node": "^0.3.17", 4381 4633 "@atproto/syntax": "^0.3.0", 4382 4634 "@hono/node-server": "^1.19.9", 4383 4635 "@inlay/core": "*", 4384 4636 "@inlay/render": "*", 4385 4637 "dotenv": "^16.5.0", 4386 4638 "hono": "^4.12.2", 4387 - "ioredis": "^5.6.0" 4639 + "ioredis": "^5.6.0", 4640 + "iron-session": "^8.0.4" 4388 4641 }, 4389 4642 "devDependencies": { 4390 4643 "tsx": "^4.0.0",
+7 -1
packages/@inlay/render/src/index.ts
··· 41 41 params?: Record<string, string>; 42 42 body?: Record<string, unknown>; 43 43 componentUri?: string; 44 + personalized?: boolean; 44 45 }): Promise<unknown>; 45 46 resolveLexicon(nsid: string): Promise<unknown | null>; 46 47 } ··· 490 491 props: Record<string, unknown>, 491 492 ctx: RenderContext 492 493 ): Promise<RenderResult> { 493 - const body = component.body as { $type: string; did: string }; 494 + const body = component.body as { 495 + $type: string; 496 + did: string; 497 + personalized?: boolean; 498 + }; 494 499 const depth = ctx.depth ?? 0; 495 500 // Push this component onto the owner stack — it creates child elements. 496 501 const stack = [type, ...(ctx.stack ?? [])]; ··· 518 523 type: "procedure", 519 524 body: wireProps as Record<string, unknown>, 520 525 componentUri, 526 + personalized: body.personalized ?? false, 521 527 })) as { node: unknown; cache?: CachePolicy }; 522 528 523 529 // Restore slots — register caller context in the WeakMap.
+75
packages/@inlay/render/test/render.test.ts
··· 4039 4039 }); 4040 4040 }); 4041 4041 }); 4042 + 4043 + // ============================================================================ 4044 + // Personalized flag on external components 4045 + // ============================================================================ 4046 + 4047 + describe("personalized flag", () => { 4048 + it("forwards personalized: true to resolver.xrpc", async () => { 4049 + const greetingComponent: ComponentRecord = { 4050 + $type: "at.inlay.component", 4051 + type: Greeting, 4052 + body: { 4053 + $type: "at.inlay.component#bodyExternal", 4054 + did: SERVICE_DID, 4055 + personalized: true, 4056 + }, 4057 + imports: [HOST_PACK_URI], 4058 + }; 4059 + 4060 + let capturedPersonalized: boolean | undefined; 4061 + 4062 + const { options } = world({ 4063 + [`xrpc:${SERVICE_DID}:${Greeting}`]: () => ({ 4064 + node: $(Text, { value: "hi" }), 4065 + }), 4066 + }); 4067 + 4068 + const originalXrpc = options.resolver.xrpc; 4069 + options.resolver.xrpc = async (params) => { 4070 + capturedPersonalized = params.personalized; 4071 + return originalXrpc(params); 4072 + }; 4073 + 4074 + await renderToCompletion( 4075 + $(Greeting, { name: "world" }), 4076 + options, 4077 + createContext(greetingComponent) 4078 + ); 4079 + 4080 + assert.equal(capturedPersonalized, true); 4081 + }); 4082 + 4083 + it("defaults personalized to false when not set", async () => { 4084 + const greetingComponent: ComponentRecord = { 4085 + $type: "at.inlay.component", 4086 + type: Greeting, 4087 + body: { 4088 + $type: "at.inlay.component#bodyExternal", 4089 + did: SERVICE_DID, 4090 + }, 4091 + imports: [HOST_PACK_URI], 4092 + }; 4093 + 4094 + let capturedPersonalized: boolean | undefined; 4095 + 4096 + const { options } = world({ 4097 + [`xrpc:${SERVICE_DID}:${Greeting}`]: () => ({ 4098 + node: $(Text, { value: "hi" }), 4099 + }), 4100 + }); 4101 + 4102 + const originalXrpc = options.resolver.xrpc; 4103 + options.resolver.xrpc = async (params) => { 4104 + capturedPersonalized = params.personalized; 4105 + return originalXrpc(params); 4106 + }; 4107 + 4108 + await renderToCompletion( 4109 + $(Greeting, { name: "world" }), 4110 + options, 4111 + createContext(greetingComponent) 4112 + ); 4113 + 4114 + assert.equal(capturedPersonalized, false); 4115 + }); 4116 + });
+3 -1
proto/package.json
··· 8 8 }, 9 9 "dependencies": { 10 10 "@atproto/lex-resolver": "^0.0.15", 11 + "@atproto/oauth-client-node": "^0.3.17", 11 12 "@atproto/syntax": "^0.3.0", 12 13 "@hono/node-server": "^1.19.9", 13 14 "@inlay/core": "*", 14 15 "@inlay/render": "*", 15 16 "dotenv": "^16.5.0", 16 17 "hono": "^4.12.2", 17 - "ioredis": "^5.6.0" 18 + "ioredis": "^5.6.0", 19 + "iron-session": "^8.0.4" 18 20 }, 19 21 "devDependencies": { 20 22 "tsx": "^4.0.0",
+162
proto/src/auth.ts
··· 1 + import { 2 + NodeOAuthClient, 3 + type NodeSavedSession, 4 + type NodeSavedSessionStore, 5 + type NodeSavedState, 6 + type NodeSavedStateStore, 7 + } from "@atproto/oauth-client-node"; 8 + import { getCookie, setCookie, deleteCookie } from "hono/cookie"; 9 + import { sealData, unsealData } from "iron-session"; 10 + import type { Context } from "hono"; 11 + import Redis from "ioredis"; 12 + 13 + const SESSION_COOKIE = "inlay_session"; 14 + const SESSION_TTL = 60 * 60 * 24 * 30; // 30 days 15 + const STATE_TTL = 60 * 15; // 15 minutes 16 + 17 + let oauthClient: NodeOAuthClient | null = null; 18 + let redis: Redis | null = null; 19 + 20 + function getRedis(): Redis { 21 + if (!redis) { 22 + redis = new Redis(process.env.REDIS_URL || "redis://localhost:6379", { 23 + maxRetriesPerRequest: 3, 24 + lazyConnect: true, 25 + }); 26 + } 27 + return redis; 28 + } 29 + 30 + function getCookieSecret(): string { 31 + const secret = process.env.COOKIE_SECRET; 32 + if (!secret) { 33 + if (process.env.NODE_ENV === "production") { 34 + throw new Error("COOKIE_SECRET is required in production"); 35 + } 36 + return "development-secret-at-least-32-characters-long!!"; 37 + } 38 + return secret; 39 + } 40 + 41 + // --- Redis-backed stores --- 42 + 43 + class RedisStateStore implements NodeSavedStateStore { 44 + async get(key: string): Promise<NodeSavedState | undefined> { 45 + const data = await getRedis().get(`inlay:oauth:state:${key}`); 46 + return data ? JSON.parse(data) : undefined; 47 + } 48 + async set(key: string, val: NodeSavedState): Promise<void> { 49 + await getRedis().set( 50 + `inlay:oauth:state:${key}`, 51 + JSON.stringify(val), 52 + "EX", 53 + STATE_TTL 54 + ); 55 + } 56 + async del(key: string): Promise<void> { 57 + await getRedis().del(`inlay:oauth:state:${key}`); 58 + } 59 + } 60 + 61 + class RedisSessionStore implements NodeSavedSessionStore { 62 + async get(key: string): Promise<NodeSavedSession | undefined> { 63 + const data = await getRedis().get(`inlay:oauth:session:${key}`); 64 + return data ? JSON.parse(data) : undefined; 65 + } 66 + async set(key: string, val: NodeSavedSession): Promise<void> { 67 + await getRedis().set( 68 + `inlay:oauth:session:${key}`, 69 + JSON.stringify(val), 70 + "EX", 71 + SESSION_TTL 72 + ); 73 + } 74 + async del(key: string): Promise<void> { 75 + await getRedis().del(`inlay:oauth:session:${key}`); 76 + } 77 + } 78 + 79 + // --- OAuth client --- 80 + 81 + export async function initOAuthClient(): Promise<NodeOAuthClient> { 82 + if (oauthClient) return oauthClient; 83 + 84 + const publicUrl = process.env.PUBLIC_URL; 85 + const isLocal = !publicUrl; 86 + const loopback = "http://127.0.0.1:3001"; 87 + 88 + oauthClient = new NodeOAuthClient({ 89 + clientMetadata: { 90 + client_name: "Inlay Proto", 91 + client_id: isLocal 92 + ? `http://localhost?redirect_uri=${encodeURIComponent(`${loopback}/oauth/callback`)}&scope=${encodeURIComponent("atproto transition:generic")}` 93 + : `${publicUrl}/oauth/client-metadata.json`, 94 + redirect_uris: [ 95 + isLocal ? `${loopback}/oauth/callback` : `${publicUrl}/oauth/callback`, 96 + ], 97 + scope: "atproto transition:generic", 98 + grant_types: ["authorization_code", "refresh_token"], 99 + response_types: ["code"], 100 + application_type: "web", 101 + token_endpoint_auth_method: "none", 102 + dpop_bound_access_tokens: true, 103 + }, 104 + stateStore: new RedisStateStore(), 105 + sessionStore: new RedisSessionStore(), 106 + }); 107 + 108 + return oauthClient; 109 + } 110 + 111 + // --- Session cookie --- 112 + 113 + export async function getViewerDid(c: Context): Promise<string | null> { 114 + const sealed = getCookie(c, SESSION_COOKIE); 115 + if (!sealed) return null; 116 + try { 117 + const data = await unsealData<{ did: string }>(sealed, { 118 + password: getCookieSecret(), 119 + }); 120 + return data.did || null; 121 + } catch { 122 + return null; 123 + } 124 + } 125 + 126 + export async function setViewerDid(c: Context, did: string): Promise<void> { 127 + const sealed = await sealData({ did }, { password: getCookieSecret() }); 128 + setCookie(c, SESSION_COOKIE, sealed, { 129 + httpOnly: true, 130 + secure: process.env.NODE_ENV === "production", 131 + sameSite: "Lax", 132 + maxAge: SESSION_TTL, 133 + path: "/", 134 + }); 135 + } 136 + 137 + export async function clearViewer(c: Context): Promise<void> { 138 + deleteCookie(c, SESSION_COOKIE, { path: "/" }); 139 + } 140 + 141 + // --- Service JWT --- 142 + 143 + export async function getServiceJwt( 144 + viewerDid: string, 145 + componentDid: string, 146 + procedure: string 147 + ): Promise<string | null> { 148 + const client = await initOAuthClient(); 149 + try { 150 + const session = await client.restore(viewerDid); 151 + const params = new URLSearchParams({ aud: componentDid, lxm: procedure }); 152 + const res = await session.fetchHandler( 153 + `/xrpc/com.atproto.server.getServiceAuth?${params}` 154 + ); 155 + if (!res.ok) throw new Error(`getServiceAuth ${res.status}`); 156 + const data = (await res.json()) as { token: string }; 157 + return data.token; 158 + } catch (err) { 159 + console.error("[auth] Failed to get service JWT:", err); 160 + return null; 161 + } 162 + }
+5
proto/src/env.ts
··· 1 + // Environment variables: 2 + // REDIS_URL - Redis connection string (required) 3 + // PUBLIC_URL - Public URL, e.g. "https://inlay-proto.up.railway.app" (omit for localhost:3001) 4 + // COOKIE_SECRET - 32+ character secret for session cookies (required in production) 5 + 1 6 import dotenv from "dotenv"; 2 7 import { resolve } from "path"; 3 8 const root = resolve(process.cwd(), "..");
+83 -4
proto/src/index.tsx
··· 16 16 import { deserializeTree, $ } from "@inlay/core"; 17 17 import { setQueryString } from "./primitives.tsx"; 18 18 import { resolveDidToService } from "./resolve.ts"; 19 + import { 20 + initOAuthClient, 21 + getViewerDid, 22 + setViewerDid, 23 + clearViewer, 24 + } from "./auth.ts"; 19 25 import "./types.ts"; 20 26 21 27 import type { RenderContext } from "@inlay/render"; 22 28 23 29 const app = new Hono(); 24 30 25 - const resolver = createResolver(); 26 - const renderOptions = createRenderOptions(resolver); 31 + const oauthClient = await initOAuthClient(); 32 + 33 + // Shared resolver for non-authenticated contexts (htmx list pagination) 34 + const sharedResolver = createResolver(); 35 + const sharedOptions = createRenderOptions(sharedResolver); 27 36 28 - initRender(renderOptions); 37 + initRender(sharedOptions); 29 38 30 39 // --- Static files --- 31 40 app.use("/public/*", serveStatic({ root: "./" })); ··· 120 129 ); 121 130 } 122 131 132 + // --- OAuth routes --- 133 + app.get("/oauth/client-metadata.json", async (c) => { 134 + return c.json(oauthClient.clientMetadata); 135 + }); 136 + 137 + app.get("/login", async (c) => { 138 + const handle = c.req.query("handle"); 139 + if (!handle) { 140 + return c.html( 141 + <Shell> 142 + <div style="max-width:400px;margin:80px auto;text-align:center"> 143 + <h2>Log in</h2> 144 + <form action="/login" method="get"> 145 + <input 146 + name="handle" 147 + placeholder="alice.com" 148 + style="padding:8px 12px;font-size:16px;width:100%;box-sizing:border-box;border:1px solid var(--border);border-radius:6px" 149 + /> 150 + <button 151 + type="submit" 152 + style="margin-top:12px;padding:8px 24px;font-size:14px;cursor:pointer" 153 + > 154 + Continue 155 + </button> 156 + </form> 157 + </div> 158 + </Shell> 159 + ); 160 + } 161 + 162 + try { 163 + const url = await oauthClient.authorize(handle.trim(), { 164 + scope: "atproto transition:generic", 165 + }); 166 + return c.redirect(url.toString()); 167 + } catch (err) { 168 + const msg = err instanceof Error ? err.message : String(err); 169 + return c.html( 170 + <Shell> 171 + <div class="error">Login failed: {msg}</div> 172 + </Shell> 173 + ); 174 + } 175 + }); 176 + 177 + app.get("/oauth/callback", async (c) => { 178 + try { 179 + const params = new URLSearchParams(new URL(c.req.url).search); 180 + const { session } = await oauthClient.callback(params); 181 + await setViewerDid(c, session.did); 182 + return c.redirect("/"); 183 + } catch (err) { 184 + const msg = err instanceof Error ? err.message : String(err); 185 + return c.html( 186 + <Shell> 187 + <div class="error">OAuth callback failed: {msg}</div> 188 + </Shell> 189 + ); 190 + } 191 + }); 192 + 193 + app.get("/logout", async (c) => { 194 + await clearViewer(c); 195 + return c.redirect("/"); 196 + }); 197 + 123 198 // --- Main route --- 124 199 app.get("/at/:did/:collection/:rkey", async (c) => { 125 200 const { did, collection, rkey } = c.req.param(); ··· 135 210 } 136 211 137 212 try { 213 + const viewerDid = await getViewerDid(c); 214 + const resolver = viewerDid ? createResolver(viewerDid) : sharedResolver; 215 + const renderOptions = createRenderOptions(resolver); 216 + 138 217 const component = await resolver.fetchRecord(componentUri as any); 139 218 if (!component) { 140 219 return c.html( ··· 236 315 237 316 const resolved = await Promise.all( 238 317 page.items.map((item) => 239 - renderNode(deserializeTree(item), ctx, renderOptions) 318 + renderNode(deserializeTree(item), ctx, sharedOptions) 240 319 ) 241 320 ); 242 321 const renderedItems: JSXElement[] = [];
+23 -9
proto/src/resolver.ts
··· 2 2 import { LexResolver } from "@atproto/lex-resolver"; 3 3 import { resolveDidToService } from "./resolve.ts"; 4 4 import { cacheGet, cacheSet } from "./cache.ts"; 5 + import { getServiceJwt } from "./auth.ts"; 5 6 import type { Resolver } from "@inlay/render"; 6 7 7 8 const lexResolver = new LexResolver({}); ··· 50 51 async function callXrpc( 51 52 serviceUrl: string, 52 53 params: { 53 - did: string; 54 54 nsid: string; 55 55 type?: string; 56 56 body?: unknown; 57 57 params?: Record<string, string>; 58 - componentUri?: string; 59 - } 58 + }, 59 + jwt?: string | null 60 60 ): Promise<unknown> { 61 - const input = (params.body ?? {}) as Record<string, unknown>; 61 + const headers: Record<string, string> = {}; 62 + if (jwt) headers["Authorization"] = `Bearer ${jwt}`; 62 63 63 64 if (params.type === "query") { 64 65 const qs = new URLSearchParams(); ··· 69 70 } 70 71 const qsStr = qs.toString(); 71 72 const url = `${serviceUrl}/xrpc/${params.nsid}${qsStr ? `?${qsStr}` : ""}`; 72 - const res = await fetch(url); 73 + const res = await fetch(url, { headers }); 73 74 if (!res.ok) { 74 75 const text = await res.text().catch(() => ""); 75 76 throw new Error( ··· 79 80 return res.json(); 80 81 } 81 82 82 - // procedure 83 + headers["Content-Type"] = "application/json"; 83 84 const url = `${serviceUrl}/xrpc/${params.nsid}`; 84 85 const res = await fetch(url, { 85 86 method: "POST", 86 - headers: { "Content-Type": "application/json" }, 87 - body: JSON.stringify(input), 87 + headers, 88 + body: JSON.stringify(params.body ?? {}), 88 89 }); 89 90 if (!res.ok) { 90 91 const text = await res.text().catch(() => ""); ··· 95 96 return res.json(); 96 97 } 97 98 98 - export function createResolver(): Resolver { 99 + export function createResolver(viewerDid?: string | null): Resolver { 99 100 return { 100 101 async fetchRecord(uri) { 101 102 return fetchRecordFromPds(uri); ··· 109 110 throw new Error( 110 111 `XRPC resolve failed for ${params.nsid} (did=${params.did})` 111 112 ); 113 + } 114 + 115 + // Personalized: get service JWT, skip cache 116 + if (params.personalized && viewerDid) { 117 + const jwt = await getServiceJwt(viewerDid, params.did, params.nsid); 118 + const value = await callXrpc(serviceUrl, params, jwt); 119 + const response = value as ComponentResponse; 120 + if (response.cache) { 121 + throw new Error( 122 + `Personalized component ${params.nsid} must not return cache metadata` 123 + ); 124 + } 125 + return value; 112 126 } 113 127 114 128 if (!params.componentUri || params.type === "query") {