this repo has no description
1
fork

Configure Feed

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

initial: portable.agency — link external accounts to atproto DIDs

Pairs a self-claim on the user's PDS with a third-party attestation on portable.agency's PDS. First trial: User and Agents Discord fascinators.

+1195
+17
.env.example
··· 1 + PORT=3000 2 + PUBLIC_URL=https://portable.agency 3 + SESSION_SECRET=replace-with-random-32-bytes-hex 4 + 5 + # Generate with: node -e "import('@atproto/jwk-jose').then(m => m.JoseKey.generate().then(k => console.log(JSON.stringify(k.privateJwk))))" 6 + OAUTH_PRIVATE_KEY= 7 + 8 + ATTESTER_DID=did:plc:y564i3jkoqqb5of5bcnq4xee 9 + ATTESTER_IDENTIFIER=portable.agency 10 + ATTESTER_APP_PASSWORD= 11 + ATTESTER_PDS_URL= 12 + 13 + DISCORD_CLIENT_ID= 14 + DISCORD_CLIENT_SECRET= 15 + 16 + UA_GUILD_ID= 17 + UA_FASCINATOR_ROLE_ID=1356666056201998426
+7
.gitignore
··· 1 + node_modules/ 2 + .env 3 + .env.local 4 + *.log 5 + .DS_Store 6 + .claude/ 7 + CLAUDE.md
+58
lexicons/agency.portable.attestation.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "agency.portable.attestation", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "An attestation by a verifier that a subject atproto account is linked to an identity, community, or role in an external service. Paired with a matching agency.portable.membership record on the subject's PDS.", 8 + "key": "any", 9 + "record": { 10 + "type": "object", 11 + "required": ["subject", "service", "createdAt"], 12 + "properties": { 13 + "subject": { 14 + "type": "string", 15 + "format": "did", 16 + "description": "DID of the atproto account this attestation is about." 17 + }, 18 + "service": { 19 + "type": "ref", 20 + "ref": "#service" 21 + }, 22 + "role": { 23 + "type": "string", 24 + "description": "Optional role, rank, or status within the service or community (e.g. 'admin', 'maintainer', 'fascinator')." 25 + }, 26 + "createdAt": { 27 + "type": "string", 28 + "format": "datetime" 29 + }, 30 + "revokedAt": { 31 + "type": "string", 32 + "format": "datetime", 33 + "description": "If present, indicates when this attestation was revoked (e.g. the subject lost the role). Absence means the attestation is currently active." 34 + } 35 + } 36 + } 37 + }, 38 + "service": { 39 + "type": "object", 40 + "description": "Identifies an external service and, optionally, a specific community and identity within it.", 41 + "required": ["type"], 42 + "properties": { 43 + "type": { 44 + "type": "string", 45 + "description": "External service identifier (e.g. 'discord', 'github', 'slack')." 46 + }, 47 + "community": { 48 + "type": "string", 49 + "description": "Optional identifier for a specific group, server, workspace, or organization within the service (e.g. a Discord guild ID, a GitHub org slug)." 50 + }, 51 + "identifier": { 52 + "type": "string", 53 + "description": "Optional external identity within the service (e.g. a user ID or handle). Include when proving platform identity; may be omitted when only proving community or role membership." 54 + } 55 + } 56 + } 57 + } 58 + }
+53
lexicons/agency.portable.membership.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "agency.portable.membership", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A self-attested claim by an atproto account that it is linked to an identity, community, or role in an external service. Paired with a matching agency.portable.attestation record on the attester's PDS.", 8 + "key": "any", 9 + "record": { 10 + "type": "object", 11 + "required": ["service", "attestedBy", "createdAt"], 12 + "properties": { 13 + "service": { 14 + "type": "ref", 15 + "ref": "#service" 16 + }, 17 + "role": { 18 + "type": "string", 19 + "description": "Optional role, rank, or status within the service or community (e.g. 'admin', 'maintainer', 'fascinator')." 20 + }, 21 + "attestedBy": { 22 + "type": "string", 23 + "format": "did", 24 + "description": "DID of the attester that verified this claim." 25 + }, 26 + "createdAt": { 27 + "type": "string", 28 + "format": "datetime" 29 + } 30 + } 31 + } 32 + }, 33 + "service": { 34 + "type": "object", 35 + "description": "Identifies an external service and, optionally, a specific community and identity within it.", 36 + "required": ["type"], 37 + "properties": { 38 + "type": { 39 + "type": "string", 40 + "description": "External service identifier (e.g. 'discord', 'github', 'slack')." 41 + }, 42 + "community": { 43 + "type": "string", 44 + "description": "Optional identifier for a specific group, server, workspace, or organization within the service (e.g. a Discord guild ID, a GitHub org slug)." 45 + }, 46 + "identifier": { 47 + "type": "string", 48 + "description": "Optional external identity within the service (e.g. a user ID or handle). Include when proving platform identity; may be omitted when only proving community or role membership." 49 + } 50 + } 51 + } 52 + } 53 + }
+518
package-lock.json
··· 1 + { 2 + "name": "portable-agency-web", 3 + "version": "0.1.0", 4 + "lockfileVersion": 3, 5 + "requires": true, 6 + "packages": { 7 + "": { 8 + "name": "portable-agency-web", 9 + "version": "0.1.0", 10 + "dependencies": { 11 + "@atproto/api": "^0.13.0", 12 + "@atproto/jwk-jose": "^0.1.0", 13 + "@atproto/oauth-client-node": "^0.1.0", 14 + "@hono/node-server": "^1.13.0", 15 + "dotenv": "^16.4.7", 16 + "hono": "^4.6.0" 17 + } 18 + }, 19 + "node_modules/@atproto-labs/did-resolver": { 20 + "version": "0.1.4", 21 + "resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.1.4.tgz", 22 + "integrity": "sha512-5d+LHScS2ueYsFRjMOC3c1EwM2ui1yBVbBA0yY3MH7aydbljm5D28scsOVuymIhHwPFwcGvZbMON4PVSfpBbbQ==", 23 + "license": "MIT", 24 + "dependencies": { 25 + "@atproto-labs/fetch": "0.1.1", 26 + "@atproto-labs/pipe": "0.1.0", 27 + "@atproto-labs/simple-store": "0.1.1", 28 + "@atproto-labs/simple-store-memory": "0.1.1", 29 + "@atproto/did": "0.1.2", 30 + "zod": "^3.23.8" 31 + } 32 + }, 33 + "node_modules/@atproto-labs/fetch": { 34 + "version": "0.1.1", 35 + "resolved": "https://registry.npmjs.org/@atproto-labs/fetch/-/fetch-0.1.1.tgz", 36 + "integrity": "sha512-X1zO1MDoJzEurbWXMAe1H8EZ995Xam/aXdxhGVrXmOMyPDuvBa1oxwh/kQNZRCKcMQUbiwkk+Jfq6ZkTuvGbww==", 37 + "license": "MIT", 38 + "dependencies": { 39 + "@atproto-labs/pipe": "0.1.0" 40 + }, 41 + "optionalDependencies": { 42 + "zod": "^3.23.8" 43 + } 44 + }, 45 + "node_modules/@atproto-labs/fetch-node": { 46 + "version": "0.1.3", 47 + "resolved": "https://registry.npmjs.org/@atproto-labs/fetch-node/-/fetch-node-0.1.3.tgz", 48 + "integrity": "sha512-KX3ogPJt6dXNppWImQ9omfhrc8t73WrJaxHMphRAqQL8jXxKW5NBCTjSuwroBkJ1pj1aValBrc5NpdYu+H/9Qg==", 49 + "license": "MIT", 50 + "dependencies": { 51 + "@atproto-labs/fetch": "0.1.1", 52 + "@atproto-labs/pipe": "0.1.0", 53 + "ipaddr.js": "^2.1.0", 54 + "psl": "^1.9.0", 55 + "undici": "^6.14.1" 56 + } 57 + }, 58 + "node_modules/@atproto-labs/handle-resolver": { 59 + "version": "0.1.3", 60 + "resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver/-/handle-resolver-0.1.3.tgz", 61 + "integrity": "sha512-pUn8uqQNqMpecQjO0UWmdKhKX1NnXdLBXHRgID2g4kmhpz3hkbbec+h34uSk6wLfZnwPFaVQnGdEkyMq/tNToQ==", 62 + "license": "MIT", 63 + "dependencies": { 64 + "@atproto-labs/simple-store": "0.1.1", 65 + "@atproto-labs/simple-store-memory": "0.1.1", 66 + "@atproto/did": "0.1.2", 67 + "zod": "^3.23.8" 68 + } 69 + }, 70 + "node_modules/@atproto-labs/handle-resolver-node": { 71 + "version": "0.1.6", 72 + "resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver-node/-/handle-resolver-node-0.1.6.tgz", 73 + "integrity": "sha512-fJfPtqvo+EQ9ZNjdicQB4nuo+l8a7KvUlzW+tpTFkMQsdB017Dl0qHD7rv9SFlXMrswX5A3Dpwi/UDfGTtiltA==", 74 + "license": "MIT", 75 + "dependencies": { 76 + "@atproto-labs/fetch-node": "0.1.3", 77 + "@atproto-labs/handle-resolver": "0.1.3", 78 + "@atproto/did": "0.1.2" 79 + } 80 + }, 81 + "node_modules/@atproto-labs/identity-resolver": { 82 + "version": "0.1.4", 83 + "resolved": "https://registry.npmjs.org/@atproto-labs/identity-resolver/-/identity-resolver-0.1.4.tgz", 84 + "integrity": "sha512-uaRJsYFCRZQcw0c7S+RuziSVm5qHcK3N6uqsXz8NzYoJAE55Ah4DkQMj0rfapZmb0jyBau04RC/WoI4PSim+Aw==", 85 + "license": "MIT", 86 + "dependencies": { 87 + "@atproto-labs/did-resolver": "0.1.4", 88 + "@atproto-labs/handle-resolver": "0.1.3", 89 + "@atproto/syntax": "0.3.0" 90 + } 91 + }, 92 + "node_modules/@atproto-labs/identity-resolver/node_modules/@atproto/syntax": { 93 + "version": "0.3.0", 94 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.3.0.tgz", 95 + "integrity": "sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA==", 96 + "license": "MIT" 97 + }, 98 + "node_modules/@atproto-labs/pipe": { 99 + "version": "0.1.0", 100 + "resolved": "https://registry.npmjs.org/@atproto-labs/pipe/-/pipe-0.1.0.tgz", 101 + "integrity": "sha512-ghOqHFyJlQVFPESzlVHjKroP0tPzbmG5Jms0dNI9yLDEfL8xp4OFPWLX4f6T8mRq69wWs4nIDM3sSsFbFqLa1w==", 102 + "license": "MIT" 103 + }, 104 + "node_modules/@atproto-labs/simple-store": { 105 + "version": "0.1.1", 106 + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store/-/simple-store-0.1.1.tgz", 107 + "integrity": "sha512-WKILW2b3QbAYKh+w5U2x6p5FqqLl0nAeLwGeDY+KjX01K4Dq3vQTR9b/qNp0jZm48CabPQVrqCv0PPU9LgRRRg==", 108 + "license": "MIT" 109 + }, 110 + "node_modules/@atproto-labs/simple-store-memory": { 111 + "version": "0.1.1", 112 + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store-memory/-/simple-store-memory-0.1.1.tgz", 113 + "integrity": "sha512-PCRqhnZ8NBNBvLku53O56T0lsVOtclfIrQU/rwLCc4+p45/SBPrRYNBi6YFq5rxZbK6Njos9MCmILV/KLQxrWA==", 114 + "license": "MIT", 115 + "dependencies": { 116 + "@atproto-labs/simple-store": "0.1.1", 117 + "lru-cache": "^10.2.0" 118 + } 119 + }, 120 + "node_modules/@atproto/api": { 121 + "version": "0.13.35", 122 + "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.13.35.tgz", 123 + "integrity": "sha512-vsEfBj0C333TLjDppvTdTE0IdKlXuljKSveAeI4PPx/l6eUKNnDTsYxvILtXUVzwUlTDmSRqy5O4Ryh78n1b7g==", 124 + "license": "MIT", 125 + "dependencies": { 126 + "@atproto/common-web": "^0.4.0", 127 + "@atproto/lexicon": "^0.4.6", 128 + "@atproto/syntax": "^0.3.2", 129 + "@atproto/xrpc": "^0.6.8", 130 + "await-lock": "^2.2.2", 131 + "multiformats": "^9.9.0", 132 + "tlds": "^1.234.0", 133 + "zod": "^3.23.8" 134 + } 135 + }, 136 + "node_modules/@atproto/common-web": { 137 + "version": "0.4.21", 138 + "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.21.tgz", 139 + "integrity": "sha512-Odq+wdk3YNasGCjjlpl3bCIPvqYHige5DLfMkIffNv/2PI/iIj5ZvAvMvJlJ59OhReKSxtpI0invx5UQPc3+fw==", 140 + "license": "MIT", 141 + "dependencies": { 142 + "@atproto/lex-data": "^0.0.15", 143 + "@atproto/lex-json": "^0.0.16", 144 + "@atproto/syntax": "^0.5.4", 145 + "zod": "^3.23.8" 146 + } 147 + }, 148 + "node_modules/@atproto/common-web/node_modules/@atproto/syntax": { 149 + "version": "0.5.4", 150 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.5.4.tgz", 151 + "integrity": "sha512-9XJOpMAgsGFxMEIp8nJ8AIWv+krrY1xQMj+wULbbXhQztQV+9aZ0TbG9Jtn3Op2or8Kr6OqyWR4ga9Z189kKDw==", 152 + "license": "MIT", 153 + "dependencies": { 154 + "tslib": "^2.8.1" 155 + } 156 + }, 157 + "node_modules/@atproto/did": { 158 + "version": "0.1.2", 159 + "resolved": "https://registry.npmjs.org/@atproto/did/-/did-0.1.2.tgz", 160 + "integrity": "sha512-gmY1SyAuqfmsFbIXkUIScfnULqn39FoUNz4oE0fUuMu9in6PEyoxlmD2lAo7Q3KMy3X/hvTn2u5f8W/2KuDg1w==", 161 + "license": "MIT", 162 + "dependencies": { 163 + "zod": "^3.23.8" 164 + } 165 + }, 166 + "node_modules/@atproto/jwk": { 167 + "version": "0.6.0", 168 + "resolved": "https://registry.npmjs.org/@atproto/jwk/-/jwk-0.6.0.tgz", 169 + "integrity": "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw==", 170 + "license": "MIT", 171 + "dependencies": { 172 + "multiformats": "^9.9.0", 173 + "zod": "^3.23.8" 174 + } 175 + }, 176 + "node_modules/@atproto/jwk-jose": { 177 + "version": "0.1.11", 178 + "resolved": "https://registry.npmjs.org/@atproto/jwk-jose/-/jwk-jose-0.1.11.tgz", 179 + "integrity": "sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q==", 180 + "license": "MIT", 181 + "dependencies": { 182 + "@atproto/jwk": "0.6.0", 183 + "jose": "^5.2.0" 184 + } 185 + }, 186 + "node_modules/@atproto/jwk-webcrypto": { 187 + "version": "0.1.2", 188 + "resolved": "https://registry.npmjs.org/@atproto/jwk-webcrypto/-/jwk-webcrypto-0.1.2.tgz", 189 + "integrity": "sha512-vTBUbUZXh0GI+6KJiPGukmI4BQEHFAij8fJJ4WnReF/hefAs3ISZtrWZHGBebz+q2EcExYlnhhlmxvDzV7veGw==", 190 + "license": "MIT", 191 + "dependencies": { 192 + "@atproto/jwk": "0.1.1", 193 + "@atproto/jwk-jose": "0.1.2" 194 + } 195 + }, 196 + "node_modules/@atproto/jwk-webcrypto/node_modules/@atproto/jwk": { 197 + "version": "0.1.1", 198 + "resolved": "https://registry.npmjs.org/@atproto/jwk/-/jwk-0.1.1.tgz", 199 + "integrity": "sha512-6h/bj1APUk7QcV9t/oA6+9DB5NZx9SZru9x+/pV5oHFI9Xz4ZuM5+dq1PfsJV54pZyqdnZ6W6M717cxoC7q7og==", 200 + "license": "MIT", 201 + "dependencies": { 202 + "multiformats": "^9.9.0", 203 + "zod": "^3.23.8" 204 + } 205 + }, 206 + "node_modules/@atproto/jwk-webcrypto/node_modules/@atproto/jwk-jose": { 207 + "version": "0.1.2", 208 + "resolved": "https://registry.npmjs.org/@atproto/jwk-jose/-/jwk-jose-0.1.2.tgz", 209 + "integrity": "sha512-lDwc/6lLn2aZ/JpyyggyjLFsJPMntrVzryyGUx5aNpuTS8SIuc4Ky0REhxqfLopQXJJZCuRRjagHG3uP05/moQ==", 210 + "license": "MIT", 211 + "dependencies": { 212 + "@atproto/jwk": "0.1.1", 213 + "jose": "^5.2.0" 214 + } 215 + }, 216 + "node_modules/@atproto/lex-data": { 217 + "version": "0.0.15", 218 + "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.15.tgz", 219 + "integrity": "sha512-ZsbGiaM5S3CnGrcTMbDGON3bLZzCi/Mx9UvcMREKSRujnF68eHgMiXxJqvykP7+QpOX6tYCK93axZkuJVhtSEw==", 220 + "license": "MIT", 221 + "dependencies": { 222 + "multiformats": "^9.9.0", 223 + "tslib": "^2.8.1", 224 + "uint8arrays": "3.0.0", 225 + "unicode-segmenter": "^0.14.0" 226 + } 227 + }, 228 + "node_modules/@atproto/lex-json": { 229 + "version": "0.0.16", 230 + "resolved": "https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.0.16.tgz", 231 + "integrity": "sha512-IgLgQ0krshVlrIYZ+heTBDbCnM3LmAgWvsaYn5MxvKA3LcBot3PG3ptdO8VOweVZ+WgCLuo39cz9EbUmIbqdtg==", 232 + "license": "MIT", 233 + "dependencies": { 234 + "@atproto/lex-data": "^0.0.15", 235 + "tslib": "^2.8.1" 236 + } 237 + }, 238 + "node_modules/@atproto/lexicon": { 239 + "version": "0.4.14", 240 + "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.4.14.tgz", 241 + "integrity": "sha512-jiKpmH1QER3Gvc7JVY5brwrfo+etFoe57tKPQX/SmPwjvUsFnJAow5xLIryuBaJgFAhnTZViXKs41t//pahGHQ==", 242 + "license": "MIT", 243 + "dependencies": { 244 + "@atproto/common-web": "^0.4.2", 245 + "@atproto/syntax": "^0.4.0", 246 + "iso-datestring-validator": "^2.2.2", 247 + "multiformats": "^9.9.0", 248 + "zod": "^3.23.8" 249 + } 250 + }, 251 + "node_modules/@atproto/lexicon/node_modules/@atproto/syntax": { 252 + "version": "0.4.3", 253 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.3.tgz", 254 + "integrity": "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==", 255 + "license": "MIT", 256 + "dependencies": { 257 + "tslib": "^2.8.1" 258 + } 259 + }, 260 + "node_modules/@atproto/oauth-client": { 261 + "version": "0.2.2", 262 + "resolved": "https://registry.npmjs.org/@atproto/oauth-client/-/oauth-client-0.2.2.tgz", 263 + "integrity": "sha512-hYL7Hx2h52zeC1WZeFjV9FFfqt8PZnURKofz0VJVeiPqB9wySJ46MgFVxsZ3t08c03WSDugujEz2JlhtQpS7Zg==", 264 + "license": "MIT", 265 + "dependencies": { 266 + "@atproto-labs/did-resolver": "0.1.4", 267 + "@atproto-labs/fetch": "0.1.1", 268 + "@atproto-labs/handle-resolver": "0.1.3", 269 + "@atproto-labs/identity-resolver": "0.1.4", 270 + "@atproto-labs/simple-store": "0.1.1", 271 + "@atproto-labs/simple-store-memory": "0.1.1", 272 + "@atproto/did": "0.1.2", 273 + "@atproto/jwk": "0.1.1", 274 + "@atproto/oauth-types": "0.1.5", 275 + "@atproto/xrpc": "0.6.3", 276 + "multiformats": "^9.9.0", 277 + "zod": "^3.23.8" 278 + } 279 + }, 280 + "node_modules/@atproto/oauth-client-node": { 281 + "version": "0.1.4", 282 + "resolved": "https://registry.npmjs.org/@atproto/oauth-client-node/-/oauth-client-node-0.1.4.tgz", 283 + "integrity": "sha512-LKLBrvJL6gd6YqbeZsm9BZ1gqrMDKtz/77TNNjPBP+RBIV6e6Oj5uJmFMshzyJj87Rwd7Menb0niz6LhlKv/rg==", 284 + "license": "MIT", 285 + "dependencies": { 286 + "@atproto-labs/did-resolver": "0.1.4", 287 + "@atproto-labs/handle-resolver-node": "0.1.6", 288 + "@atproto-labs/simple-store": "0.1.1", 289 + "@atproto/did": "0.1.2", 290 + "@atproto/jwk": "0.1.1", 291 + "@atproto/jwk-jose": "0.1.2", 292 + "@atproto/jwk-webcrypto": "0.1.2", 293 + "@atproto/oauth-client": "0.2.2", 294 + "@atproto/oauth-types": "0.1.5" 295 + } 296 + }, 297 + "node_modules/@atproto/oauth-client-node/node_modules/@atproto/jwk": { 298 + "version": "0.1.1", 299 + "resolved": "https://registry.npmjs.org/@atproto/jwk/-/jwk-0.1.1.tgz", 300 + "integrity": "sha512-6h/bj1APUk7QcV9t/oA6+9DB5NZx9SZru9x+/pV5oHFI9Xz4ZuM5+dq1PfsJV54pZyqdnZ6W6M717cxoC7q7og==", 301 + "license": "MIT", 302 + "dependencies": { 303 + "multiformats": "^9.9.0", 304 + "zod": "^3.23.8" 305 + } 306 + }, 307 + "node_modules/@atproto/oauth-client-node/node_modules/@atproto/jwk-jose": { 308 + "version": "0.1.2", 309 + "resolved": "https://registry.npmjs.org/@atproto/jwk-jose/-/jwk-jose-0.1.2.tgz", 310 + "integrity": "sha512-lDwc/6lLn2aZ/JpyyggyjLFsJPMntrVzryyGUx5aNpuTS8SIuc4Ky0REhxqfLopQXJJZCuRRjagHG3uP05/moQ==", 311 + "license": "MIT", 312 + "dependencies": { 313 + "@atproto/jwk": "0.1.1", 314 + "jose": "^5.2.0" 315 + } 316 + }, 317 + "node_modules/@atproto/oauth-client/node_modules/@atproto/jwk": { 318 + "version": "0.1.1", 319 + "resolved": "https://registry.npmjs.org/@atproto/jwk/-/jwk-0.1.1.tgz", 320 + "integrity": "sha512-6h/bj1APUk7QcV9t/oA6+9DB5NZx9SZru9x+/pV5oHFI9Xz4ZuM5+dq1PfsJV54pZyqdnZ6W6M717cxoC7q7og==", 321 + "license": "MIT", 322 + "dependencies": { 323 + "multiformats": "^9.9.0", 324 + "zod": "^3.23.8" 325 + } 326 + }, 327 + "node_modules/@atproto/oauth-client/node_modules/@atproto/xrpc": { 328 + "version": "0.6.3", 329 + "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.6.3.tgz", 330 + "integrity": "sha512-S3tRvOdA9amPkKLll3rc4vphlDitLrkN5TwWh5Tu/jzk7mnobVVE3akYgICV9XCNHKjWM+IAPxFFI2qi+VW6nQ==", 331 + "license": "MIT", 332 + "dependencies": { 333 + "@atproto/lexicon": "^0.4.2", 334 + "zod": "^3.23.8" 335 + } 336 + }, 337 + "node_modules/@atproto/oauth-types": { 338 + "version": "0.1.5", 339 + "resolved": "https://registry.npmjs.org/@atproto/oauth-types/-/oauth-types-0.1.5.tgz", 340 + "integrity": "sha512-vNab/6BYUQCfmfhGc3G61EcatQxvh2d41FDWqR8CAYsblNXO6nOEVXn7cXdQUkb3K49LU0vy5Jf1+wFNcpY3IQ==", 341 + "license": "MIT", 342 + "dependencies": { 343 + "@atproto/jwk": "0.1.1", 344 + "zod": "^3.23.8" 345 + } 346 + }, 347 + "node_modules/@atproto/oauth-types/node_modules/@atproto/jwk": { 348 + "version": "0.1.1", 349 + "resolved": "https://registry.npmjs.org/@atproto/jwk/-/jwk-0.1.1.tgz", 350 + "integrity": "sha512-6h/bj1APUk7QcV9t/oA6+9DB5NZx9SZru9x+/pV5oHFI9Xz4ZuM5+dq1PfsJV54pZyqdnZ6W6M717cxoC7q7og==", 351 + "license": "MIT", 352 + "dependencies": { 353 + "multiformats": "^9.9.0", 354 + "zod": "^3.23.8" 355 + } 356 + }, 357 + "node_modules/@atproto/syntax": { 358 + "version": "0.3.4", 359 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.3.4.tgz", 360 + "integrity": "sha512-8CNmi5DipOLaVeSMPggMe7FCksVag0aO6XZy9WflbduTKM4dFZVCs4686UeMLfGRXX+X966XgwECHoLYrovMMg==", 361 + "license": "MIT" 362 + }, 363 + "node_modules/@atproto/xrpc": { 364 + "version": "0.6.12", 365 + "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.6.12.tgz", 366 + "integrity": "sha512-Ut3iISNLujlmY9Gu8sNU+SPDJDvqlVzWddU8qUr0Yae5oD4SguaUFjjhireMGhQ3M5E0KljQgDbTmnBo1kIZ3w==", 367 + "license": "MIT", 368 + "dependencies": { 369 + "@atproto/lexicon": "^0.4.10", 370 + "zod": "^3.23.8" 371 + } 372 + }, 373 + "node_modules/@hono/node-server": { 374 + "version": "1.19.14", 375 + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", 376 + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", 377 + "license": "MIT", 378 + "engines": { 379 + "node": ">=18.14.1" 380 + }, 381 + "peerDependencies": { 382 + "hono": "^4" 383 + } 384 + }, 385 + "node_modules/await-lock": { 386 + "version": "2.2.2", 387 + "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz", 388 + "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==", 389 + "license": "MIT" 390 + }, 391 + "node_modules/dotenv": { 392 + "version": "16.6.1", 393 + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", 394 + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", 395 + "license": "BSD-2-Clause", 396 + "engines": { 397 + "node": ">=12" 398 + }, 399 + "funding": { 400 + "url": "https://dotenvx.com" 401 + } 402 + }, 403 + "node_modules/hono": { 404 + "version": "4.12.14", 405 + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", 406 + "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", 407 + "license": "MIT", 408 + "engines": { 409 + "node": ">=16.9.0" 410 + } 411 + }, 412 + "node_modules/ipaddr.js": { 413 + "version": "2.3.0", 414 + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", 415 + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", 416 + "license": "MIT", 417 + "engines": { 418 + "node": ">= 10" 419 + } 420 + }, 421 + "node_modules/iso-datestring-validator": { 422 + "version": "2.2.2", 423 + "resolved": "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz", 424 + "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==", 425 + "license": "MIT" 426 + }, 427 + "node_modules/jose": { 428 + "version": "5.10.0", 429 + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", 430 + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", 431 + "license": "MIT", 432 + "funding": { 433 + "url": "https://github.com/sponsors/panva" 434 + } 435 + }, 436 + "node_modules/lru-cache": { 437 + "version": "10.4.3", 438 + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", 439 + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", 440 + "license": "ISC" 441 + }, 442 + "node_modules/multiformats": { 443 + "version": "9.9.0", 444 + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", 445 + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 446 + "license": "(Apache-2.0 AND MIT)" 447 + }, 448 + "node_modules/psl": { 449 + "version": "1.15.0", 450 + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", 451 + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", 452 + "license": "MIT", 453 + "dependencies": { 454 + "punycode": "^2.3.1" 455 + }, 456 + "funding": { 457 + "url": "https://github.com/sponsors/lupomontero" 458 + } 459 + }, 460 + "node_modules/punycode": { 461 + "version": "2.3.1", 462 + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", 463 + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", 464 + "license": "MIT", 465 + "engines": { 466 + "node": ">=6" 467 + } 468 + }, 469 + "node_modules/tlds": { 470 + "version": "1.261.0", 471 + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", 472 + "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", 473 + "license": "MIT", 474 + "bin": { 475 + "tlds": "bin.js" 476 + } 477 + }, 478 + "node_modules/tslib": { 479 + "version": "2.8.1", 480 + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", 481 + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", 482 + "license": "0BSD" 483 + }, 484 + "node_modules/uint8arrays": { 485 + "version": "3.0.0", 486 + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz", 487 + "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==", 488 + "license": "MIT", 489 + "dependencies": { 490 + "multiformats": "^9.4.2" 491 + } 492 + }, 493 + "node_modules/undici": { 494 + "version": "6.25.0", 495 + "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", 496 + "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", 497 + "license": "MIT", 498 + "engines": { 499 + "node": ">=18.17" 500 + } 501 + }, 502 + "node_modules/unicode-segmenter": { 503 + "version": "0.14.5", 504 + "resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.5.tgz", 505 + "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==", 506 + "license": "MIT" 507 + }, 508 + "node_modules/zod": { 509 + "version": "3.25.76", 510 + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 511 + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 512 + "license": "MIT", 513 + "funding": { 514 + "url": "https://github.com/sponsors/colinhacks" 515 + } 516 + } 517 + } 518 + }
+18
package.json
··· 1 + { 2 + "name": "portable-agency-web", 3 + "version": "0.1.0", 4 + "type": "module", 5 + "main": "src/server.js", 6 + "scripts": { 7 + "start": "node src/server.js", 8 + "dev": "node --watch src/server.js" 9 + }, 10 + "dependencies": { 11 + "@atproto/api": "^0.13.0", 12 + "@atproto/jwk-jose": "^0.1.0", 13 + "@atproto/oauth-client-node": "^0.1.0", 14 + "@hono/node-server": "^1.13.0", 15 + "dotenv": "^16.4.7", 16 + "hono": "^4.6.0" 17 + } 18 + }
+41
src/attester.js
··· 1 + import { AtpAgent } from '@atproto/api'; 2 + import { attestationRkey } from './rkey.js'; 3 + 4 + let cached; 5 + 6 + const agent = async () => { 7 + if (cached) return cached; 8 + const a = new AtpAgent({ service: process.env.ATTESTER_PDS_URL }); 9 + await a.login({ 10 + identifier: process.env.ATTESTER_IDENTIFIER, 11 + password: process.env.ATTESTER_APP_PASSWORD, 12 + }); 13 + cached = a; 14 + return a; 15 + }; 16 + 17 + export const writeAttestation = async ({ subject, service, role }) => { 18 + const a = await agent(); 19 + const record = { 20 + $type: 'agency.portable.attestation', 21 + subject, 22 + service, 23 + ...(role ? { role } : {}), 24 + createdAt: new Date().toISOString(), 25 + }; 26 + return a.com.atproto.repo.putRecord({ 27 + repo: process.env.ATTESTER_DID, 28 + collection: 'agency.portable.attestation', 29 + rkey: attestationRkey({ subject, service }), 30 + record, 31 + }); 32 + }; 33 + 34 + export const deleteAttestation = async ({ rkey }) => { 35 + const a = await agent(); 36 + return a.com.atproto.repo.deleteRecord({ 37 + repo: process.env.ATTESTER_DID, 38 + collection: 'agency.portable.attestation', 39 + rkey, 40 + }); 41 + };
+40
src/cookies.js
··· 1 + import { createHmac, timingSafeEqual } from 'node:crypto'; 2 + 3 + const COOKIE_NAME = 'pa_session'; 4 + const MAX_AGE_SECONDS = 15 * 60; 5 + 6 + const sign = (payload, secret) => { 7 + const body = Buffer.from(JSON.stringify(payload)).toString('base64url'); 8 + const mac = createHmac('sha256', secret).update(body).digest('base64url'); 9 + return `${body}.${mac}`; 10 + }; 11 + 12 + const verify = (token, secret) => { 13 + const [body, mac] = token.split('.'); 14 + if (!body || !mac) return null; 15 + const expected = createHmac('sha256', secret).update(body).digest('base64url'); 16 + const a = Buffer.from(mac); 17 + const b = Buffer.from(expected); 18 + if (a.length !== b.length || !timingSafeEqual(a, b)) return null; 19 + const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')); 20 + if (payload.exp && payload.exp < Date.now() / 1000) return null; 21 + return payload; 22 + }; 23 + 24 + export const setSession = (c, payload, secret) => { 25 + const exp = Math.floor(Date.now() / 1000) + MAX_AGE_SECONDS; 26 + const token = sign({ ...payload, exp }, secret); 27 + c.header('Set-Cookie', 28 + `${COOKIE_NAME}=${token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${MAX_AGE_SECONDS}`); 29 + }; 30 + 31 + export const getSession = (c, secret) => { 32 + const raw = c.req.header('cookie') ?? ''; 33 + const match = raw.split(';').map(s => s.trim()).find(s => s.startsWith(`${COOKIE_NAME}=`)); 34 + if (!match) return null; 35 + return verify(match.slice(COOKIE_NAME.length + 1), secret); 36 + }; 37 + 38 + export const clearSession = (c) => { 39 + c.header('Set-Cookie', `${COOKIE_NAME}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`); 40 + };
+49
src/discord.js
··· 1 + const AUTH_URL = 'https://discord.com/api/oauth2/authorize'; 2 + const TOKEN_URL = 'https://discord.com/api/oauth2/token'; 3 + const API = 'https://discord.com/api'; 4 + 5 + export const authUrl = ({ state, redirectUri }) => { 6 + const params = new URLSearchParams({ 7 + client_id: process.env.DISCORD_CLIENT_ID, 8 + response_type: 'code', 9 + scope: 'identify guilds.members.read', 10 + redirect_uri: redirectUri, 11 + state, 12 + prompt: 'none', 13 + }); 14 + return `${AUTH_URL}?${params}`; 15 + }; 16 + 17 + export const exchangeCode = async ({ code, redirectUri }) => { 18 + const body = new URLSearchParams({ 19 + client_id: process.env.DISCORD_CLIENT_ID, 20 + client_secret: process.env.DISCORD_CLIENT_SECRET, 21 + grant_type: 'authorization_code', 22 + code, 23 + redirect_uri: redirectUri, 24 + }); 25 + const res = await fetch(TOKEN_URL, { 26 + method: 'POST', 27 + headers: { 'content-type': 'application/x-www-form-urlencoded' }, 28 + body, 29 + }); 30 + if (!res.ok) throw new Error(`discord token exchange failed: ${res.status}`); 31 + return res.json(); 32 + }; 33 + 34 + export const getUser = async (accessToken) => { 35 + const res = await fetch(`${API}/users/@me`, { 36 + headers: { authorization: `Bearer ${accessToken}` }, 37 + }); 38 + if (!res.ok) throw new Error(`discord user fetch failed: ${res.status}`); 39 + return res.json(); 40 + }; 41 + 42 + export const getGuildMember = async (accessToken, guildId) => { 43 + const res = await fetch(`${API}/users/@me/guilds/${guildId}/member`, { 44 + headers: { authorization: `Bearer ${accessToken}` }, 45 + }); 46 + if (res.status === 404) return null; 47 + if (!res.ok) throw new Error(`discord guild member fetch failed: ${res.status}`); 48 + return res.json(); 49 + };
+17
src/keys.js
··· 1 + import { JoseKey } from '@atproto/jwk-jose'; 2 + 3 + let cached; 4 + 5 + export const getKeyset = async () => { 6 + if (cached) return cached; 7 + const jwk = process.env.OAUTH_PRIVATE_KEY; 8 + if (!jwk) { 9 + throw new Error('OAUTH_PRIVATE_KEY env var is required. Generate one with: node -e "import(\'@atproto/jwk-jose\').then(m => m.JoseKey.generate().then(k => console.log(JSON.stringify(k.privateJwk))))"'); 10 + } 11 + const parsed = JSON.parse(jwk); 12 + parsed.kid ??= 'key0'; 13 + parsed.use ??= 'sig'; 14 + parsed.alg ??= 'ES256'; 15 + cached = [new JoseKey(parsed)]; 16 + return cached; 17 + };
+37
src/oauth.js
··· 1 + import { NodeOAuthClient } from '@atproto/oauth-client-node'; 2 + import { getKeyset } from './keys.js'; 3 + 4 + const stateStore = new Map(); 5 + const sessionStore = new Map(); 6 + 7 + export const buildOAuthClient = async ({ publicUrl }) => { 8 + const keyset = await getKeyset(); 9 + const clientId = `${publicUrl}/client-metadata.json`; 10 + return new NodeOAuthClient({ 11 + clientMetadata: { 12 + client_id: clientId, 13 + client_name: 'portable.agency', 14 + client_uri: publicUrl, 15 + redirect_uris: [`${publicUrl}/oauth/callback`], 16 + grant_types: ['authorization_code', 'refresh_token'], 17 + scope: 'atproto repo:agency.portable.membership', 18 + response_types: ['code'], 19 + application_type: 'web', 20 + token_endpoint_auth_method: 'private_key_jwt', 21 + token_endpoint_auth_signing_alg: 'ES256', 22 + dpop_bound_access_tokens: true, 23 + jwks_uri: `${publicUrl}/jwks.json`, 24 + }, 25 + keyset, 26 + stateStore: { 27 + async set(key, state) { stateStore.set(key, state); }, 28 + async get(key) { return stateStore.get(key); }, 29 + async del(key) { stateStore.delete(key); }, 30 + }, 31 + sessionStore: { 32 + async set(sub, session) { sessionStore.set(sub, session); }, 33 + async get(sub) { return sessionStore.get(sub); }, 34 + async del(sub) { sessionStore.delete(sub); }, 35 + }, 36 + }); 37 + };
+9
src/rkey.js
··· 1 + import { createHash } from 'node:crypto'; 2 + 3 + const hash = (parts) => createHash('sha256').update(parts.join('|')).digest('hex'); 4 + 5 + export const attestationRkey = ({ subject, service }) => 6 + hash([subject, service.type, service.community ?? '', service.identifier ?? '']); 7 + 8 + export const membershipRkey = ({ attestedBy, service }) => 9 + hash([attestedBy, service.type, service.community ?? '', service.identifier ?? '']);
+331
src/server.js
··· 1 + import 'dotenv/config'; 2 + import { readFile } from 'node:fs/promises'; 3 + import { randomBytes } from 'node:crypto'; 4 + import { fileURLToPath } from 'node:url'; 5 + import { dirname, join } from 'node:path'; 6 + import { Hono } from 'hono'; 7 + import { serve } from '@hono/node-server'; 8 + import { Agent } from '@atproto/api'; 9 + import { buildOAuthClient } from './oauth.js'; 10 + import { writeAttestation, deleteAttestation } from './attester.js'; 11 + import { membershipRkey, attestationRkey } from './rkey.js'; 12 + import * as discord from './discord.js'; 13 + import { setSession, getSession, clearSession } from './cookies.js'; 14 + 15 + const __dirname = dirname(fileURLToPath(import.meta.url)); 16 + const LEXICONS_DIR = join(__dirname, '..', 'lexicons'); 17 + 18 + const PUBLIC_URL = process.env.PUBLIC_URL; 19 + const SESSION_SECRET = process.env.SESSION_SECRET; 20 + const ATTESTER_DID = process.env.ATTESTER_DID; 21 + const UA_GUILD_ID = process.env.UA_GUILD_ID; 22 + const UA_FASCINATOR_ROLE_ID = process.env.UA_FASCINATOR_ROLE_ID; 23 + const DISCORD_REDIRECT = `${PUBLIC_URL}/discord/callback`; 24 + 25 + const oauth = await buildOAuthClient({ publicUrl: PUBLIC_URL }); 26 + 27 + const resolveDidDoc = async (did) => { 28 + const url = did.startsWith('did:plc:') 29 + ? `https://plc.directory/${did}` 30 + : `https://${did.replace(/^did:web:/, '')}/.well-known/did.json`; 31 + const res = await fetch(url); 32 + if (!res.ok) throw new Error(`DID doc fetch failed: ${res.status}`); 33 + const doc = await res.json(); 34 + const handle = (doc.alsoKnownAs ?? []) 35 + .find((a) => typeof a === 'string' && a.startsWith('at://'))?.slice(5); 36 + const pdsEndpoint = (doc.service ?? []) 37 + .find((s) => s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer')?.serviceEndpoint; 38 + const pds = pdsEndpoint ? new URL(pdsEndpoint).hostname : null; 39 + return { handle, pds }; 40 + }; 41 + 42 + const errorPage = (c, message) => c.html(` 43 + <!doctype html> 44 + <meta charset="utf-8"> 45 + <title>something went wrong</title> 46 + <h1>something went wrong</h1> 47 + <p>${message}</p> 48 + <p><a href="/">Start over</a></p> 49 + `, 400); 50 + 51 + const app = new Hono(); 52 + 53 + app.get('/', async (c) => { 54 + const s = getSession(c, SESSION_SECRET); 55 + const discordLinked = !!s?.discord; 56 + const atmosphereLinked = !!s?.atmosphere?.did; 57 + 58 + if (atmosphereLinked && (!s.atmosphere.handle || !s.atmosphere.pds)) { 59 + try { 60 + const { handle, pds } = await resolveDidDoc(s.atmosphere.did); 61 + if (handle) s.atmosphere.handle = handle; 62 + if (pds) s.atmosphere.pds = pds; 63 + setSession(c, { discord: s.discord, atmosphere: s.atmosphere }, SESSION_SECRET); 64 + } catch (err) { 65 + console.error('lazy did doc resolve failed', err); 66 + } 67 + } 68 + 69 + const discordCard = discordLinked ? ` 70 + <ul> 71 + <li>User: <code>${s.discord.username ?? s.discord.userId}</code></li> 72 + <li>Guild: <code>User &amp; Agents</code></li> 73 + ${s.discord.role ? `<li>Role: <code>${s.discord.role}</code></li>` : ''} 74 + </ul> 75 + <form action="/unlink" method="post"><button type="submit">Unlink</button></form> 76 + ` : ` 77 + <p><a href="/discord/start"><button type="button">Link Discord account</button></a></p> 78 + `; 79 + 80 + let atmosphereCard; 81 + if (atmosphereLinked) { 82 + atmosphereCard = ` 83 + <ul> 84 + <li>Handle: <code>${s.atmosphere.handle ?? '(unknown)'}</code></li> 85 + <li>PDS: <code>${s.atmosphere.pds ?? '(unknown)'}</code></li> 86 + <li>DID: <code>${s.atmosphere.did}</code></li> 87 + </ul> 88 + <form action="/unlink" method="post"><button type="submit">Unlink</button></form> 89 + `; 90 + } else if (discordLinked) { 91 + atmosphereCard = ` 92 + <form action="/login" method="get"> 93 + <input name="handle" placeholder="you.bsky.social" required> 94 + <button type="submit">Sign in</button> 95 + </form> 96 + <p><small>Signing in writes the link and completes the flow.</small></p> 97 + `; 98 + } else { 99 + atmosphereCard = ` 100 + <form action="/login" method="get"> 101 + <input name="handle" placeholder="you.bsky.social" disabled> 102 + <button type="submit" disabled>Sign in</button> 103 + </form> 104 + <p><small>Link a Discord account first.</small></p> 105 + `; 106 + } 107 + 108 + let recordsCard; 109 + if (atmosphereLinked && discordLinked) { 110 + const attUri = `at://${ATTESTER_DID}/agency.portable.attestation/${s.atmosphere.attRkey}`; 111 + const memUri = `at://${s.atmosphere.did}/agency.portable.membership/${s.atmosphere.memRkey}`; 112 + recordsCard = ` 113 + <ul> 114 + <li><a href="https://pdsls.dev/${attUri}" target="_blank" rel="noopener">Attestation</a> on portable.agency's PDS</li> 115 + <li><a href="https://pdsls.dev/${memUri}" target="_blank" rel="noopener">Claim</a> on your PDS</li> 116 + </ul> 117 + `; 118 + } else { 119 + recordsCard = ` 120 + <ul> 121 + <li>Attestation on portable.agency's PDS</li> 122 + <li>Claim on your PDS</li> 123 + </ul> 124 + <p><small>Written when you complete both steps.</small></p> 125 + `; 126 + } 127 + 128 + return c.html(` 129 + <!doctype html> 130 + <meta charset="utf-8"> 131 + <title>portable.agency</title> 132 + <style> 133 + body { font-family: system-ui, sans-serif; max-width: 1100px; margin: 2rem auto; padding: 0 1rem; } 134 + .rows { display: flex; flex-direction: column; gap: 1rem; } 135 + .row { display: flex; gap: 0; flex-wrap: wrap; align-items: stretch; } 136 + .card { flex: 1 1 0; min-width: 200px; border: 1px solid #ccc; border-radius: 6px; padding: 0.75rem; font-size: 0.9rem; } 137 + .card h2 { margin-top: 0; font-size: 0.95rem; } 138 + .card ul { padding-left: 1.1rem; margin: 0.25rem 0; } 139 + .card p { margin: 0.5rem 0; } 140 + .connector { flex: 0 0 1.25rem; align-self: center; height: 1px; background: #ccc; position: relative; } 141 + .connector::after { 142 + content: ''; position: absolute; right: -1px; top: 50%; 143 + width: 0; height: 0; 144 + border-top: 5px solid transparent; border-bottom: 5px solid transparent; 145 + border-left: 6px solid #ccc; transform: translateY(-50%); 146 + } 147 + code { word-break: break-all; } 148 + .how { margin-top: 2rem; color: #333; font-size: 0.95rem; } 149 + .how h2 { font-size: 1rem; margin-top: 0; } 150 + .how ol, .how ul { padding-left: 1.25rem; } 151 + .how li { margin-bottom: 0.25rem; } 152 + </style> 153 + <h1>portable.agency</h1> 154 + <p>Link your platformed accounts to an Atmosphere account.</p> 155 + <div class="rows"> 156 + <div class="row"> 157 + <div class="card"> 158 + <h2>Discord &mdash; User &amp; Agents</h2> 159 + ${discordCard} 160 + </div> 161 + <div class="connector" aria-hidden="true"></div> 162 + <div class="card"> 163 + <h2>Atmosphere account</h2> 164 + ${atmosphereCard} 165 + </div> 166 + <div class="connector" aria-hidden="true"></div> 167 + <div class="card"> 168 + <h2>Linked records</h2> 169 + ${recordsCard} 170 + </div> 171 + </div> 172 + </div> 173 + <section class="how"> 174 + <h2>How this works</h2> 175 + <p>Each linkage is stored as a <em>pair of records</em> on atproto PDSes, so it survives portable.agency disappearing and can be verified by anyone.</p> 176 + <ol> 177 + <li><strong>Link a platformed account.</strong> Authorize the external service (e.g. Discord) so we can confirm your membership and role. Nothing is written yet.</li> 178 + <li><strong>Sign in with your Atmosphere account.</strong> Fine-grained OAuth — we only request permission to write to the <code>agency.portable.membership</code> collection.</li> 179 + <li><strong>Two records are written.</strong> 180 + <ul> 181 + <li>An <strong>attestation</strong> (<code>agency.portable.attestation</code>) on portable.agency's PDS — a third-party statement that your DID owns the linked account.</li> 182 + <li>A <strong>claim</strong> (<code>agency.portable.membership</code>) on your own PDS — a self-claim naming portable.agency as the attester.</li> 183 + </ul> 184 + Both records carry the same <code>service</code> block. Matching them is the proof. 185 + </li> 186 + </ol> 187 + <p>Record keys are deterministic (hash of <code>did + type + community + identifier</code>), so re-linking the same account is idempotent, but linking multiple accounts on the same platform creates separate records. We store no session-level state beyond the in-flight OAuth handshakes.</p> 188 + </section> 189 + `); 190 + }); 191 + 192 + app.post('/unlink', async (c) => { 193 + const s = getSession(c, SESSION_SECRET); 194 + if (s?.atmosphere?.attRkey) { 195 + try { 196 + await deleteAttestation({ rkey: s.atmosphere.attRkey }); 197 + } catch (err) { 198 + console.error('attestation delete failed', err); 199 + } 200 + } 201 + clearSession(c); 202 + return c.redirect('/'); 203 + }); 204 + 205 + app.get('/client-metadata.json', (c) => c.json(oauth.clientMetadata)); 206 + 207 + app.get('/jwks.json', (c) => c.json(oauth.jwks)); 208 + 209 + app.get('/lexicons/:nsid', async (c) => { 210 + const nsid = c.req.param('nsid').replace(/\.json$/, ''); 211 + try { 212 + const body = await readFile(join(LEXICONS_DIR, `${nsid}.json`), 'utf8'); 213 + return c.body(body, 200, { 'content-type': 'application/json' }); 214 + } catch { 215 + return c.text('not found', 404); 216 + } 217 + }); 218 + 219 + app.get('/discord/start', (c) => { 220 + const discordState = randomBytes(16).toString('hex'); 221 + setSession(c, { discordState }, SESSION_SECRET); 222 + return c.redirect(discord.authUrl({ state: discordState, redirectUri: DISCORD_REDIRECT })); 223 + }); 224 + 225 + app.get('/discord/callback', async (c) => { 226 + const s = getSession(c, SESSION_SECRET); 227 + const code = c.req.query('code'); 228 + const state = c.req.query('state'); 229 + if (!code) return errorPage(c, 'Missing Discord authorization code.'); 230 + if (!s?.discordState || state !== s.discordState) { 231 + return errorPage(c, 'Discord state mismatch — possible CSRF.'); 232 + } 233 + 234 + let user, member; 235 + try { 236 + const token = await discord.exchangeCode({ code, redirectUri: DISCORD_REDIRECT }); 237 + user = await discord.getUser(token.access_token); 238 + member = await discord.getGuildMember(token.access_token, UA_GUILD_ID); 239 + } catch (err) { 240 + console.error('discord oauth failed', err); 241 + return errorPage(c, 'Could not verify Discord account.'); 242 + } 243 + 244 + if (!member) return errorPage(c, 'You are not a member of the User &amp; Agents Discord.'); 245 + 246 + const role = (member.roles ?? []).includes(UA_FASCINATOR_ROLE_ID) ? 'fascinator' : undefined; 247 + setSession(c, { 248 + discord: { 249 + userId: user.id, 250 + username: user.username, 251 + role, 252 + }, 253 + }, SESSION_SECRET); 254 + return c.redirect('/'); 255 + }); 256 + 257 + app.get('/login', async (c) => { 258 + const s = getSession(c, SESSION_SECRET); 259 + if (!s?.discord) return c.redirect('/'); 260 + const handle = c.req.query('handle'); 261 + if (!handle) return errorPage(c, 'Missing handle.'); 262 + const state = randomBytes(16).toString('hex'); 263 + const url = await oauth.authorize(handle, { state }); 264 + return c.redirect(url.toString()); 265 + }); 266 + 267 + app.get('/oauth/callback', async (c) => { 268 + const s = getSession(c, SESSION_SECRET); 269 + if (!s?.discord) { 270 + clearSession(c); 271 + return errorPage(c, 'Missing Discord linkage state. Start over.'); 272 + } 273 + 274 + let session; 275 + try { 276 + const params = new URLSearchParams(c.req.url.split('?')[1]); 277 + ({ session } = await oauth.callback(params)); 278 + } catch (err) { 279 + console.error('bsky oauth callback failed', err); 280 + clearSession(c); 281 + return errorPage(c, 'Sign-in with atmosphere account failed. Try again.'); 282 + } 283 + 284 + const service = { 285 + type: 'discord', 286 + community: UA_GUILD_ID, 287 + identifier: s.discord.userId, 288 + }; 289 + const role = s.discord.role; 290 + const attRkey = attestationRkey({ subject: session.did, service }); 291 + const memRkey = membershipRkey({ attestedBy: ATTESTER_DID, service }); 292 + const createdAt = new Date().toISOString(); 293 + 294 + try { 295 + await writeAttestation({ subject: session.did, service, role }); 296 + const bskyAgent = new Agent(session); 297 + await bskyAgent.com.atproto.repo.putRecord({ 298 + repo: session.did, 299 + collection: 'agency.portable.membership', 300 + rkey: memRkey, 301 + record: { 302 + $type: 'agency.portable.membership', 303 + service, 304 + ...(role ? { role } : {}), 305 + attestedBy: ATTESTER_DID, 306 + createdAt, 307 + }, 308 + }); 309 + } catch (err) { 310 + console.error('record write failed', err); 311 + clearSession(c); 312 + return errorPage(c, 'Could not write linkage records. Please try again.'); 313 + } 314 + 315 + let handle, pds; 316 + try { 317 + ({ handle, pds } = await resolveDidDoc(session.did)); 318 + } catch (err) { 319 + console.error('did doc resolve failed', err); 320 + } 321 + 322 + setSession(c, { 323 + discord: s.discord, 324 + atmosphere: { did: session.did, handle, pds, attRkey, memRkey }, 325 + }, SESSION_SECRET); 326 + return c.redirect('/'); 327 + }); 328 + 329 + const port = Number(process.env.PORT ?? 3000); 330 + serve({ fetch: app.fetch, port }); 331 + console.log(`portable.agency web listening on :${port}`);