This repository has no description
0

Configure Feed

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

initial: discord URL harvester + atproto destination

Bot harvests URLs from messages in any Discord server it's invited to
and routes them to per-rule destinations on the user's PDS. Web app
handles Discord+atproto OAuth, Stripe subscriptions, and rule CRUD.

Pass-through by design: only account metadata and aggregate write
counts are stored; no message content or URL data persists.

Defenses against free-rider servers: in-memory pre-filter on the hot
path, plus auto-leave for guilds with no active rules after a grace
period (default 7 days).

author
dietrich ayala
date (May 6, 2026, 5:36 PM +0200) commit 3a6dbf02
+3148
+19
.env.example
··· 1 + PORT=3000 2 + PUBLIC_URL=https://disject.portable.agency 3 + SESSION_SECRET=replace-with-random-32-bytes-hex 4 + 5 + DISCORD_TOKEN= 6 + DISCORD_CLIENT_ID= 7 + DISCORD_CLIENT_SECRET= 8 + 9 + # Generate with: npm run keygen 10 + OAUTH_PRIVATE_KEY= 11 + 12 + DB_PATH=./data/disject.db 13 + 14 + # How long the bot stays in a guild with no active rule (hours). Default 168 = 7 days. 15 + GUILD_GRACE_HOURS=168 16 + 17 + STRIPE_SECRET_KEY= 18 + STRIPE_WEBHOOK_SECRET= 19 + STRIPE_PRICE_ID=
+7
.gitignore
··· 1 + node_modules/ 2 + .env 3 + data/ 4 + *.db 5 + *.db-journal 6 + .DS_Store 7 + .claude/settings.local.json
+1540
package-lock.json
··· 1 + { 2 + "name": "disject", 3 + "version": "0.0.1", 4 + "lockfileVersion": 3, 5 + "requires": true, 6 + "packages": { 7 + "": { 8 + "name": "disject", 9 + "version": "0.0.1", 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 + "better-sqlite3": "^11.3.0", 16 + "discord.js": "^14.18.0", 17 + "dotenv": "^16.4.7", 18 + "hono": "^4.6.0", 19 + "stripe": "^17.0.0" 20 + } 21 + }, 22 + "node_modules/@atproto-labs/did-resolver": { 23 + "version": "0.1.4", 24 + "resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.1.4.tgz", 25 + "integrity": "sha512-5d+LHScS2ueYsFRjMOC3c1EwM2ui1yBVbBA0yY3MH7aydbljm5D28scsOVuymIhHwPFwcGvZbMON4PVSfpBbbQ==", 26 + "license": "MIT", 27 + "dependencies": { 28 + "@atproto-labs/fetch": "0.1.1", 29 + "@atproto-labs/pipe": "0.1.0", 30 + "@atproto-labs/simple-store": "0.1.1", 31 + "@atproto-labs/simple-store-memory": "0.1.1", 32 + "@atproto/did": "0.1.2", 33 + "zod": "^3.23.8" 34 + } 35 + }, 36 + "node_modules/@atproto-labs/fetch": { 37 + "version": "0.1.1", 38 + "resolved": "https://registry.npmjs.org/@atproto-labs/fetch/-/fetch-0.1.1.tgz", 39 + "integrity": "sha512-X1zO1MDoJzEurbWXMAe1H8EZ995Xam/aXdxhGVrXmOMyPDuvBa1oxwh/kQNZRCKcMQUbiwkk+Jfq6ZkTuvGbww==", 40 + "license": "MIT", 41 + "dependencies": { 42 + "@atproto-labs/pipe": "0.1.0" 43 + }, 44 + "optionalDependencies": { 45 + "zod": "^3.23.8" 46 + } 47 + }, 48 + "node_modules/@atproto-labs/fetch-node": { 49 + "version": "0.1.3", 50 + "resolved": "https://registry.npmjs.org/@atproto-labs/fetch-node/-/fetch-node-0.1.3.tgz", 51 + "integrity": "sha512-KX3ogPJt6dXNppWImQ9omfhrc8t73WrJaxHMphRAqQL8jXxKW5NBCTjSuwroBkJ1pj1aValBrc5NpdYu+H/9Qg==", 52 + "license": "MIT", 53 + "dependencies": { 54 + "@atproto-labs/fetch": "0.1.1", 55 + "@atproto-labs/pipe": "0.1.0", 56 + "ipaddr.js": "^2.1.0", 57 + "psl": "^1.9.0", 58 + "undici": "^6.14.1" 59 + } 60 + }, 61 + "node_modules/@atproto-labs/handle-resolver": { 62 + "version": "0.1.3", 63 + "resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver/-/handle-resolver-0.1.3.tgz", 64 + "integrity": "sha512-pUn8uqQNqMpecQjO0UWmdKhKX1NnXdLBXHRgID2g4kmhpz3hkbbec+h34uSk6wLfZnwPFaVQnGdEkyMq/tNToQ==", 65 + "license": "MIT", 66 + "dependencies": { 67 + "@atproto-labs/simple-store": "0.1.1", 68 + "@atproto-labs/simple-store-memory": "0.1.1", 69 + "@atproto/did": "0.1.2", 70 + "zod": "^3.23.8" 71 + } 72 + }, 73 + "node_modules/@atproto-labs/handle-resolver-node": { 74 + "version": "0.1.6", 75 + "resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver-node/-/handle-resolver-node-0.1.6.tgz", 76 + "integrity": "sha512-fJfPtqvo+EQ9ZNjdicQB4nuo+l8a7KvUlzW+tpTFkMQsdB017Dl0qHD7rv9SFlXMrswX5A3Dpwi/UDfGTtiltA==", 77 + "license": "MIT", 78 + "dependencies": { 79 + "@atproto-labs/fetch-node": "0.1.3", 80 + "@atproto-labs/handle-resolver": "0.1.3", 81 + "@atproto/did": "0.1.2" 82 + } 83 + }, 84 + "node_modules/@atproto-labs/identity-resolver": { 85 + "version": "0.1.4", 86 + "resolved": "https://registry.npmjs.org/@atproto-labs/identity-resolver/-/identity-resolver-0.1.4.tgz", 87 + "integrity": "sha512-uaRJsYFCRZQcw0c7S+RuziSVm5qHcK3N6uqsXz8NzYoJAE55Ah4DkQMj0rfapZmb0jyBau04RC/WoI4PSim+Aw==", 88 + "license": "MIT", 89 + "dependencies": { 90 + "@atproto-labs/did-resolver": "0.1.4", 91 + "@atproto-labs/handle-resolver": "0.1.3", 92 + "@atproto/syntax": "0.3.0" 93 + } 94 + }, 95 + "node_modules/@atproto-labs/identity-resolver/node_modules/@atproto/syntax": { 96 + "version": "0.3.0", 97 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.3.0.tgz", 98 + "integrity": "sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA==", 99 + "license": "MIT" 100 + }, 101 + "node_modules/@atproto-labs/pipe": { 102 + "version": "0.1.0", 103 + "resolved": "https://registry.npmjs.org/@atproto-labs/pipe/-/pipe-0.1.0.tgz", 104 + "integrity": "sha512-ghOqHFyJlQVFPESzlVHjKroP0tPzbmG5Jms0dNI9yLDEfL8xp4OFPWLX4f6T8mRq69wWs4nIDM3sSsFbFqLa1w==", 105 + "license": "MIT" 106 + }, 107 + "node_modules/@atproto-labs/simple-store": { 108 + "version": "0.1.1", 109 + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store/-/simple-store-0.1.1.tgz", 110 + "integrity": "sha512-WKILW2b3QbAYKh+w5U2x6p5FqqLl0nAeLwGeDY+KjX01K4Dq3vQTR9b/qNp0jZm48CabPQVrqCv0PPU9LgRRRg==", 111 + "license": "MIT" 112 + }, 113 + "node_modules/@atproto-labs/simple-store-memory": { 114 + "version": "0.1.1", 115 + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store-memory/-/simple-store-memory-0.1.1.tgz", 116 + "integrity": "sha512-PCRqhnZ8NBNBvLku53O56T0lsVOtclfIrQU/rwLCc4+p45/SBPrRYNBi6YFq5rxZbK6Njos9MCmILV/KLQxrWA==", 117 + "license": "MIT", 118 + "dependencies": { 119 + "@atproto-labs/simple-store": "0.1.1", 120 + "lru-cache": "^10.2.0" 121 + } 122 + }, 123 + "node_modules/@atproto/api": { 124 + "version": "0.13.35", 125 + "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.13.35.tgz", 126 + "integrity": "sha512-vsEfBj0C333TLjDppvTdTE0IdKlXuljKSveAeI4PPx/l6eUKNnDTsYxvILtXUVzwUlTDmSRqy5O4Ryh78n1b7g==", 127 + "license": "MIT", 128 + "dependencies": { 129 + "@atproto/common-web": "^0.4.0", 130 + "@atproto/lexicon": "^0.4.6", 131 + "@atproto/syntax": "^0.3.2", 132 + "@atproto/xrpc": "^0.6.8", 133 + "await-lock": "^2.2.2", 134 + "multiformats": "^9.9.0", 135 + "tlds": "^1.234.0", 136 + "zod": "^3.23.8" 137 + } 138 + }, 139 + "node_modules/@atproto/common-web": { 140 + "version": "0.4.21", 141 + "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.21.tgz", 142 + "integrity": "sha512-Odq+wdk3YNasGCjjlpl3bCIPvqYHige5DLfMkIffNv/2PI/iIj5ZvAvMvJlJ59OhReKSxtpI0invx5UQPc3+fw==", 143 + "license": "MIT", 144 + "dependencies": { 145 + "@atproto/lex-data": "^0.0.15", 146 + "@atproto/lex-json": "^0.0.16", 147 + "@atproto/syntax": "^0.5.4", 148 + "zod": "^3.23.8" 149 + } 150 + }, 151 + "node_modules/@atproto/common-web/node_modules/@atproto/syntax": { 152 + "version": "0.5.4", 153 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.5.4.tgz", 154 + "integrity": "sha512-9XJOpMAgsGFxMEIp8nJ8AIWv+krrY1xQMj+wULbbXhQztQV+9aZ0TbG9Jtn3Op2or8Kr6OqyWR4ga9Z189kKDw==", 155 + "license": "MIT", 156 + "dependencies": { 157 + "tslib": "^2.8.1" 158 + } 159 + }, 160 + "node_modules/@atproto/did": { 161 + "version": "0.1.2", 162 + "resolved": "https://registry.npmjs.org/@atproto/did/-/did-0.1.2.tgz", 163 + "integrity": "sha512-gmY1SyAuqfmsFbIXkUIScfnULqn39FoUNz4oE0fUuMu9in6PEyoxlmD2lAo7Q3KMy3X/hvTn2u5f8W/2KuDg1w==", 164 + "license": "MIT", 165 + "dependencies": { 166 + "zod": "^3.23.8" 167 + } 168 + }, 169 + "node_modules/@atproto/jwk": { 170 + "version": "0.6.0", 171 + "resolved": "https://registry.npmjs.org/@atproto/jwk/-/jwk-0.6.0.tgz", 172 + "integrity": "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw==", 173 + "license": "MIT", 174 + "dependencies": { 175 + "multiformats": "^9.9.0", 176 + "zod": "^3.23.8" 177 + } 178 + }, 179 + "node_modules/@atproto/jwk-jose": { 180 + "version": "0.1.11", 181 + "resolved": "https://registry.npmjs.org/@atproto/jwk-jose/-/jwk-jose-0.1.11.tgz", 182 + "integrity": "sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q==", 183 + "license": "MIT", 184 + "dependencies": { 185 + "@atproto/jwk": "0.6.0", 186 + "jose": "^5.2.0" 187 + } 188 + }, 189 + "node_modules/@atproto/jwk-webcrypto": { 190 + "version": "0.1.2", 191 + "resolved": "https://registry.npmjs.org/@atproto/jwk-webcrypto/-/jwk-webcrypto-0.1.2.tgz", 192 + "integrity": "sha512-vTBUbUZXh0GI+6KJiPGukmI4BQEHFAij8fJJ4WnReF/hefAs3ISZtrWZHGBebz+q2EcExYlnhhlmxvDzV7veGw==", 193 + "license": "MIT", 194 + "dependencies": { 195 + "@atproto/jwk": "0.1.1", 196 + "@atproto/jwk-jose": "0.1.2" 197 + } 198 + }, 199 + "node_modules/@atproto/jwk-webcrypto/node_modules/@atproto/jwk": { 200 + "version": "0.1.1", 201 + "resolved": "https://registry.npmjs.org/@atproto/jwk/-/jwk-0.1.1.tgz", 202 + "integrity": "sha512-6h/bj1APUk7QcV9t/oA6+9DB5NZx9SZru9x+/pV5oHFI9Xz4ZuM5+dq1PfsJV54pZyqdnZ6W6M717cxoC7q7og==", 203 + "license": "MIT", 204 + "dependencies": { 205 + "multiformats": "^9.9.0", 206 + "zod": "^3.23.8" 207 + } 208 + }, 209 + "node_modules/@atproto/jwk-webcrypto/node_modules/@atproto/jwk-jose": { 210 + "version": "0.1.2", 211 + "resolved": "https://registry.npmjs.org/@atproto/jwk-jose/-/jwk-jose-0.1.2.tgz", 212 + "integrity": "sha512-lDwc/6lLn2aZ/JpyyggyjLFsJPMntrVzryyGUx5aNpuTS8SIuc4Ky0REhxqfLopQXJJZCuRRjagHG3uP05/moQ==", 213 + "license": "MIT", 214 + "dependencies": { 215 + "@atproto/jwk": "0.1.1", 216 + "jose": "^5.2.0" 217 + } 218 + }, 219 + "node_modules/@atproto/lex-data": { 220 + "version": "0.0.15", 221 + "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.15.tgz", 222 + "integrity": "sha512-ZsbGiaM5S3CnGrcTMbDGON3bLZzCi/Mx9UvcMREKSRujnF68eHgMiXxJqvykP7+QpOX6tYCK93axZkuJVhtSEw==", 223 + "license": "MIT", 224 + "dependencies": { 225 + "multiformats": "^9.9.0", 226 + "tslib": "^2.8.1", 227 + "uint8arrays": "3.0.0", 228 + "unicode-segmenter": "^0.14.0" 229 + } 230 + }, 231 + "node_modules/@atproto/lex-json": { 232 + "version": "0.0.16", 233 + "resolved": "https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.0.16.tgz", 234 + "integrity": "sha512-IgLgQ0krshVlrIYZ+heTBDbCnM3LmAgWvsaYn5MxvKA3LcBot3PG3ptdO8VOweVZ+WgCLuo39cz9EbUmIbqdtg==", 235 + "license": "MIT", 236 + "dependencies": { 237 + "@atproto/lex-data": "^0.0.15", 238 + "tslib": "^2.8.1" 239 + } 240 + }, 241 + "node_modules/@atproto/lexicon": { 242 + "version": "0.4.14", 243 + "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.4.14.tgz", 244 + "integrity": "sha512-jiKpmH1QER3Gvc7JVY5brwrfo+etFoe57tKPQX/SmPwjvUsFnJAow5xLIryuBaJgFAhnTZViXKs41t//pahGHQ==", 245 + "license": "MIT", 246 + "dependencies": { 247 + "@atproto/common-web": "^0.4.2", 248 + "@atproto/syntax": "^0.4.0", 249 + "iso-datestring-validator": "^2.2.2", 250 + "multiformats": "^9.9.0", 251 + "zod": "^3.23.8" 252 + } 253 + }, 254 + "node_modules/@atproto/lexicon/node_modules/@atproto/syntax": { 255 + "version": "0.4.3", 256 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.3.tgz", 257 + "integrity": "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==", 258 + "license": "MIT", 259 + "dependencies": { 260 + "tslib": "^2.8.1" 261 + } 262 + }, 263 + "node_modules/@atproto/oauth-client": { 264 + "version": "0.2.2", 265 + "resolved": "https://registry.npmjs.org/@atproto/oauth-client/-/oauth-client-0.2.2.tgz", 266 + "integrity": "sha512-hYL7Hx2h52zeC1WZeFjV9FFfqt8PZnURKofz0VJVeiPqB9wySJ46MgFVxsZ3t08c03WSDugujEz2JlhtQpS7Zg==", 267 + "license": "MIT", 268 + "dependencies": { 269 + "@atproto-labs/did-resolver": "0.1.4", 270 + "@atproto-labs/fetch": "0.1.1", 271 + "@atproto-labs/handle-resolver": "0.1.3", 272 + "@atproto-labs/identity-resolver": "0.1.4", 273 + "@atproto-labs/simple-store": "0.1.1", 274 + "@atproto-labs/simple-store-memory": "0.1.1", 275 + "@atproto/did": "0.1.2", 276 + "@atproto/jwk": "0.1.1", 277 + "@atproto/oauth-types": "0.1.5", 278 + "@atproto/xrpc": "0.6.3", 279 + "multiformats": "^9.9.0", 280 + "zod": "^3.23.8" 281 + } 282 + }, 283 + "node_modules/@atproto/oauth-client-node": { 284 + "version": "0.1.4", 285 + "resolved": "https://registry.npmjs.org/@atproto/oauth-client-node/-/oauth-client-node-0.1.4.tgz", 286 + "integrity": "sha512-LKLBrvJL6gd6YqbeZsm9BZ1gqrMDKtz/77TNNjPBP+RBIV6e6Oj5uJmFMshzyJj87Rwd7Menb0niz6LhlKv/rg==", 287 + "license": "MIT", 288 + "dependencies": { 289 + "@atproto-labs/did-resolver": "0.1.4", 290 + "@atproto-labs/handle-resolver-node": "0.1.6", 291 + "@atproto-labs/simple-store": "0.1.1", 292 + "@atproto/did": "0.1.2", 293 + "@atproto/jwk": "0.1.1", 294 + "@atproto/jwk-jose": "0.1.2", 295 + "@atproto/jwk-webcrypto": "0.1.2", 296 + "@atproto/oauth-client": "0.2.2", 297 + "@atproto/oauth-types": "0.1.5" 298 + } 299 + }, 300 + "node_modules/@atproto/oauth-client-node/node_modules/@atproto/jwk": { 301 + "version": "0.1.1", 302 + "resolved": "https://registry.npmjs.org/@atproto/jwk/-/jwk-0.1.1.tgz", 303 + "integrity": "sha512-6h/bj1APUk7QcV9t/oA6+9DB5NZx9SZru9x+/pV5oHFI9Xz4ZuM5+dq1PfsJV54pZyqdnZ6W6M717cxoC7q7og==", 304 + "license": "MIT", 305 + "dependencies": { 306 + "multiformats": "^9.9.0", 307 + "zod": "^3.23.8" 308 + } 309 + }, 310 + "node_modules/@atproto/oauth-client-node/node_modules/@atproto/jwk-jose": { 311 + "version": "0.1.2", 312 + "resolved": "https://registry.npmjs.org/@atproto/jwk-jose/-/jwk-jose-0.1.2.tgz", 313 + "integrity": "sha512-lDwc/6lLn2aZ/JpyyggyjLFsJPMntrVzryyGUx5aNpuTS8SIuc4Ky0REhxqfLopQXJJZCuRRjagHG3uP05/moQ==", 314 + "license": "MIT", 315 + "dependencies": { 316 + "@atproto/jwk": "0.1.1", 317 + "jose": "^5.2.0" 318 + } 319 + }, 320 + "node_modules/@atproto/oauth-client/node_modules/@atproto/jwk": { 321 + "version": "0.1.1", 322 + "resolved": "https://registry.npmjs.org/@atproto/jwk/-/jwk-0.1.1.tgz", 323 + "integrity": "sha512-6h/bj1APUk7QcV9t/oA6+9DB5NZx9SZru9x+/pV5oHFI9Xz4ZuM5+dq1PfsJV54pZyqdnZ6W6M717cxoC7q7og==", 324 + "license": "MIT", 325 + "dependencies": { 326 + "multiformats": "^9.9.0", 327 + "zod": "^3.23.8" 328 + } 329 + }, 330 + "node_modules/@atproto/oauth-client/node_modules/@atproto/xrpc": { 331 + "version": "0.6.3", 332 + "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.6.3.tgz", 333 + "integrity": "sha512-S3tRvOdA9amPkKLll3rc4vphlDitLrkN5TwWh5Tu/jzk7mnobVVE3akYgICV9XCNHKjWM+IAPxFFI2qi+VW6nQ==", 334 + "license": "MIT", 335 + "dependencies": { 336 + "@atproto/lexicon": "^0.4.2", 337 + "zod": "^3.23.8" 338 + } 339 + }, 340 + "node_modules/@atproto/oauth-types": { 341 + "version": "0.1.5", 342 + "resolved": "https://registry.npmjs.org/@atproto/oauth-types/-/oauth-types-0.1.5.tgz", 343 + "integrity": "sha512-vNab/6BYUQCfmfhGc3G61EcatQxvh2d41FDWqR8CAYsblNXO6nOEVXn7cXdQUkb3K49LU0vy5Jf1+wFNcpY3IQ==", 344 + "license": "MIT", 345 + "dependencies": { 346 + "@atproto/jwk": "0.1.1", 347 + "zod": "^3.23.8" 348 + } 349 + }, 350 + "node_modules/@atproto/oauth-types/node_modules/@atproto/jwk": { 351 + "version": "0.1.1", 352 + "resolved": "https://registry.npmjs.org/@atproto/jwk/-/jwk-0.1.1.tgz", 353 + "integrity": "sha512-6h/bj1APUk7QcV9t/oA6+9DB5NZx9SZru9x+/pV5oHFI9Xz4ZuM5+dq1PfsJV54pZyqdnZ6W6M717cxoC7q7og==", 354 + "license": "MIT", 355 + "dependencies": { 356 + "multiformats": "^9.9.0", 357 + "zod": "^3.23.8" 358 + } 359 + }, 360 + "node_modules/@atproto/syntax": { 361 + "version": "0.3.4", 362 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.3.4.tgz", 363 + "integrity": "sha512-8CNmi5DipOLaVeSMPggMe7FCksVag0aO6XZy9WflbduTKM4dFZVCs4686UeMLfGRXX+X966XgwECHoLYrovMMg==", 364 + "license": "MIT" 365 + }, 366 + "node_modules/@atproto/xrpc": { 367 + "version": "0.6.12", 368 + "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.6.12.tgz", 369 + "integrity": "sha512-Ut3iISNLujlmY9Gu8sNU+SPDJDvqlVzWddU8qUr0Yae5oD4SguaUFjjhireMGhQ3M5E0KljQgDbTmnBo1kIZ3w==", 370 + "license": "MIT", 371 + "dependencies": { 372 + "@atproto/lexicon": "^0.4.10", 373 + "zod": "^3.23.8" 374 + } 375 + }, 376 + "node_modules/@discordjs/builders": { 377 + "version": "1.14.1", 378 + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", 379 + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", 380 + "license": "Apache-2.0", 381 + "dependencies": { 382 + "@discordjs/formatters": "^0.6.2", 383 + "@discordjs/util": "^1.2.0", 384 + "@sapphire/shapeshift": "^4.0.0", 385 + "discord-api-types": "^0.38.40", 386 + "fast-deep-equal": "^3.1.3", 387 + "ts-mixer": "^6.0.4", 388 + "tslib": "^2.6.3" 389 + }, 390 + "engines": { 391 + "node": ">=16.11.0" 392 + }, 393 + "funding": { 394 + "url": "https://github.com/discordjs/discord.js?sponsor" 395 + } 396 + }, 397 + "node_modules/@discordjs/collection": { 398 + "version": "1.5.3", 399 + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", 400 + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", 401 + "license": "Apache-2.0", 402 + "engines": { 403 + "node": ">=16.11.0" 404 + } 405 + }, 406 + "node_modules/@discordjs/formatters": { 407 + "version": "0.6.2", 408 + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", 409 + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", 410 + "license": "Apache-2.0", 411 + "dependencies": { 412 + "discord-api-types": "^0.38.33" 413 + }, 414 + "engines": { 415 + "node": ">=16.11.0" 416 + }, 417 + "funding": { 418 + "url": "https://github.com/discordjs/discord.js?sponsor" 419 + } 420 + }, 421 + "node_modules/@discordjs/rest": { 422 + "version": "2.6.1", 423 + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz", 424 + "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==", 425 + "license": "Apache-2.0", 426 + "dependencies": { 427 + "@discordjs/collection": "^2.1.1", 428 + "@discordjs/util": "^1.2.0", 429 + "@sapphire/async-queue": "^1.5.3", 430 + "@sapphire/snowflake": "^3.5.5", 431 + "@vladfrangu/async_event_emitter": "^2.4.6", 432 + "discord-api-types": "^0.38.40", 433 + "magic-bytes.js": "^1.13.0", 434 + "tslib": "^2.6.3", 435 + "undici": "6.24.1" 436 + }, 437 + "engines": { 438 + "node": ">=18" 439 + }, 440 + "funding": { 441 + "url": "https://github.com/discordjs/discord.js?sponsor" 442 + } 443 + }, 444 + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { 445 + "version": "2.1.1", 446 + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", 447 + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", 448 + "license": "Apache-2.0", 449 + "engines": { 450 + "node": ">=18" 451 + }, 452 + "funding": { 453 + "url": "https://github.com/discordjs/discord.js?sponsor" 454 + } 455 + }, 456 + "node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": { 457 + "version": "3.5.5", 458 + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", 459 + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", 460 + "license": "MIT", 461 + "engines": { 462 + "node": ">=v14.0.0", 463 + "npm": ">=7.0.0" 464 + } 465 + }, 466 + "node_modules/@discordjs/util": { 467 + "version": "1.2.0", 468 + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", 469 + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", 470 + "license": "Apache-2.0", 471 + "dependencies": { 472 + "discord-api-types": "^0.38.33" 473 + }, 474 + "engines": { 475 + "node": ">=18" 476 + }, 477 + "funding": { 478 + "url": "https://github.com/discordjs/discord.js?sponsor" 479 + } 480 + }, 481 + "node_modules/@discordjs/ws": { 482 + "version": "1.2.3", 483 + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", 484 + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", 485 + "license": "Apache-2.0", 486 + "dependencies": { 487 + "@discordjs/collection": "^2.1.0", 488 + "@discordjs/rest": "^2.5.1", 489 + "@discordjs/util": "^1.1.0", 490 + "@sapphire/async-queue": "^1.5.2", 491 + "@types/ws": "^8.5.10", 492 + "@vladfrangu/async_event_emitter": "^2.2.4", 493 + "discord-api-types": "^0.38.1", 494 + "tslib": "^2.6.2", 495 + "ws": "^8.17.0" 496 + }, 497 + "engines": { 498 + "node": ">=16.11.0" 499 + }, 500 + "funding": { 501 + "url": "https://github.com/discordjs/discord.js?sponsor" 502 + } 503 + }, 504 + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { 505 + "version": "2.1.1", 506 + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", 507 + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", 508 + "license": "Apache-2.0", 509 + "engines": { 510 + "node": ">=18" 511 + }, 512 + "funding": { 513 + "url": "https://github.com/discordjs/discord.js?sponsor" 514 + } 515 + }, 516 + "node_modules/@hono/node-server": { 517 + "version": "1.19.14", 518 + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", 519 + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", 520 + "license": "MIT", 521 + "engines": { 522 + "node": ">=18.14.1" 523 + }, 524 + "peerDependencies": { 525 + "hono": "^4" 526 + } 527 + }, 528 + "node_modules/@sapphire/async-queue": { 529 + "version": "1.5.5", 530 + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", 531 + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", 532 + "license": "MIT", 533 + "engines": { 534 + "node": ">=v14.0.0", 535 + "npm": ">=7.0.0" 536 + } 537 + }, 538 + "node_modules/@sapphire/shapeshift": { 539 + "version": "4.0.0", 540 + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", 541 + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", 542 + "license": "MIT", 543 + "dependencies": { 544 + "fast-deep-equal": "^3.1.3", 545 + "lodash": "^4.17.21" 546 + }, 547 + "engines": { 548 + "node": ">=v16" 549 + } 550 + }, 551 + "node_modules/@sapphire/snowflake": { 552 + "version": "3.5.3", 553 + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", 554 + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", 555 + "license": "MIT", 556 + "engines": { 557 + "node": ">=v14.0.0", 558 + "npm": ">=7.0.0" 559 + } 560 + }, 561 + "node_modules/@types/node": { 562 + "version": "25.6.0", 563 + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", 564 + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", 565 + "license": "MIT", 566 + "dependencies": { 567 + "undici-types": "~7.19.0" 568 + } 569 + }, 570 + "node_modules/@types/ws": { 571 + "version": "8.18.1", 572 + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", 573 + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", 574 + "license": "MIT", 575 + "dependencies": { 576 + "@types/node": "*" 577 + } 578 + }, 579 + "node_modules/@vladfrangu/async_event_emitter": { 580 + "version": "2.4.7", 581 + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", 582 + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", 583 + "license": "MIT", 584 + "engines": { 585 + "node": ">=v14.0.0", 586 + "npm": ">=7.0.0" 587 + } 588 + }, 589 + "node_modules/await-lock": { 590 + "version": "2.2.2", 591 + "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz", 592 + "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==", 593 + "license": "MIT" 594 + }, 595 + "node_modules/base64-js": { 596 + "version": "1.5.1", 597 + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", 598 + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", 599 + "funding": [ 600 + { 601 + "type": "github", 602 + "url": "https://github.com/sponsors/feross" 603 + }, 604 + { 605 + "type": "patreon", 606 + "url": "https://www.patreon.com/feross" 607 + }, 608 + { 609 + "type": "consulting", 610 + "url": "https://feross.org/support" 611 + } 612 + ], 613 + "license": "MIT" 614 + }, 615 + "node_modules/better-sqlite3": { 616 + "version": "11.10.0", 617 + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", 618 + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", 619 + "hasInstallScript": true, 620 + "license": "MIT", 621 + "dependencies": { 622 + "bindings": "^1.5.0", 623 + "prebuild-install": "^7.1.1" 624 + } 625 + }, 626 + "node_modules/bindings": { 627 + "version": "1.5.0", 628 + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", 629 + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", 630 + "license": "MIT", 631 + "dependencies": { 632 + "file-uri-to-path": "1.0.0" 633 + } 634 + }, 635 + "node_modules/bl": { 636 + "version": "4.1.0", 637 + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", 638 + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", 639 + "license": "MIT", 640 + "dependencies": { 641 + "buffer": "^5.5.0", 642 + "inherits": "^2.0.4", 643 + "readable-stream": "^3.4.0" 644 + } 645 + }, 646 + "node_modules/buffer": { 647 + "version": "5.7.1", 648 + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", 649 + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", 650 + "funding": [ 651 + { 652 + "type": "github", 653 + "url": "https://github.com/sponsors/feross" 654 + }, 655 + { 656 + "type": "patreon", 657 + "url": "https://www.patreon.com/feross" 658 + }, 659 + { 660 + "type": "consulting", 661 + "url": "https://feross.org/support" 662 + } 663 + ], 664 + "license": "MIT", 665 + "dependencies": { 666 + "base64-js": "^1.3.1", 667 + "ieee754": "^1.1.13" 668 + } 669 + }, 670 + "node_modules/call-bind-apply-helpers": { 671 + "version": "1.0.2", 672 + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", 673 + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", 674 + "license": "MIT", 675 + "dependencies": { 676 + "es-errors": "^1.3.0", 677 + "function-bind": "^1.1.2" 678 + }, 679 + "engines": { 680 + "node": ">= 0.4" 681 + } 682 + }, 683 + "node_modules/call-bound": { 684 + "version": "1.0.4", 685 + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", 686 + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", 687 + "license": "MIT", 688 + "dependencies": { 689 + "call-bind-apply-helpers": "^1.0.2", 690 + "get-intrinsic": "^1.3.0" 691 + }, 692 + "engines": { 693 + "node": ">= 0.4" 694 + }, 695 + "funding": { 696 + "url": "https://github.com/sponsors/ljharb" 697 + } 698 + }, 699 + "node_modules/chownr": { 700 + "version": "1.1.4", 701 + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", 702 + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", 703 + "license": "ISC" 704 + }, 705 + "node_modules/decompress-response": { 706 + "version": "6.0.0", 707 + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", 708 + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", 709 + "license": "MIT", 710 + "dependencies": { 711 + "mimic-response": "^3.1.0" 712 + }, 713 + "engines": { 714 + "node": ">=10" 715 + }, 716 + "funding": { 717 + "url": "https://github.com/sponsors/sindresorhus" 718 + } 719 + }, 720 + "node_modules/deep-extend": { 721 + "version": "0.6.0", 722 + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", 723 + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", 724 + "license": "MIT", 725 + "engines": { 726 + "node": ">=4.0.0" 727 + } 728 + }, 729 + "node_modules/detect-libc": { 730 + "version": "2.1.2", 731 + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", 732 + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", 733 + "license": "Apache-2.0", 734 + "engines": { 735 + "node": ">=8" 736 + } 737 + }, 738 + "node_modules/discord-api-types": { 739 + "version": "0.38.47", 740 + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.47.tgz", 741 + "integrity": "sha512-XgXQodHQBAE6kfD7kMvVo30863iHX1LHSqNq6MGUTDwIFCCvHva13+rwxyxVXDqudyApMNAd32PGjgVETi5rjA==", 742 + "license": "MIT", 743 + "workspaces": [ 744 + "scripts/actions/documentation" 745 + ] 746 + }, 747 + "node_modules/discord.js": { 748 + "version": "14.26.4", 749 + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.4.tgz", 750 + "integrity": "sha512-4oBp8tc6Kf8IDBwAHhbsMaAqx1b5fob9SNasZT7V6yyyUydoO5i5fGuX7TmvRtR+q/WgKRnRViRoAWnG7fNyvA==", 751 + "license": "Apache-2.0", 752 + "dependencies": { 753 + "@discordjs/builders": "^1.14.1", 754 + "@discordjs/collection": "1.5.3", 755 + "@discordjs/formatters": "^0.6.2", 756 + "@discordjs/rest": "^2.6.1", 757 + "@discordjs/util": "^1.2.0", 758 + "@discordjs/ws": "^1.2.3", 759 + "@sapphire/snowflake": "3.5.3", 760 + "discord-api-types": "^0.38.40", 761 + "fast-deep-equal": "3.1.3", 762 + "lodash.snakecase": "4.1.1", 763 + "magic-bytes.js": "^1.13.0", 764 + "tslib": "^2.6.3", 765 + "undici": "6.24.1" 766 + }, 767 + "engines": { 768 + "node": ">=18" 769 + }, 770 + "funding": { 771 + "url": "https://github.com/discordjs/discord.js?sponsor" 772 + } 773 + }, 774 + "node_modules/dotenv": { 775 + "version": "16.6.1", 776 + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", 777 + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", 778 + "license": "BSD-2-Clause", 779 + "engines": { 780 + "node": ">=12" 781 + }, 782 + "funding": { 783 + "url": "https://dotenvx.com" 784 + } 785 + }, 786 + "node_modules/dunder-proto": { 787 + "version": "1.0.1", 788 + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", 789 + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", 790 + "license": "MIT", 791 + "dependencies": { 792 + "call-bind-apply-helpers": "^1.0.1", 793 + "es-errors": "^1.3.0", 794 + "gopd": "^1.2.0" 795 + }, 796 + "engines": { 797 + "node": ">= 0.4" 798 + } 799 + }, 800 + "node_modules/end-of-stream": { 801 + "version": "1.4.5", 802 + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", 803 + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", 804 + "license": "MIT", 805 + "dependencies": { 806 + "once": "^1.4.0" 807 + } 808 + }, 809 + "node_modules/es-define-property": { 810 + "version": "1.0.1", 811 + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", 812 + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", 813 + "license": "MIT", 814 + "engines": { 815 + "node": ">= 0.4" 816 + } 817 + }, 818 + "node_modules/es-errors": { 819 + "version": "1.3.0", 820 + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", 821 + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", 822 + "license": "MIT", 823 + "engines": { 824 + "node": ">= 0.4" 825 + } 826 + }, 827 + "node_modules/es-object-atoms": { 828 + "version": "1.1.1", 829 + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", 830 + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", 831 + "license": "MIT", 832 + "dependencies": { 833 + "es-errors": "^1.3.0" 834 + }, 835 + "engines": { 836 + "node": ">= 0.4" 837 + } 838 + }, 839 + "node_modules/expand-template": { 840 + "version": "2.0.3", 841 + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", 842 + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", 843 + "license": "(MIT OR WTFPL)", 844 + "engines": { 845 + "node": ">=6" 846 + } 847 + }, 848 + "node_modules/fast-deep-equal": { 849 + "version": "3.1.3", 850 + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", 851 + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", 852 + "license": "MIT" 853 + }, 854 + "node_modules/file-uri-to-path": { 855 + "version": "1.0.0", 856 + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", 857 + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", 858 + "license": "MIT" 859 + }, 860 + "node_modules/fs-constants": { 861 + "version": "1.0.0", 862 + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", 863 + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", 864 + "license": "MIT" 865 + }, 866 + "node_modules/function-bind": { 867 + "version": "1.1.2", 868 + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", 869 + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", 870 + "license": "MIT", 871 + "funding": { 872 + "url": "https://github.com/sponsors/ljharb" 873 + } 874 + }, 875 + "node_modules/get-intrinsic": { 876 + "version": "1.3.0", 877 + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", 878 + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", 879 + "license": "MIT", 880 + "dependencies": { 881 + "call-bind-apply-helpers": "^1.0.2", 882 + "es-define-property": "^1.0.1", 883 + "es-errors": "^1.3.0", 884 + "es-object-atoms": "^1.1.1", 885 + "function-bind": "^1.1.2", 886 + "get-proto": "^1.0.1", 887 + "gopd": "^1.2.0", 888 + "has-symbols": "^1.1.0", 889 + "hasown": "^2.0.2", 890 + "math-intrinsics": "^1.1.0" 891 + }, 892 + "engines": { 893 + "node": ">= 0.4" 894 + }, 895 + "funding": { 896 + "url": "https://github.com/sponsors/ljharb" 897 + } 898 + }, 899 + "node_modules/get-proto": { 900 + "version": "1.0.1", 901 + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", 902 + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", 903 + "license": "MIT", 904 + "dependencies": { 905 + "dunder-proto": "^1.0.1", 906 + "es-object-atoms": "^1.0.0" 907 + }, 908 + "engines": { 909 + "node": ">= 0.4" 910 + } 911 + }, 912 + "node_modules/github-from-package": { 913 + "version": "0.0.0", 914 + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", 915 + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", 916 + "license": "MIT" 917 + }, 918 + "node_modules/gopd": { 919 + "version": "1.2.0", 920 + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", 921 + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", 922 + "license": "MIT", 923 + "engines": { 924 + "node": ">= 0.4" 925 + }, 926 + "funding": { 927 + "url": "https://github.com/sponsors/ljharb" 928 + } 929 + }, 930 + "node_modules/has-symbols": { 931 + "version": "1.1.0", 932 + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", 933 + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", 934 + "license": "MIT", 935 + "engines": { 936 + "node": ">= 0.4" 937 + }, 938 + "funding": { 939 + "url": "https://github.com/sponsors/ljharb" 940 + } 941 + }, 942 + "node_modules/hasown": { 943 + "version": "2.0.3", 944 + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", 945 + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", 946 + "license": "MIT", 947 + "dependencies": { 948 + "function-bind": "^1.1.2" 949 + }, 950 + "engines": { 951 + "node": ">= 0.4" 952 + } 953 + }, 954 + "node_modules/hono": { 955 + "version": "4.12.18", 956 + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", 957 + "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", 958 + "license": "MIT", 959 + "engines": { 960 + "node": ">=16.9.0" 961 + } 962 + }, 963 + "node_modules/ieee754": { 964 + "version": "1.2.1", 965 + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", 966 + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", 967 + "funding": [ 968 + { 969 + "type": "github", 970 + "url": "https://github.com/sponsors/feross" 971 + }, 972 + { 973 + "type": "patreon", 974 + "url": "https://www.patreon.com/feross" 975 + }, 976 + { 977 + "type": "consulting", 978 + "url": "https://feross.org/support" 979 + } 980 + ], 981 + "license": "BSD-3-Clause" 982 + }, 983 + "node_modules/inherits": { 984 + "version": "2.0.4", 985 + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", 986 + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", 987 + "license": "ISC" 988 + }, 989 + "node_modules/ini": { 990 + "version": "1.3.8", 991 + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", 992 + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", 993 + "license": "ISC" 994 + }, 995 + "node_modules/ipaddr.js": { 996 + "version": "2.4.0", 997 + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", 998 + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", 999 + "license": "MIT", 1000 + "engines": { 1001 + "node": ">= 10" 1002 + } 1003 + }, 1004 + "node_modules/iso-datestring-validator": { 1005 + "version": "2.2.2", 1006 + "resolved": "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz", 1007 + "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==", 1008 + "license": "MIT" 1009 + }, 1010 + "node_modules/jose": { 1011 + "version": "5.10.0", 1012 + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", 1013 + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", 1014 + "license": "MIT", 1015 + "funding": { 1016 + "url": "https://github.com/sponsors/panva" 1017 + } 1018 + }, 1019 + "node_modules/lodash": { 1020 + "version": "4.18.1", 1021 + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", 1022 + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", 1023 + "license": "MIT" 1024 + }, 1025 + "node_modules/lodash.snakecase": { 1026 + "version": "4.1.1", 1027 + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", 1028 + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", 1029 + "license": "MIT" 1030 + }, 1031 + "node_modules/lru-cache": { 1032 + "version": "10.4.3", 1033 + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", 1034 + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", 1035 + "license": "ISC" 1036 + }, 1037 + "node_modules/magic-bytes.js": { 1038 + "version": "1.13.0", 1039 + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", 1040 + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", 1041 + "license": "MIT" 1042 + }, 1043 + "node_modules/math-intrinsics": { 1044 + "version": "1.1.0", 1045 + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", 1046 + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", 1047 + "license": "MIT", 1048 + "engines": { 1049 + "node": ">= 0.4" 1050 + } 1051 + }, 1052 + "node_modules/mimic-response": { 1053 + "version": "3.1.0", 1054 + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", 1055 + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", 1056 + "license": "MIT", 1057 + "engines": { 1058 + "node": ">=10" 1059 + }, 1060 + "funding": { 1061 + "url": "https://github.com/sponsors/sindresorhus" 1062 + } 1063 + }, 1064 + "node_modules/minimist": { 1065 + "version": "1.2.8", 1066 + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", 1067 + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", 1068 + "license": "MIT", 1069 + "funding": { 1070 + "url": "https://github.com/sponsors/ljharb" 1071 + } 1072 + }, 1073 + "node_modules/mkdirp-classic": { 1074 + "version": "0.5.3", 1075 + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", 1076 + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", 1077 + "license": "MIT" 1078 + }, 1079 + "node_modules/multiformats": { 1080 + "version": "9.9.0", 1081 + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", 1082 + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", 1083 + "license": "(Apache-2.0 AND MIT)" 1084 + }, 1085 + "node_modules/napi-build-utils": { 1086 + "version": "2.0.0", 1087 + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", 1088 + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", 1089 + "license": "MIT" 1090 + }, 1091 + "node_modules/node-abi": { 1092 + "version": "3.91.0", 1093 + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.91.0.tgz", 1094 + "integrity": "sha512-B+S7X/GS3Un6wMICtnsNjQD7oSpVBQrZftHE6GZ1Fe9/k3XOOoqbM5DZZ0GO4x3YiSCQfrM28yj1ppplwgIsfg==", 1095 + "license": "MIT", 1096 + "dependencies": { 1097 + "semver": "^7.3.5" 1098 + }, 1099 + "engines": { 1100 + "node": ">=10" 1101 + } 1102 + }, 1103 + "node_modules/object-inspect": { 1104 + "version": "1.13.4", 1105 + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", 1106 + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", 1107 + "license": "MIT", 1108 + "engines": { 1109 + "node": ">= 0.4" 1110 + }, 1111 + "funding": { 1112 + "url": "https://github.com/sponsors/ljharb" 1113 + } 1114 + }, 1115 + "node_modules/once": { 1116 + "version": "1.4.0", 1117 + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", 1118 + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", 1119 + "license": "ISC", 1120 + "dependencies": { 1121 + "wrappy": "1" 1122 + } 1123 + }, 1124 + "node_modules/prebuild-install": { 1125 + "version": "7.1.3", 1126 + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", 1127 + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", 1128 + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", 1129 + "license": "MIT", 1130 + "dependencies": { 1131 + "detect-libc": "^2.0.0", 1132 + "expand-template": "^2.0.3", 1133 + "github-from-package": "0.0.0", 1134 + "minimist": "^1.2.3", 1135 + "mkdirp-classic": "^0.5.3", 1136 + "napi-build-utils": "^2.0.0", 1137 + "node-abi": "^3.3.0", 1138 + "pump": "^3.0.0", 1139 + "rc": "^1.2.7", 1140 + "simple-get": "^4.0.0", 1141 + "tar-fs": "^2.0.0", 1142 + "tunnel-agent": "^0.6.0" 1143 + }, 1144 + "bin": { 1145 + "prebuild-install": "bin.js" 1146 + }, 1147 + "engines": { 1148 + "node": ">=10" 1149 + } 1150 + }, 1151 + "node_modules/psl": { 1152 + "version": "1.15.0", 1153 + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", 1154 + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", 1155 + "license": "MIT", 1156 + "dependencies": { 1157 + "punycode": "^2.3.1" 1158 + }, 1159 + "funding": { 1160 + "url": "https://github.com/sponsors/lupomontero" 1161 + } 1162 + }, 1163 + "node_modules/pump": { 1164 + "version": "3.0.4", 1165 + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", 1166 + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", 1167 + "license": "MIT", 1168 + "dependencies": { 1169 + "end-of-stream": "^1.1.0", 1170 + "once": "^1.3.1" 1171 + } 1172 + }, 1173 + "node_modules/punycode": { 1174 + "version": "2.3.1", 1175 + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", 1176 + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", 1177 + "license": "MIT", 1178 + "engines": { 1179 + "node": ">=6" 1180 + } 1181 + }, 1182 + "node_modules/qs": { 1183 + "version": "6.15.1", 1184 + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", 1185 + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", 1186 + "license": "BSD-3-Clause", 1187 + "dependencies": { 1188 + "side-channel": "^1.1.0" 1189 + }, 1190 + "engines": { 1191 + "node": ">=0.6" 1192 + }, 1193 + "funding": { 1194 + "url": "https://github.com/sponsors/ljharb" 1195 + } 1196 + }, 1197 + "node_modules/rc": { 1198 + "version": "1.2.8", 1199 + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", 1200 + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", 1201 + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", 1202 + "dependencies": { 1203 + "deep-extend": "^0.6.0", 1204 + "ini": "~1.3.0", 1205 + "minimist": "^1.2.0", 1206 + "strip-json-comments": "~2.0.1" 1207 + }, 1208 + "bin": { 1209 + "rc": "cli.js" 1210 + } 1211 + }, 1212 + "node_modules/readable-stream": { 1213 + "version": "3.6.2", 1214 + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", 1215 + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", 1216 + "license": "MIT", 1217 + "dependencies": { 1218 + "inherits": "^2.0.3", 1219 + "string_decoder": "^1.1.1", 1220 + "util-deprecate": "^1.0.1" 1221 + }, 1222 + "engines": { 1223 + "node": ">= 6" 1224 + } 1225 + }, 1226 + "node_modules/safe-buffer": { 1227 + "version": "5.2.1", 1228 + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", 1229 + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", 1230 + "funding": [ 1231 + { 1232 + "type": "github", 1233 + "url": "https://github.com/sponsors/feross" 1234 + }, 1235 + { 1236 + "type": "patreon", 1237 + "url": "https://www.patreon.com/feross" 1238 + }, 1239 + { 1240 + "type": "consulting", 1241 + "url": "https://feross.org/support" 1242 + } 1243 + ], 1244 + "license": "MIT" 1245 + }, 1246 + "node_modules/semver": { 1247 + "version": "7.7.4", 1248 + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", 1249 + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", 1250 + "license": "ISC", 1251 + "bin": { 1252 + "semver": "bin/semver.js" 1253 + }, 1254 + "engines": { 1255 + "node": ">=10" 1256 + } 1257 + }, 1258 + "node_modules/side-channel": { 1259 + "version": "1.1.0", 1260 + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", 1261 + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", 1262 + "license": "MIT", 1263 + "dependencies": { 1264 + "es-errors": "^1.3.0", 1265 + "object-inspect": "^1.13.3", 1266 + "side-channel-list": "^1.0.0", 1267 + "side-channel-map": "^1.0.1", 1268 + "side-channel-weakmap": "^1.0.2" 1269 + }, 1270 + "engines": { 1271 + "node": ">= 0.4" 1272 + }, 1273 + "funding": { 1274 + "url": "https://github.com/sponsors/ljharb" 1275 + } 1276 + }, 1277 + "node_modules/side-channel-list": { 1278 + "version": "1.0.1", 1279 + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", 1280 + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", 1281 + "license": "MIT", 1282 + "dependencies": { 1283 + "es-errors": "^1.3.0", 1284 + "object-inspect": "^1.13.4" 1285 + }, 1286 + "engines": { 1287 + "node": ">= 0.4" 1288 + }, 1289 + "funding": { 1290 + "url": "https://github.com/sponsors/ljharb" 1291 + } 1292 + }, 1293 + "node_modules/side-channel-map": { 1294 + "version": "1.0.1", 1295 + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", 1296 + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", 1297 + "license": "MIT", 1298 + "dependencies": { 1299 + "call-bound": "^1.0.2", 1300 + "es-errors": "^1.3.0", 1301 + "get-intrinsic": "^1.2.5", 1302 + "object-inspect": "^1.13.3" 1303 + }, 1304 + "engines": { 1305 + "node": ">= 0.4" 1306 + }, 1307 + "funding": { 1308 + "url": "https://github.com/sponsors/ljharb" 1309 + } 1310 + }, 1311 + "node_modules/side-channel-weakmap": { 1312 + "version": "1.0.2", 1313 + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", 1314 + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", 1315 + "license": "MIT", 1316 + "dependencies": { 1317 + "call-bound": "^1.0.2", 1318 + "es-errors": "^1.3.0", 1319 + "get-intrinsic": "^1.2.5", 1320 + "object-inspect": "^1.13.3", 1321 + "side-channel-map": "^1.0.1" 1322 + }, 1323 + "engines": { 1324 + "node": ">= 0.4" 1325 + }, 1326 + "funding": { 1327 + "url": "https://github.com/sponsors/ljharb" 1328 + } 1329 + }, 1330 + "node_modules/simple-concat": { 1331 + "version": "1.0.1", 1332 + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", 1333 + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", 1334 + "funding": [ 1335 + { 1336 + "type": "github", 1337 + "url": "https://github.com/sponsors/feross" 1338 + }, 1339 + { 1340 + "type": "patreon", 1341 + "url": "https://www.patreon.com/feross" 1342 + }, 1343 + { 1344 + "type": "consulting", 1345 + "url": "https://feross.org/support" 1346 + } 1347 + ], 1348 + "license": "MIT" 1349 + }, 1350 + "node_modules/simple-get": { 1351 + "version": "4.0.1", 1352 + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", 1353 + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", 1354 + "funding": [ 1355 + { 1356 + "type": "github", 1357 + "url": "https://github.com/sponsors/feross" 1358 + }, 1359 + { 1360 + "type": "patreon", 1361 + "url": "https://www.patreon.com/feross" 1362 + }, 1363 + { 1364 + "type": "consulting", 1365 + "url": "https://feross.org/support" 1366 + } 1367 + ], 1368 + "license": "MIT", 1369 + "dependencies": { 1370 + "decompress-response": "^6.0.0", 1371 + "once": "^1.3.1", 1372 + "simple-concat": "^1.0.0" 1373 + } 1374 + }, 1375 + "node_modules/string_decoder": { 1376 + "version": "1.3.0", 1377 + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", 1378 + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", 1379 + "license": "MIT", 1380 + "dependencies": { 1381 + "safe-buffer": "~5.2.0" 1382 + } 1383 + }, 1384 + "node_modules/strip-json-comments": { 1385 + "version": "2.0.1", 1386 + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", 1387 + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", 1388 + "license": "MIT", 1389 + "engines": { 1390 + "node": ">=0.10.0" 1391 + } 1392 + }, 1393 + "node_modules/stripe": { 1394 + "version": "17.7.0", 1395 + "resolved": "https://registry.npmjs.org/stripe/-/stripe-17.7.0.tgz", 1396 + "integrity": "sha512-aT2BU9KkizY9SATf14WhhYVv2uOapBWX0OFWF4xvcj1mPaNotlSc2CsxpS4DS46ZueSppmCF5BX1sNYBtwBvfw==", 1397 + "license": "MIT", 1398 + "dependencies": { 1399 + "@types/node": ">=8.1.0", 1400 + "qs": "^6.11.0" 1401 + }, 1402 + "engines": { 1403 + "node": ">=12.*" 1404 + } 1405 + }, 1406 + "node_modules/tar-fs": { 1407 + "version": "2.1.4", 1408 + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", 1409 + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", 1410 + "license": "MIT", 1411 + "dependencies": { 1412 + "chownr": "^1.1.1", 1413 + "mkdirp-classic": "^0.5.2", 1414 + "pump": "^3.0.0", 1415 + "tar-stream": "^2.1.4" 1416 + } 1417 + }, 1418 + "node_modules/tar-stream": { 1419 + "version": "2.2.0", 1420 + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", 1421 + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", 1422 + "license": "MIT", 1423 + "dependencies": { 1424 + "bl": "^4.0.3", 1425 + "end-of-stream": "^1.4.1", 1426 + "fs-constants": "^1.0.0", 1427 + "inherits": "^2.0.3", 1428 + "readable-stream": "^3.1.1" 1429 + }, 1430 + "engines": { 1431 + "node": ">=6" 1432 + } 1433 + }, 1434 + "node_modules/tlds": { 1435 + "version": "1.261.0", 1436 + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", 1437 + "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", 1438 + "license": "MIT", 1439 + "bin": { 1440 + "tlds": "bin.js" 1441 + } 1442 + }, 1443 + "node_modules/ts-mixer": { 1444 + "version": "6.0.4", 1445 + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", 1446 + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", 1447 + "license": "MIT" 1448 + }, 1449 + "node_modules/tslib": { 1450 + "version": "2.8.1", 1451 + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", 1452 + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", 1453 + "license": "0BSD" 1454 + }, 1455 + "node_modules/tunnel-agent": { 1456 + "version": "0.6.0", 1457 + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", 1458 + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", 1459 + "license": "Apache-2.0", 1460 + "dependencies": { 1461 + "safe-buffer": "^5.0.1" 1462 + }, 1463 + "engines": { 1464 + "node": "*" 1465 + } 1466 + }, 1467 + "node_modules/uint8arrays": { 1468 + "version": "3.0.0", 1469 + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz", 1470 + "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==", 1471 + "license": "MIT", 1472 + "dependencies": { 1473 + "multiformats": "^9.4.2" 1474 + } 1475 + }, 1476 + "node_modules/undici": { 1477 + "version": "6.24.1", 1478 + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", 1479 + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", 1480 + "license": "MIT", 1481 + "engines": { 1482 + "node": ">=18.17" 1483 + } 1484 + }, 1485 + "node_modules/undici-types": { 1486 + "version": "7.19.2", 1487 + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", 1488 + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", 1489 + "license": "MIT" 1490 + }, 1491 + "node_modules/unicode-segmenter": { 1492 + "version": "0.14.5", 1493 + "resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.5.tgz", 1494 + "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==", 1495 + "license": "MIT" 1496 + }, 1497 + "node_modules/util-deprecate": { 1498 + "version": "1.0.2", 1499 + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", 1500 + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", 1501 + "license": "MIT" 1502 + }, 1503 + "node_modules/wrappy": { 1504 + "version": "1.0.2", 1505 + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", 1506 + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", 1507 + "license": "ISC" 1508 + }, 1509 + "node_modules/ws": { 1510 + "version": "8.20.0", 1511 + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", 1512 + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", 1513 + "license": "MIT", 1514 + "engines": { 1515 + "node": ">=10.0.0" 1516 + }, 1517 + "peerDependencies": { 1518 + "bufferutil": "^4.0.1", 1519 + "utf-8-validate": ">=5.0.2" 1520 + }, 1521 + "peerDependenciesMeta": { 1522 + "bufferutil": { 1523 + "optional": true 1524 + }, 1525 + "utf-8-validate": { 1526 + "optional": true 1527 + } 1528 + } 1529 + }, 1530 + "node_modules/zod": { 1531 + "version": "3.25.76", 1532 + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", 1533 + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", 1534 + "license": "MIT", 1535 + "funding": { 1536 + "url": "https://github.com/sponsors/colinhacks" 1537 + } 1538 + } 1539 + } 1540 + }
+26
package.json
··· 1 + { 2 + "name": "disject", 3 + "version": "0.0.1", 4 + "private": true, 5 + "type": "module", 6 + "main": "src/index.js", 7 + "scripts": { 8 + "start": "node src/index.js", 9 + "dev": "node --watch src/index.js", 10 + "db:init": "node src/db.js init", 11 + "user:add": "node scripts/user-add.js", 12 + "rule:add": "node scripts/rule-add.js", 13 + "keygen": "node -e \"import('@atproto/jwk-jose').then(m => m.JoseKey.generate().then(k => console.log(JSON.stringify(k.privateJwk))))\"" 14 + }, 15 + "dependencies": { 16 + "@atproto/api": "^0.13.0", 17 + "@atproto/jwk-jose": "^0.1.0", 18 + "@atproto/oauth-client-node": "^0.1.0", 19 + "@hono/node-server": "^1.13.0", 20 + "better-sqlite3": "^11.3.0", 21 + "discord.js": "^14.18.0", 22 + "dotenv": "^16.4.7", 23 + "hono": "^4.6.0", 24 + "stripe": "^17.0.0" 25 + } 26 + }
+195
readme.md
··· 1 + # disject 2 + 3 + Route Discord URLs into atproto-shaped destinations. 4 + 5 + A paying user signs up at the website, links their Discord account and an Atmosphere account, and writes rules like "harvest URLs from server X channel Y → my Semble bookmarks." A single shared Discord bot, invited into any server the user wants, evaluates everyone's rules and routes matching URLs to each rule's destination. 6 + 7 + ## How it works 8 + 9 + Two halves, deliberately decoupled: 10 + 11 + 1. **Discord harvesting.** A single shared bot (one Discord application, operated by us) is invited by users into any server. On every `messageCreate` the bot sees, it queries the rules table for matches and dispatches URLs to each matching rule's destination. The bot is omniscient by virtue of being in the server; gating happens at the rule level, not at the bot level. 12 + 2. **Web app.** Hosts identity linking (Discord OAuth + atproto OAuth), Stripe Checkout, and the rules UI. 13 + 14 + URLs themselves are pass-through. Disject stores account metadata and aggregate write counts only — never the URLs, message content, channel history, or anything else from Discord. 15 + 16 + ## Bot model 17 + 18 + One global bot user. Anyone can invite it via the install link; processing only happens for messages that match rules belonging to active (paid) users. Two paying users in the same server route to their own destinations independently. 19 + 20 + The bot needs the **Message Content** privileged intent. Below 100 servers Discord doesn't require verification; past that, we'll need to verify the application. 21 + 22 + **Auto-leave for inactive guilds.** A free-rider server (high message volume, no paying customer) costs us gateway bandwidth even though no work happens downstream. Defense: the bot leaves any guild that has no active rule pointing at it after a grace period (default 7 days, configurable via `GUILD_GRACE_HOURS`). The reaper runs hourly. Re-invitation is fine; just configure a rule first. 23 + 24 + The hot path is also pre-filtered in memory: `messageCreate` does a single `Set.has()` lookup against the cache of guilds-with-active-rules before any DB or content access. Messages from inactive guilds are dropped before extraction. 25 + 26 + ## Rules 27 + 28 + A rule is one row in the `rules` table: 29 + 30 + | column | meaning | 31 + | --------------------- | ----------------------------------------------------------------------- | 32 + | `user_id` | rule owner (FK → `users.id`) | 33 + | `guild_id` | the server this rule applies to (required; user must be a member) | 34 + | `channel_id` | match a specific channel, or NULL for any channel in the server | 35 + | `author_id` | match a specific Discord user, NULL for anyone, `'self'` = rule owner | 36 + | `destination_kind` | adapter name: `stdout`, `atproto-record`, eventually `semble`, … | 37 + | `destination_config` | JSON, adapter-specific | 38 + | `enabled` | toggle | 39 + 40 + `guild_id` is required and must be a server the user is a member of (and where the bot is present). Filters are AND'd within a rule. NULL on `channel_id` / `author_id` = wildcard. 41 + 42 + Multiple paying users can have rules in the same server, each routing to their own destination — the rule itself is the correlation between user and server, not a separate claim. 43 + 44 + Common patterns: 45 + 46 + - "everything I post in server X" → `guild_id=X, author_id='self'` 47 + - "all URLs in `#links` of server X" → `guild_id=X, channel_id=#links` 48 + - "Boris's posts in server X" → `guild_id=X, author_id=<boris_discord_id>` 49 + - "everything in server X" → `guild_id=X` 50 + 51 + A message can match multiple rules (same owner, different filters) → fans out to multiple destinations → counts as multiple writes. 52 + 53 + ## Pricing 54 + 55 + Flat **$2/month** while we monitor real usage. No hard cap in v1. Per-user write counts are recorded monthly in `write_counters` purely for observability — to inform future tier design, not to gate dispatch. 56 + 57 + ## Storage 58 + 59 + SQLite on a Railway volume. Five tables: 60 + 61 + - `users` — `(discord_id, did, stripe_customer_id, active, …)`. One row per paying customer. 62 + - `rules` — see above. 63 + - `write_counters` — `(user_id, period 'YYYY-MM', count)`. Aggregate only. 64 + - `oauth_state` — short-lived atproto OAuth handshake state. 65 + - `oauth_session` — long-lived atproto OAuth sessions (DPoP + refresh token), indexed by DID. The destination adapter restores these to write records on the user's behalf. 66 + 67 + Pass-through is enforced by what the schema *can't* hold: there's no URLs table, no message log, no content column. 68 + 69 + ## Project layout 70 + 71 + ``` 72 + disject/ 73 + src/ 74 + index.js entry — boots both bot and web in one process 75 + bot.js Discord client + messageCreate handler 76 + web.js Hono app — OAuth, rules UI, Stripe, webhooks 77 + db.js sqlite + tables + prepared statements 78 + extract.js URL extraction 79 + cookies.js HMAC signed-cookie session 80 + keys.js JOSE keyset for atproto OAuth client auth 81 + oauth.js atproto OAuth client singleton (SQLite-backed stores) 82 + discord-oauth.js Discord OAuth helpers 83 + stripe.js Stripe SDK singleton 84 + secret-store.js AES-256-GCM encrypt/decrypt for OAuth sessions at rest 85 + destinations/ 86 + index.js dispatch by destination_kind 87 + stdout.js debug destination (logs JSON to stdout) 88 + atproto-record.js writes a record to the rule owner's PDS 89 + scripts/ 90 + user-add.js upsert a user row (dev) 91 + rule-add.js insert a rule row (dev) 92 + data/ sqlite file lives here (gitignored) 93 + ``` 94 + 95 + ## Local dev 96 + 97 + ``` 98 + cp .env.example .env # see Environment below 99 + npm install 100 + npm run keygen # paste output into OAUTH_PRIVATE_KEY 101 + npm run db:init 102 + npm start 103 + ``` 104 + 105 + `npm start` boots both the bot and the web server (Hono) in one process. Visit `PUBLIC_URL` to link Discord + Atmosphere accounts and create rules. 106 + 107 + For a bot-only quick test (no web/auth), seed by hand: 108 + 109 + ``` 110 + npm run user:add -- <your_discord_id> 111 + npm run rule:add -- <your_discord_id> --author self 112 + ``` 113 + 114 + Then post a URL where the bot can see it; you'll get a JSON line on stdout. 115 + 116 + ### atproto OAuth caveat 117 + 118 + atproto OAuth rejects `localhost`. Full end-to-end testing of the web flow requires a public HTTPS URL — either a tunnel (cloudflared/ngrok) with `PUBLIC_URL` set to the tunnel host, or a Railway deploy. 119 + 120 + ### Scripts 121 + 122 + - `npm run db:init` — create tables if missing 123 + - `npm run user:add -- <discord_id> [--did <did>] [--inactive]` — upsert a user 124 + - `npm run rule:add -- <discord_id> [--guild ID] [--channel ID] [--author ID|self] [--dest kind] [--config JSON] [--disabled]` — add a rule 125 + 126 + ### Inspecting the DB 127 + 128 + ``` 129 + sqlite3 data/disject.db "SELECT * FROM rules;" 130 + sqlite3 data/disject.db "SELECT u.discord_id, w.period, w.count FROM write_counters w JOIN users u ON u.id = w.user_id;" 131 + ``` 132 + 133 + ## Environment 134 + 135 + | var | required | what | 136 + | ------------------------- | ------------------ | -------------------------------------------------------------------------- | 137 + | `PUBLIC_URL` | yes (web) | externally-reachable HTTPS origin, e.g. `https://disject.portable.agency` | 138 + | `PORT` | no | defaults to 3000 | 139 + | `SESSION_SECRET` | yes (web) | random hex, used to sign session cookies | 140 + | `DISCORD_TOKEN` | yes (bot) | bot token from the Discord developer portal | 141 + | `DISCORD_CLIENT_ID` | yes | Discord application ID | 142 + | `DISCORD_CLIENT_SECRET` | yes (web) | Discord OAuth client secret | 143 + | `OAUTH_PRIVATE_KEY` | yes (web) | atproto OAuth client private JWK; generate with `npm run keygen` | 144 + | `STRIPE_SECRET_KEY` | yes (subs) | Stripe secret key | 145 + | `STRIPE_WEBHOOK_SECRET` | yes (subs) | Stripe webhook signing secret | 146 + | `STRIPE_PRICE_ID` | yes (subs) | the recurring price (e.g. `price_…`) for the $2/mo subscription | 147 + | `DB_PATH` | no | sqlite path; defaults to `./data/disject.db` | 148 + 149 + ## Discord setup 150 + 151 + 1. https://discord.com/developers/applications → New Application. 152 + 2. Bot tab → reveal token → set `DISCORD_TOKEN`. 153 + 3. Bot tab → enable **Message Content Intent** (privileged). 154 + 4. Installation tab: 155 + - Authorization Methods: **Guild Install** (User Install optional). 156 + - Install Link: **Discord Provided Link**. 157 + - Default Install Settings → Guild Install: 158 + - Scopes: `bot`, `applications.commands` 159 + - Permissions: View Channels, Read Message History, Send Messages, Use Slash Commands 160 + 5. The Install Link at the top of the Installation tab is the invite URL. 161 + 6. **OAuth2 tab → Redirects:** add `${PUBLIC_URL}/discord/callback`. Reset the client secret if you haven't already and copy it into `DISCORD_CLIENT_SECRET`. 162 + 163 + ## Stripe setup 164 + 165 + 1. Create a Product with a recurring $2/month Price; copy the `price_…` id into `STRIPE_PRICE_ID`. 166 + 2. Enable the Customer Portal in the Stripe dashboard (Settings → Customer Portal). Allow subscription cancellation. 167 + 3. Add a webhook endpoint pointing at `${PUBLIC_URL}/webhooks/stripe`. Subscribe to: 168 + - `checkout.session.completed` 169 + - `customer.subscription.created` 170 + - `customer.subscription.updated` 171 + - `customer.subscription.deleted` 172 + 4. Copy the webhook signing secret into `STRIPE_WEBHOOK_SECRET`. 173 + 174 + ## Deploy 175 + 176 + Railway, like portable.agency. SQLite file lives on a mounted volume at `/data/disject.db` (set `DB_PATH=/data/disject.db`). 177 + 178 + ## Status 179 + 180 + - [x] Bot — multi-server, server-agnostic, rule-driven dispatch 181 + - [x] Rules engine + pass-through harvesting 182 + - [x] Write counters 183 + - [x] `stdout` destination 184 + - [x] Web — Discord OAuth + atproto OAuth + identity link 185 + - [x] Web — rules CRUD UI with guild-membership guard 186 + - [x] Stripe Checkout + Customer Portal + webhooks → `active` flag 187 + - [x] Persistent OAuth sessions (SQLite-backed) 188 + - [x] `atproto-record` destination — writes to user's PDS under a configured NSID 189 + - [x] OAuth state/sessions encrypted at rest (AES-256-GCM, key derived from `SESSION_SECRET`) 190 + - [x] Bot slash commands `/disject connect` and `/disject status` 191 + - [x] Atmosphere unlink button on landing page 192 + - [x] Usage panel on rules page (this-month + recent-months write counts) 193 + - [x] Multi-user per server — multiple paying users can have rules in the same server, each routed to their own destination 194 + - [ ] Real destination: Semble (pending lexicon discovery) 195 + - [ ] Deploy at `disject.portable.agency`
+43
scripts/rule-add.js
··· 1 + import { db } from '../src/db.js'; 2 + 3 + const [, , discordId, ...rest] = process.argv; 4 + if (!discordId) { 5 + console.error(`usage: npm run rule:add -- <owner_discord_id> [options] 6 + --guild <id> limit to a specific guild 7 + --channel <id> limit to a specific channel 8 + --author <id|self> limit to messages from a specific author ('self' = owner) 9 + --dest <kind> destination kind (default: stdout) 10 + --config <json> destination config as JSON string 11 + --disabled create disabled 12 + 13 + omit a filter to make it a wildcard.`); 14 + process.exit(1); 15 + } 16 + 17 + const flags = new Map(); 18 + for (let i = 0; i < rest.length; i++) { 19 + if (rest[i].startsWith('--')) flags.set(rest[i].slice(2), rest[i + 1] ?? true); 20 + } 21 + 22 + const user = db.prepare(`SELECT id FROM users WHERE discord_id = ?`).get(discordId); 23 + if (!user) { 24 + console.error(`no user with discord_id=${discordId} — run user:add first`); 25 + process.exit(1); 26 + } 27 + 28 + const row = { 29 + user_id: user.id, 30 + guild_id: flags.get('guild') || null, 31 + channel_id: flags.get('channel') || null, 32 + author_id: flags.get('author') || null, 33 + destination_kind: flags.get('dest') || 'stdout', 34 + destination_config: flags.get('config') || null, 35 + enabled: flags.has('disabled') ? 0 : 1, 36 + }; 37 + 38 + const result = db.prepare(` 39 + INSERT INTO rules (user_id, guild_id, channel_id, author_id, destination_kind, destination_config, enabled) 40 + VALUES (@user_id, @guild_id, @channel_id, @author_id, @destination_kind, @destination_config, @enabled) 41 + `).run(row); 42 + 43 + console.log(`inserted rule id=${result.lastInsertRowid}`, row);
+27
scripts/user-add.js
··· 1 + import { db } from '../src/db.js'; 2 + 3 + const [, , discordId, ...rest] = process.argv; 4 + if (!discordId) { 5 + console.error('usage: npm run user:add -- <discord_id> [--did <did>] [--inactive]'); 6 + process.exit(1); 7 + } 8 + 9 + const flags = new Map(); 10 + for (let i = 0; i < rest.length; i++) { 11 + if (rest[i].startsWith('--')) flags.set(rest[i].slice(2), rest[i + 1] ?? true); 12 + } 13 + 14 + const did = flags.get('did') || null; 15 + const active = flags.has('inactive') ? 0 : 1; 16 + 17 + db.prepare(` 18 + INSERT INTO users (discord_id, did, active) 19 + VALUES (?, ?, ?) 20 + ON CONFLICT(discord_id) DO UPDATE SET 21 + did = COALESCE(excluded.did, users.did), 22 + active = excluded.active, 23 + updated_at = strftime('%s','now') 24 + `).run(discordId, did, active); 25 + 26 + const row = db.prepare(`SELECT id, discord_id, did, active FROM users WHERE discord_id = ?`).get(discordId); 27 + console.log(`upserted user`, row);
+22
src/active-guilds.js
··· 1 + import { db } from './db.js'; 2 + 3 + let cache = new Set(); 4 + 5 + const stmt = db.prepare(` 6 + SELECT DISTINCT r.guild_id 7 + FROM rules r 8 + JOIN users u ON u.id = r.user_id 9 + WHERE r.enabled = 1 AND u.active = 1 AND r.guild_id IS NOT NULL 10 + `); 11 + 12 + export function refreshActiveGuilds() { 13 + cache = new Set(stmt.all().map(r => r.guild_id)); 14 + return cache.size; 15 + } 16 + 17 + export function isActiveGuild(guildId) { 18 + return cache.has(guildId); 19 + } 20 + 21 + // Load once at module init so the bot is correct from the first message. 22 + refreshActiveGuilds();
+144
src/bot.js
··· 1 + import { 2 + Client, 3 + Events, 4 + GatewayIntentBits, 5 + Partials, 6 + REST, 7 + Routes, 8 + SlashCommandBuilder, 9 + MessageFlags, 10 + } from 'discord.js'; 11 + import { findMatchingRules, bumpCounter, getUserByDiscord } from './db.js'; 12 + import { isActiveGuild } from './active-guilds.js'; 13 + import { extractURLs } from './extract.js'; 14 + import { dispatch } from './destinations/index.js'; 15 + 16 + const GUILD_GRACE_MS = Number(process.env.GUILD_GRACE_HOURS ?? 168) * 60 * 60 * 1000; 17 + const REAP_INTERVAL_MS = 60 * 60 * 1000; 18 + 19 + export const client = new Client({ 20 + intents: [ 21 + GatewayIntentBits.Guilds, 22 + GatewayIntentBits.GuildMessages, 23 + GatewayIntentBits.MessageContent, 24 + GatewayIntentBits.DirectMessages, 25 + ], 26 + partials: [Partials.Channel, Partials.Message], 27 + }); 28 + 29 + client.once(Events.ClientReady, async c => { 30 + console.log(`disject bot ready as ${c.user.tag} in ${c.guilds.cache.size} guild(s)`); 31 + try { 32 + await registerCommands(c.user.id); 33 + } catch (err) { 34 + console.error('slash command registration failed', err.message); 35 + } 36 + reapInactiveGuilds(); 37 + setInterval(reapInactiveGuilds, REAP_INTERVAL_MS).unref(); 38 + }); 39 + 40 + client.on(Events.GuildCreate, guild => { 41 + console.log(`disject: joined ${guild.name} (${guild.id}); grace ${Math.round(GUILD_GRACE_MS / 3600000)}h`); 42 + }); 43 + 44 + client.on(Events.GuildDelete, guild => { 45 + console.log(`disject: left ${guild.name ?? '?'} (${guild.id})`); 46 + }); 47 + 48 + function reapInactiveGuilds() { 49 + const now = Date.now(); 50 + for (const [id, guild] of client.guilds.cache) { 51 + if (isActiveGuild(id)) continue; 52 + const joined = guild.joinedTimestamp; 53 + if (!joined) continue; 54 + const age = now - joined; 55 + if (age <= GUILD_GRACE_MS) continue; 56 + console.log(`disject: leaving inactive guild ${guild.name} (${id}) — no active rules after ${Math.round(age / 3600000)}h`); 57 + guild.leave().catch(err => console.error('leave failed', { id, err: err.message })); 58 + } 59 + } 60 + 61 + async function registerCommands(applicationId) { 62 + const token = process.env.DISCORD_TOKEN; 63 + if (!token) return; 64 + const commands = [ 65 + new SlashCommandBuilder() 66 + .setName('disject') 67 + .setDescription('disject — URL routing') 68 + .addSubcommand(s => s.setName('connect').setDescription('Get a link to set up your disject account')) 69 + .addSubcommand(s => s.setName('status').setDescription('Show your link/subscription status')) 70 + .toJSON(), 71 + ]; 72 + const rest = new REST({ version: '10' }).setToken(token); 73 + await rest.put(Routes.applicationCommands(applicationId), { body: commands }); 74 + } 75 + 76 + client.on(Events.InteractionCreate, async (interaction) => { 77 + if (!interaction.isChatInputCommand()) return; 78 + if (interaction.commandName !== 'disject') return; 79 + 80 + const sub = interaction.options.getSubcommand(); 81 + const publicUrl = process.env.PUBLIC_URL; 82 + 83 + if (sub === 'connect') { 84 + const url = publicUrl ?? '(PUBLIC_URL not configured)'; 85 + return interaction.reply({ 86 + content: `Set up your disject account here: ${url}\n\nYou'll connect this Discord account, sign in with an Atmosphere account, and configure rules.`, 87 + flags: MessageFlags.Ephemeral, 88 + }); 89 + } 90 + 91 + if (sub === 'status') { 92 + const user = getUserByDiscord.get(interaction.user.id); 93 + if (!user) { 94 + return interaction.reply({ 95 + content: `No disject account connected to this Discord user yet. Run \`/disject connect\` to set one up.`, 96 + flags: MessageFlags.Ephemeral, 97 + }); 98 + } 99 + const lines = [ 100 + `Discord ID: \`${user.discord_id}\``, 101 + `Atmosphere DID: \`${user.did ?? '(not linked)'}\``, 102 + `Subscription: \`${user.active ? 'active' : 'inactive'}\``, 103 + ]; 104 + return interaction.reply({ content: lines.join('\n'), flags: MessageFlags.Ephemeral }); 105 + } 106 + }); 107 + 108 + client.on(Events.MessageCreate, async msg => { 109 + if (msg.author.bot) return; 110 + if (!msg.guildId) return; 111 + // Cheap pre-filter: skip the SQL hit (and never touch msg.content) for 112 + // guilds where no active customer has any enabled rule. 113 + if (!isActiveGuild(msg.guildId)) return; 114 + 115 + const rules = findMatchingRules.all({ 116 + guild_id: msg.guildId, 117 + channel_id: msg.channelId, 118 + author_id: msg.author.id, 119 + }); 120 + if (rules.length === 0) return; 121 + 122 + const urls = extractURLs(msg.content); 123 + if (urls.length === 0) return; 124 + 125 + const ctx = { 126 + guildId: msg.guildId, 127 + channelId: msg.channelId, 128 + messageId: msg.id, 129 + authorId: msg.author.id, 130 + }; 131 + 132 + for (const rule of rules) { 133 + try { 134 + await dispatch(rule, urls, ctx); 135 + bumpCounter(rule.user_id, urls.length); 136 + } catch (err) { 137 + console.error('dispatch failed', { rule_id: rule.rule_id, err: err.message }); 138 + } 139 + } 140 + }); 141 + 142 + export function startBot() { 143 + return client.login(process.env.DISCORD_TOKEN); 144 + }
+41
src/cookies.js
··· 1 + import { createHmac, timingSafeEqual } from 'node:crypto'; 2 + 3 + const COOKIE_NAME = 'dj_session'; 4 + const DEFAULT_MAX_AGE = 15 * 60; 5 + export const SESSION_MAX_AGE_LONG = 30 * 24 * 60 * 60; 6 + 7 + const sign = (payload, secret) => { 8 + const body = Buffer.from(JSON.stringify(payload)).toString('base64url'); 9 + const mac = createHmac('sha256', secret).update(body).digest('base64url'); 10 + return `${body}.${mac}`; 11 + }; 12 + 13 + const verify = (token, secret) => { 14 + const [body, mac] = token.split('.'); 15 + if (!body || !mac) return null; 16 + const expected = createHmac('sha256', secret).update(body).digest('base64url'); 17 + const a = Buffer.from(mac); 18 + const b = Buffer.from(expected); 19 + if (a.length !== b.length || !timingSafeEqual(a, b)) return null; 20 + const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')); 21 + if (payload.exp && payload.exp < Date.now() / 1000) return null; 22 + return payload; 23 + }; 24 + 25 + export const setSession = (c, payload, secret, maxAge = DEFAULT_MAX_AGE) => { 26 + const exp = Math.floor(Date.now() / 1000) + maxAge; 27 + const token = sign({ ...payload, exp }, secret); 28 + c.header('Set-Cookie', 29 + `${COOKIE_NAME}=${token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`); 30 + }; 31 + 32 + export const getSession = (c, secret) => { 33 + const raw = c.req.header('cookie') ?? ''; 34 + const match = raw.split(';').map(s => s.trim()).find(s => s.startsWith(`${COOKIE_NAME}=`)); 35 + if (!match) return null; 36 + return verify(match.slice(COOKIE_NAME.length + 1), secret); 37 + }; 38 + 39 + export const clearSession = (c) => { 40 + c.header('Set-Cookie', `${COOKIE_NAME}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`); 41 + };
+208
src/db.js
··· 1 + import 'dotenv/config'; 2 + import path from 'node:path'; 3 + import fs from 'node:fs'; 4 + import Database from 'better-sqlite3'; 5 + import { encrypt, decrypt } from './secret-store.js'; 6 + 7 + const dbPath = process.env.DB_PATH || './data/disject.db'; 8 + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); 9 + 10 + export const db = new Database(dbPath); 11 + db.pragma('journal_mode = WAL'); 12 + db.pragma('foreign_keys = ON'); 13 + 14 + db.exec(` 15 + CREATE TABLE IF NOT EXISTS users ( 16 + id INTEGER PRIMARY KEY, 17 + discord_id TEXT UNIQUE NOT NULL, 18 + did TEXT UNIQUE, 19 + stripe_customer_id TEXT, 20 + active INTEGER NOT NULL DEFAULT 0, 21 + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), 22 + updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) 23 + ); 24 + 25 + CREATE TABLE IF NOT EXISTS rules ( 26 + id INTEGER PRIMARY KEY, 27 + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, 28 + guild_id TEXT, 29 + channel_id TEXT, 30 + author_id TEXT, 31 + destination_kind TEXT NOT NULL DEFAULT 'stdout', 32 + destination_config TEXT, 33 + enabled INTEGER NOT NULL DEFAULT 1, 34 + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) 35 + ); 36 + CREATE INDEX IF NOT EXISTS rules_lookup ON rules(enabled, guild_id); 37 + 38 + CREATE TABLE IF NOT EXISTS write_counters ( 39 + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, 40 + period TEXT NOT NULL, 41 + count INTEGER NOT NULL DEFAULT 0, 42 + PRIMARY KEY (user_id, period) 43 + ); 44 + 45 + CREATE TABLE IF NOT EXISTS oauth_state ( 46 + key TEXT PRIMARY KEY, 47 + value TEXT NOT NULL, 48 + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) 49 + ); 50 + 51 + CREATE TABLE IF NOT EXISTS oauth_session ( 52 + sub TEXT PRIMARY KEY, 53 + value TEXT NOT NULL, 54 + updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) 55 + ); 56 + `); 57 + 58 + // Find rules that match a (guild, channel, author) tuple. NULL = wildcard. 59 + // 'self' on author_id resolves to the rule-owner's discord_id. 60 + export const findMatchingRules = db.prepare(` 61 + SELECT r.id AS rule_id, 62 + r.destination_kind, 63 + r.destination_config, 64 + u.id AS user_id, 65 + u.discord_id AS owner_discord_id, 66 + u.did AS owner_did 67 + FROM rules r 68 + JOIN users u ON u.id = r.user_id 69 + WHERE u.active = 1 70 + AND r.enabled = 1 71 + AND (r.guild_id IS NULL OR r.guild_id = @guild_id) 72 + AND (r.channel_id IS NULL OR r.channel_id = @channel_id) 73 + AND (r.author_id IS NULL 74 + OR r.author_id = @author_id 75 + OR (r.author_id = 'self' AND u.discord_id = @author_id)) 76 + `); 77 + 78 + const bumpStmt = db.prepare(` 79 + INSERT INTO write_counters (user_id, period, count) 80 + VALUES (?, ?, ?) 81 + ON CONFLICT(user_id, period) DO UPDATE SET count = count + excluded.count 82 + `); 83 + 84 + export function bumpCounter(userId, n = 1) { 85 + const period = new Date().toISOString().slice(0, 7); // YYYY-MM 86 + bumpStmt.run(userId, period, n); 87 + } 88 + 89 + const counterGet = db.prepare( 90 + `SELECT count FROM write_counters WHERE user_id = ? AND period = ?` 91 + ); 92 + export function getCounter(userId, period = new Date().toISOString().slice(0, 7)) { 93 + return counterGet.get(userId, period)?.count ?? 0; 94 + } 95 + 96 + const countersForUser = db.prepare( 97 + `SELECT period, count FROM write_counters WHERE user_id = ? ORDER BY period DESC LIMIT ?` 98 + ); 99 + export function listCounters(userId, n = 6) { 100 + return countersForUser.all(userId, n); 101 + } 102 + 103 + const upsertByDiscordStmt = db.prepare(` 104 + INSERT INTO users (discord_id, did) 105 + VALUES (?, ?) 106 + ON CONFLICT(discord_id) DO UPDATE SET 107 + did = COALESCE(excluded.did, users.did), 108 + updated_at = strftime('%s','now') 109 + RETURNING * 110 + `); 111 + 112 + export function upsertUserByDiscord({ discordId, did }) { 113 + return upsertByDiscordStmt.get(discordId, did ?? null); 114 + } 115 + 116 + export const getUserByDiscord = db.prepare(`SELECT * FROM users WHERE discord_id = ?`); 117 + export const getUserByDid = db.prepare(`SELECT * FROM users WHERE did = ?`); 118 + 119 + const unlinkDidStmt = db.prepare( 120 + `UPDATE users SET did = NULL, updated_at = strftime('%s','now') WHERE id = ?` 121 + ); 122 + export function unlinkDid(userId) { unlinkDidStmt.run(userId); } 123 + 124 + export const listRulesForUser = db.prepare( 125 + `SELECT * FROM rules WHERE user_id = ? ORDER BY id DESC` 126 + ); 127 + 128 + const insertRuleStmt = db.prepare(` 129 + INSERT INTO rules (user_id, guild_id, channel_id, author_id, destination_kind, destination_config, enabled) 130 + VALUES (@user_id, @guild_id, @channel_id, @author_id, @destination_kind, @destination_config, @enabled) 131 + RETURNING * 132 + `); 133 + 134 + export function insertRule(row) { 135 + return insertRuleStmt.get({ 136 + user_id: row.user_id, 137 + guild_id: row.guild_id ?? null, 138 + channel_id: row.channel_id ?? null, 139 + author_id: row.author_id ?? null, 140 + destination_kind: row.destination_kind ?? 'stdout', 141 + destination_config: row.destination_config ?? null, 142 + enabled: row.enabled ?? 1, 143 + }); 144 + } 145 + 146 + export const deleteRuleForUser = db.prepare( 147 + `DELETE FROM rules WHERE id = ? AND user_id = ?` 148 + ); 149 + 150 + export const toggleRuleForUser = db.prepare( 151 + `UPDATE rules SET enabled = 1 - enabled WHERE id = ? AND user_id = ?` 152 + ); 153 + 154 + const setSubStmt = db.prepare(` 155 + UPDATE users 156 + SET stripe_customer_id = COALESCE(?, stripe_customer_id), 157 + active = ?, 158 + updated_at = strftime('%s','now') 159 + WHERE id = ? 160 + `); 161 + 162 + export function setUserSubscription(userId, customerId, active) { 163 + setSubStmt.run(customerId ?? null, active ? 1 : 0, userId); 164 + } 165 + 166 + const setActiveByCustomerStmt = db.prepare(` 167 + UPDATE users 168 + SET active = ?, updated_at = strftime('%s','now') 169 + WHERE stripe_customer_id = ? 170 + `); 171 + 172 + export function setActiveByCustomer(customerId, active) { 173 + setActiveByCustomerStmt.run(active ? 1 : 0, customerId); 174 + } 175 + 176 + const oauthStateSet = db.prepare(`INSERT OR REPLACE INTO oauth_state (key, value) VALUES (?, ?)`); 177 + const oauthStateGet = db.prepare(`SELECT value FROM oauth_state WHERE key = ?`); 178 + const oauthStateDel = db.prepare(`DELETE FROM oauth_state WHERE key = ?`); 179 + 180 + const oauthSessionSet = db.prepare( 181 + `INSERT OR REPLACE INTO oauth_session (sub, value, updated_at) VALUES (?, ?, strftime('%s','now'))` 182 + ); 183 + const oauthSessionGet = db.prepare(`SELECT value FROM oauth_session WHERE sub = ?`); 184 + const oauthSessionDel = db.prepare(`DELETE FROM oauth_session WHERE sub = ?`); 185 + 186 + export const oauthStores = { 187 + state: { 188 + async set(key, value) { oauthStateSet.run(key, encrypt(JSON.stringify(value))); }, 189 + async get(key) { 190 + const row = oauthStateGet.get(key); 191 + return row ? JSON.parse(decrypt(row.value)) : undefined; 192 + }, 193 + async del(key) { oauthStateDel.run(key); }, 194 + }, 195 + session: { 196 + async set(sub, value) { oauthSessionSet.run(sub, encrypt(JSON.stringify(value))); }, 197 + async get(sub) { 198 + const row = oauthSessionGet.get(sub); 199 + return row ? JSON.parse(decrypt(row.value)) : undefined; 200 + }, 201 + async del(sub) { oauthSessionDel.run(sub); }, 202 + }, 203 + }; 204 + 205 + if (process.argv[2] === 'init') { 206 + console.log(`db initialized at ${dbPath}`); 207 + process.exit(0); 208 + }
+52
src/destinations/atproto-record.js
··· 1 + import { Agent } from '@atproto/api'; 2 + import { oauth } from '../oauth.js'; 3 + 4 + export async function send(rule, urls, ctx) { 5 + if (!rule.owner_did) { 6 + console.warn(`atproto-record: rule=${rule.rule_id} owner has no DID, skipping`); 7 + return; 8 + } 9 + 10 + const config = rule.destination_config ? JSON.parse(rule.destination_config) : {}; 11 + const collection = config.collection; 12 + if (!collection) { 13 + throw new Error('atproto-record: destination_config.collection (NSID) is required'); 14 + } 15 + 16 + let session; 17 + try { 18 + session = await oauth.restore(rule.owner_did); 19 + } catch (err) { 20 + console.error(`atproto-record: could not restore OAuth session for ${rule.owner_did}`, err.message); 21 + return; 22 + } 23 + 24 + const agent = new Agent(session); 25 + 26 + for (const url of urls) { 27 + const record = { 28 + $type: collection, 29 + url, 30 + createdAt: new Date().toISOString(), 31 + source: { 32 + discord: { 33 + guildId: ctx.guildId, 34 + channelId: ctx.channelId, 35 + messageId: ctx.messageId, 36 + authorId: ctx.authorId, 37 + }, 38 + }, 39 + }; 40 + try { 41 + await agent.com.atproto.repo.createRecord({ 42 + repo: rule.owner_did, 43 + collection, 44 + record, 45 + }); 46 + } catch (err) { 47 + console.error('atproto-record: createRecord failed', { 48 + did: rule.owner_did, collection, err: err.message, 49 + }); 50 + } 51 + } 52 + }
+18
src/destinations/index.js
··· 1 + import * as stdout from './stdout.js'; 2 + import * as atprotoRecord from './atproto-record.js'; 3 + 4 + const registry = { 5 + 'stdout': stdout, 6 + 'atproto-record': atprotoRecord, 7 + }; 8 + 9 + export const KNOWN_DESTINATIONS = Object.keys(registry); 10 + 11 + export function dispatch(rule, urls, ctx) { 12 + const dest = registry[rule.destination_kind]; 13 + if (!dest) { 14 + console.warn(`unknown destination_kind=${rule.destination_kind} on rule=${rule.rule_id}`); 15 + return; 16 + } 17 + return dest.send(rule, urls, ctx); 18 + }
+15
src/destinations/stdout.js
··· 1 + export async function send(rule, urls, ctx) { 2 + for (const url of urls) { 3 + console.log(JSON.stringify({ 4 + kind: 'stdout', 5 + rule_id: rule.rule_id, 6 + owner_discord_id: rule.owner_discord_id, 7 + owner_did: rule.owner_did, 8 + url, 9 + guild_id: ctx.guildId, 10 + channel_id: ctx.channelId, 11 + message_id: ctx.messageId, 12 + author_id: ctx.authorId, 13 + })); 14 + } 15 + }
+48
src/discord-oauth.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', 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 getGuilds = async (accessToken) => { 43 + const res = await fetch(`${API}/users/@me/guilds`, { 44 + headers: { authorization: `Bearer ${accessToken}` }, 45 + }); 46 + if (!res.ok) throw new Error(`discord guilds fetch failed: ${res.status}`); 47 + return res.json(); 48 + };
+8
src/extract.js
··· 1 + const URL_RE = /\(?https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/gi; 2 + 3 + export function extractURLs(str) { 4 + if (!str) return []; 5 + return [...str.matchAll(URL_RE)] 6 + .map(m => m[0]) 7 + .map(s => (s.startsWith('(') && s.endsWith(')')) ? s.slice(1, -1) : s); 8 + }
+11
src/index.js
··· 1 + import 'dotenv/config'; 2 + import { serve } from '@hono/node-server'; 3 + import { startBot } from './bot.js'; 4 + import { app } from './web.js'; 5 + 6 + const port = Number(process.env.PORT ?? 3000); 7 + 8 + startBot(); 9 + 10 + serve({ fetch: app.fetch, port }); 11 + console.log(`disject web listening on :${port}`);
+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: npm run keygen'); 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 + };
+35
src/oauth.js
··· 1 + import { NodeOAuthClient } from '@atproto/oauth-client-node'; 2 + import { getKeyset } from './keys.js'; 3 + import { oauthStores } from './db.js'; 4 + 5 + const PUBLIC_URL = process.env.PUBLIC_URL; 6 + if (!PUBLIC_URL) throw new Error('PUBLIC_URL is required for OAuth'); 7 + 8 + const keyset = await getKeyset(); 9 + const clientId = `${PUBLIC_URL}/client-metadata.json`; 10 + 11 + // Scopes: 12 + // - 'atproto' is required 13 + // - 'transition:generic' grants write access to the user's repo, scoped to the 14 + // collections we ask for at runtime. For now we request it broadly so any 15 + // destination NSID a user configures can be written; later we may switch to 16 + // per-collection scopes once destinations are concrete. 17 + export const oauth = new NodeOAuthClient({ 18 + clientMetadata: { 19 + client_id: clientId, 20 + client_name: 'disject', 21 + client_uri: PUBLIC_URL, 22 + redirect_uris: [`${PUBLIC_URL}/oauth/callback`], 23 + grant_types: ['authorization_code', 'refresh_token'], 24 + scope: 'atproto transition:generic', 25 + response_types: ['code'], 26 + application_type: 'web', 27 + token_endpoint_auth_method: 'private_key_jwt', 28 + token_endpoint_auth_signing_alg: 'ES256', 29 + dpop_bound_access_tokens: true, 30 + jwks_uri: `${PUBLIC_URL}/jwks.json`, 31 + }, 32 + keyset, 33 + stateStore: oauthStores.state, 34 + sessionStore: oauthStores.session, 35 + });
+38
src/secret-store.js
··· 1 + import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto'; 2 + 3 + const ALGO = 'aes-256-gcm'; 4 + 5 + function deriveKey() { 6 + const secret = process.env.SESSION_SECRET; 7 + if (!secret) throw new Error('SESSION_SECRET is required for at-rest encryption'); 8 + return createHash('sha256').update(secret).digest(); 9 + } 10 + 11 + let cachedKey; 12 + function key() { 13 + if (!cachedKey) cachedKey = deriveKey(); 14 + return cachedKey; 15 + } 16 + 17 + export function encrypt(plaintext) { 18 + const iv = randomBytes(12); 19 + const cipher = createCipheriv(ALGO, key(), iv); 20 + const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); 21 + const tag = cipher.getAuthTag(); 22 + return `v1:${iv.toString('base64')}:${ct.toString('base64')}:${tag.toString('base64')}`; 23 + } 24 + 25 + export function decrypt(blob) { 26 + if (!blob || typeof blob !== 'string') throw new Error('decrypt: empty blob'); 27 + if (!blob.startsWith('v1:')) { 28 + // legacy plaintext (pre-encryption rows); return as-is so old rows still load 29 + return blob; 30 + } 31 + const [, ivB64, ctB64, tagB64] = blob.split(':'); 32 + const iv = Buffer.from(ivB64, 'base64'); 33 + const ct = Buffer.from(ctB64, 'base64'); 34 + const tag = Buffer.from(tagB64, 'base64'); 35 + const decipher = createDecipheriv(ALGO, key(), iv); 36 + decipher.setAuthTag(tag); 37 + return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8'); 38 + }
+9
src/stripe.js
··· 1 + import Stripe from 'stripe'; 2 + 3 + const apiKey = process.env.STRIPE_SECRET_KEY; 4 + export const stripe = apiKey ? new Stripe(apiKey) : null; 5 + 6 + export function requireStripe() { 7 + if (!stripe) throw new Error('STRIPE_SECRET_KEY is not configured'); 8 + return stripe; 9 + }
+625
src/web.js
··· 1 + import { randomBytes } from 'node:crypto'; 2 + import { Hono } from 'hono'; 3 + import { oauth } from './oauth.js'; 4 + import * as discord from './discord-oauth.js'; 5 + import { setSession, getSession, clearSession, SESSION_MAX_AGE_LONG } from './cookies.js'; 6 + import { 7 + upsertUserByDiscord, 8 + getUserByDiscord, 9 + listRulesForUser, 10 + insertRule, 11 + deleteRuleForUser, 12 + toggleRuleForUser, 13 + getCounter, 14 + listCounters, 15 + setUserSubscription, 16 + setActiveByCustomer, 17 + unlinkDid, 18 + oauthStores, 19 + } from './db.js'; 20 + import { client as botClient } from './bot.js'; 21 + import { stripe, requireStripe } from './stripe.js'; 22 + import { KNOWN_DESTINATIONS } from './destinations/index.js'; 23 + import { refreshActiveGuilds } from './active-guilds.js'; 24 + 25 + const PUBLIC_URL = process.env.PUBLIC_URL; 26 + const SESSION_SECRET = process.env.SESSION_SECRET; 27 + const DISCORD_REDIRECT = `${PUBLIC_URL}/discord/callback`; 28 + 29 + if (!PUBLIC_URL) throw new Error('PUBLIC_URL is required'); 30 + if (!SESSION_SECRET) throw new Error('SESSION_SECRET is required'); 31 + 32 + const errorPage = (c, message, status = 400) => c.html(` 33 + <!doctype html> 34 + <meta charset="utf-8"> 35 + <title>something went wrong</title> 36 + <h1>something went wrong</h1> 37 + <p>${message}</p> 38 + <p><a href="/">Start over</a></p> 39 + `, status); 40 + 41 + export const app = new Hono(); 42 + 43 + function requireLinkedUser(c) { 44 + const s = getSession(c, SESSION_SECRET); 45 + if (!s?.discord?.userId || !s?.did) return null; 46 + const user = getUserByDiscord.get(s.discord.userId); 47 + if (!user || user.did !== s.did) return null; 48 + return { user, session: s }; 49 + } 50 + 51 + function botGuildsForUser(userGuildIds) { 52 + if (!botClient.isReady?.()) return []; 53 + const cache = botClient.guilds.cache; 54 + return userGuildIds 55 + .map(id => cache.get(id)) 56 + .filter(Boolean) 57 + .map(g => ({ id: g.id, name: g.name })); 58 + } 59 + 60 + app.get('/', (c) => { 61 + const s = getSession(c, SESSION_SECRET); 62 + const discordLinked = !!s?.discord; 63 + const linkedUser = discordLinked ? getUserByDiscord.get(s.discord.userId) : null; 64 + const didLinked = !!linkedUser?.did && linkedUser.did === s?.did; 65 + 66 + const discordCard = discordLinked ? ` 67 + <ul> 68 + <li>User: <code>${esc(s.discord.username ?? s.discord.userId)}</code></li> 69 + <li>ID: <code>${esc(s.discord.userId)}</code></li> 70 + </ul> 71 + <form action="/cancel" method="post"><button type="submit">Disconnect</button></form> 72 + ` : ` 73 + <p><a href="/discord/start"><button type="button">Link Discord account</button></a></p> 74 + <p><small>So we know which Discord ID's messages are yours.</small></p> 75 + `; 76 + 77 + const atmoCard = !discordLinked ? ` 78 + <form action="/login" method="get"> 79 + <input name="handle" placeholder="you.bsky.social" disabled> 80 + <button type="submit" disabled>Sign in</button> 81 + </form> 82 + <p><small>Link a Discord account first.</small></p> 83 + ` : didLinked ? ` 84 + <ul><li>DID: <code>${esc(linkedUser.did)}</code></li></ul> 85 + <form action="/atmo/unlink" method="post" style="display:inline"> 86 + <button type="submit">Unlink</button> 87 + </form> 88 + <form action="/login" method="get" style="display:inline"> 89 + <input name="handle" placeholder="you.bsky.social" required> 90 + <button type="submit">Re-link</button> 91 + </form> 92 + ` : ` 93 + <form action="/login" method="get"> 94 + <input name="handle" placeholder="you.bsky.social" required> 95 + <button type="submit">Sign in</button> 96 + </form> 97 + <p><small>Fine-grained OAuth, no write permissions yet.</small></p> 98 + `; 99 + 100 + const subscribeButton = !stripe ? `<p><small>Subscriptions not configured.</small></p>` 101 + : linkedUser?.active ? ` 102 + <form action="/portal" method="post" style="display:inline"> 103 + <button type="submit">Manage subscription</button> 104 + </form> 105 + ` : ` 106 + <form action="/subscribe" method="post" style="display:inline"> 107 + <button type="submit">Subscribe — $2/month</button> 108 + </form> 109 + `; 110 + 111 + const accountCard = !discordLinked ? ` 112 + <p><small>Link both accounts to create your disject account.</small></p> 113 + ` : !didLinked ? ` 114 + <p><small>Sign in with an Atmosphere account to create your disject account.</small></p> 115 + ` : ` 116 + <ul> 117 + <li>Discord: <code>${esc(linkedUser.discord_id)}</code></li> 118 + <li>Atmosphere: <code>${esc(linkedUser.did)}</code></li> 119 + <li>Status: <code>${linkedUser.active ? 'active' : 'inactive'}</code></li> 120 + </ul> 121 + <p> 122 + <a href="/rules"><button type="button">Manage rules</button></a> 123 + ${subscribeButton} 124 + </p> 125 + `; 126 + 127 + return c.html(layout('disject', ` 128 + <h1>disject</h1> 129 + <p>Route Discord URLs into atproto-shaped destinations.</p> 130 + <div class="row"> 131 + <div class="card"><h2>Discord</h2>${discordCard}</div> 132 + <div class="connector" aria-hidden="true"></div> 133 + <div class="card"><h2>Atmosphere account</h2>${atmoCard}</div> 134 + <div class="connector" aria-hidden="true"></div> 135 + <div class="card"><h2>disject account</h2>${accountCard}</div> 136 + </div> 137 + <section class="how"> 138 + <h2>How this works</h2> 139 + <ol> 140 + <li><strong>Link Discord.</strong> We learn your Discord user ID and which servers you're in.</li> 141 + <li><strong>Sign in with an Atmosphere account.</strong> Fine-grained OAuth.</li> 142 + <li><strong>Configure rules.</strong> Pick which servers/channels/authors to harvest URLs from, and where to route them.</li> 143 + <li><strong>Subscribe.</strong> $2/month flat. The bot only acts on rules belonging to active subscribers.</li> 144 + </ol> 145 + <p><strong>Pass-through.</strong> URLs flow through disject and out to your destinations; we don't store them.</p> 146 + </section> 147 + `)); 148 + }); 149 + 150 + app.post('/cancel', (c) => { 151 + clearSession(c); 152 + return c.redirect('/'); 153 + }); 154 + 155 + app.post('/atmo/unlink', async (c) => { 156 + const ctx = requireLinkedUser(c); 157 + if (!ctx) return c.redirect('/'); 158 + try { await oauth.revoke?.(ctx.user.did); } catch {} 159 + try { await oauthStores.session.del(ctx.user.did); } catch {} 160 + unlinkDid(ctx.user.id); 161 + setSession(c, { discord: ctx.session.discord }, SESSION_SECRET, SESSION_MAX_AGE_LONG); 162 + return c.redirect('/'); 163 + }); 164 + 165 + app.get('/client-metadata.json', (c) => c.json(oauth.clientMetadata)); 166 + app.get('/jwks.json', (c) => c.json(oauth.jwks)); 167 + 168 + app.get('/discord/start', (c) => { 169 + const discordState = randomBytes(16).toString('hex'); 170 + setSession(c, { discordState }, SESSION_SECRET); 171 + return c.redirect(discord.authUrl({ state: discordState, redirectUri: DISCORD_REDIRECT })); 172 + }); 173 + 174 + app.get('/discord/callback', async (c) => { 175 + const s = getSession(c, SESSION_SECRET); 176 + const code = c.req.query('code'); 177 + const state = c.req.query('state'); 178 + if (!code) return errorPage(c, 'Missing Discord authorization code.'); 179 + if (!s?.discordState || state !== s.discordState) { 180 + return errorPage(c, 'Discord state mismatch — possible CSRF.'); 181 + } 182 + 183 + let user, guildsRaw; 184 + try { 185 + const token = await discord.exchangeCode({ code, redirectUri: DISCORD_REDIRECT }); 186 + user = await discord.getUser(token.access_token); 187 + guildsRaw = await discord.getGuilds(token.access_token); 188 + } catch (err) { 189 + console.error('discord oauth failed', err); 190 + return errorPage(c, 'Could not verify Discord account.'); 191 + } 192 + 193 + // Only persist guilds where the bot is also present (keeps the cookie small 194 + // and matches what's actionable). 195 + const botGuildIds = botClient.isReady?.() 196 + ? new Set(botClient.guilds.cache.map(g => g.id)) 197 + : new Set(); 198 + const guildIds = guildsRaw.filter(g => botGuildIds.has(g.id)).map(g => g.id); 199 + 200 + upsertUserByDiscord({ discordId: user.id }); 201 + 202 + setSession(c, { 203 + discord: { userId: user.id, username: user.username, guildIds }, 204 + }, SESSION_SECRET, SESSION_MAX_AGE_LONG); 205 + return c.redirect('/'); 206 + }); 207 + 208 + const normalizeHandle = (raw) => { 209 + let h = raw.trim(); 210 + if (h.startsWith('@')) h = h.slice(1); 211 + if (h.includes('/')) { 212 + try { 213 + const u = new URL(h.includes('://') ? h : `https://${h}`); 214 + if (u.pathname.startsWith('/profile/')) { 215 + h = decodeURIComponent(u.pathname.slice('/profile/'.length).split('/')[0]); 216 + if (h.startsWith('@')) h = h.slice(1); 217 + } 218 + } catch {} 219 + } 220 + if (h.endsWith('.')) h = h.slice(0, -1); 221 + return h; 222 + }; 223 + 224 + app.get('/login', async (c) => { 225 + const s = getSession(c, SESSION_SECRET); 226 + if (!s?.discord) return c.redirect('/'); 227 + const raw = c.req.query('handle'); 228 + if (!raw) return errorPage(c, 'Missing handle.'); 229 + const handle = normalizeHandle(raw); 230 + const state = randomBytes(16).toString('hex'); 231 + try { 232 + const url = await oauth.authorize(handle, { state }); 233 + return c.redirect(url.toString()); 234 + } catch (err) { 235 + console.error('oauth authorize failed', err); 236 + return errorPage(c, `Could not resolve &ldquo;${esc(handle)}&rdquo;. Enter a handle like <code>you.bsky.social</code> or a DID.`); 237 + } 238 + }); 239 + 240 + app.get('/oauth/callback', async (c) => { 241 + const s = getSession(c, SESSION_SECRET); 242 + if (!s?.discord) { 243 + clearSession(c); 244 + return errorPage(c, 'Missing flow state. Start over.'); 245 + } 246 + 247 + let session; 248 + try { 249 + const params = new URLSearchParams(c.req.url.split('?')[1]); 250 + ({ session } = await oauth.callback(params)); 251 + } catch (err) { 252 + console.error('atproto oauth callback failed', err); 253 + clearSession(c); 254 + return errorPage(c, 'Sign-in with Atmosphere account failed. Try again.'); 255 + } 256 + 257 + upsertUserByDiscord({ discordId: s.discord.userId, did: session.did }); 258 + 259 + setSession(c, { 260 + discord: s.discord, 261 + did: session.did, 262 + }, SESSION_SECRET, SESSION_MAX_AGE_LONG); 263 + return c.redirect('/'); 264 + }); 265 + 266 + // ───── Rules CRUD ───────────────────────────────────────────────────────── 267 + 268 + app.get('/rules', (c) => { 269 + const ctx = requireLinkedUser(c); 270 + if (!ctx) return c.redirect('/'); 271 + const { user, session } = ctx; 272 + 273 + const rules = listRulesForUser.all(user.id); 274 + const userGuildIds = session.discord.guildIds ?? []; 275 + const guilds = botGuildsForUser(userGuildIds); 276 + const thisMonth = getCounter(user.id); 277 + const recent = listCounters(user.id, 6); 278 + 279 + const usageBlock = ` 280 + <section class="usage"> 281 + <strong>Writes this month:</strong> ${thisMonth} 282 + ${recent.length > 1 ? `<small style="margin-left:1em">recent: ${ 283 + recent.map(r => `${esc(r.period)}=${r.count}`).join(', ') 284 + }</small>` : ''} 285 + </section> 286 + `; 287 + 288 + const ruleRows = rules.length === 0 ? ` 289 + <p><em>No rules yet. Add one below.</em></p> 290 + ` : ` 291 + <table class="rules"> 292 + <thead><tr> 293 + <th>Server</th><th>Channel</th><th>Author</th><th>Destination</th><th>State</th><th></th> 294 + </tr></thead> 295 + <tbody> 296 + ${rules.map(r => ruleRow(r, guilds)).join('')} 297 + </tbody> 298 + </table> 299 + `; 300 + 301 + // Rule form dropdown: servers the user is a member of where the bot is present. 302 + const guildOptions = guilds.length === 0 303 + ? `<option value="">(none — invite the bot to a server you're in first)</option>` 304 + : guilds.map(g => `<option value="${esc(g.id)}">${esc(g.name)} (${esc(g.id)})</option>`).join(''); 305 + 306 + return c.html(layout('disject — rules', ` 307 + <h1>Rules</h1> 308 + <p><a href="/">← back</a></p> 309 + 310 + ${usageBlock} 311 + 312 + ${ruleRows} 313 + 314 + <h2>Add rule</h2> 315 + <form action="/rules" method="post" class="add-rule"> 316 + <p> 317 + <label>Server<br> 318 + <select name="guild_id">${guildOptions}</select> 319 + </label> 320 + </p> 321 + <p> 322 + <label>Channel ID (optional)<br> 323 + <input name="channel_id" placeholder="leave blank for any channel"> 324 + </label> 325 + <small>Get an ID via Discord → Settings → Advanced → enable Developer Mode, then right-click a channel → Copy ID.</small> 326 + </p> 327 + <fieldset> 328 + <legend>Author</legend> 329 + <label><input type="radio" name="author_mode" value="self" checked> Just me</label><br> 330 + <label><input type="radio" name="author_mode" value="any"> Anyone (requires a server)</label><br> 331 + <label><input type="radio" name="author_mode" value="id"> Specific Discord user ID:</label> 332 + <input name="author_id" placeholder="discord user id"> 333 + </fieldset> 334 + <p> 335 + <label>Destination<br> 336 + <select name="destination_kind"> 337 + <option value="stdout">stdout (debug)</option> 338 + <option value="atproto-record">atproto-record (write to your PDS)</option> 339 + </select> 340 + </label> 341 + </p> 342 + <p> 343 + <label>Destination config (JSON)<br> 344 + <textarea name="destination_config" rows="4" placeholder='atproto-record example:&#10;{ "collection": "com.example.bookmark" }'></textarea> 345 + </label> 346 + <small>For <code>atproto-record</code>: <code>{ "collection": "&lt;NSID&gt;" }</code>. Each URL becomes a record under that collection on your PDS. <code>stdout</code> ignores config.</small> 347 + </p> 348 + <p><button type="submit">Add rule</button></p> 349 + </form> 350 + 351 + <p style="margin-top:2rem;color:#666;font-size:0.85rem"> 352 + The "Server" dropdown shows servers where the bot is present and you are a member. If a server is missing, 353 + invite the bot and re-link your Discord account here. 354 + </p> 355 + `)); 356 + }); 357 + 358 + app.post('/rules', async (c) => { 359 + const ctx = requireLinkedUser(c); 360 + if (!ctx) return c.redirect('/'); 361 + const { user, session } = ctx; 362 + 363 + const form = await c.req.formData(); 364 + const guildIdRaw = (form.get('guild_id') ?? '').toString().trim(); 365 + const channelIdRaw = (form.get('channel_id') ?? '').toString().trim(); 366 + const authorMode = (form.get('author_mode') ?? 'self').toString(); 367 + const authorIdRaw = (form.get('author_id') ?? '').toString().trim(); 368 + const destKind = (form.get('destination_kind') ?? 'stdout').toString(); 369 + const destConfigRaw = (form.get('destination_config') ?? '').toString().trim(); 370 + 371 + let author_id; 372 + if (authorMode === 'self') author_id = 'self'; 373 + else if (authorMode === 'any') author_id = null; 374 + else if (authorMode === 'id') { 375 + if (!/^\d+$/.test(authorIdRaw)) return errorPage(c, 'Author Discord ID must be all digits.'); 376 + author_id = authorIdRaw; 377 + } else { 378 + return errorPage(c, 'Invalid author mode.'); 379 + } 380 + 381 + const guild_id = guildIdRaw || null; 382 + if (!guild_id) { 383 + return errorPage(c, 'Every rule must specify a server.'); 384 + } 385 + const memberOf = new Set(session.discord.guildIds ?? []); 386 + if (!memberOf.has(guild_id)) { 387 + return errorPage(c, 'You must be a member of that server (and the bot must be in it). Re-link Discord if your guild list is stale.', 403); 388 + } 389 + 390 + const channel_id = channelIdRaw || null; 391 + if (channel_id && !/^\d+$/.test(channel_id)) { 392 + return errorPage(c, 'Channel ID must be all digits.'); 393 + } 394 + 395 + if (!KNOWN_DESTINATIONS.includes(destKind)) { 396 + return errorPage(c, `Unknown destination kind: ${esc(destKind)}`); 397 + } 398 + let destination_config = null; 399 + if (destConfigRaw) { 400 + let parsed; 401 + try { parsed = JSON.parse(destConfigRaw); } catch { return errorPage(c, 'Destination config must be valid JSON.'); } 402 + if (destKind === 'atproto-record') { 403 + if (!parsed?.collection || typeof parsed.collection !== 'string') { 404 + return errorPage(c, 'atproto-record requires a "collection" string (an NSID) in the config.'); 405 + } 406 + if (!/^[a-z][a-z0-9]*(\.[a-z0-9-]+)+$/i.test(parsed.collection)) { 407 + return errorPage(c, 'collection must look like an NSID (e.g. "com.example.bookmark").'); 408 + } 409 + } 410 + destination_config = destConfigRaw; 411 + } else if (destKind === 'atproto-record') { 412 + return errorPage(c, 'atproto-record requires a config (e.g. { "collection": "com.example.bookmark" }).'); 413 + } 414 + 415 + insertRule({ 416 + user_id: user.id, 417 + guild_id, 418 + channel_id, 419 + author_id, 420 + destination_kind: destKind, 421 + destination_config, 422 + enabled: 1, 423 + }); 424 + refreshActiveGuilds(); 425 + 426 + return c.redirect('/rules'); 427 + }); 428 + 429 + app.post('/rules/:id/delete', (c) => { 430 + const ctx = requireLinkedUser(c); 431 + if (!ctx) return c.redirect('/'); 432 + const id = Number(c.req.param('id')); 433 + if (!Number.isInteger(id)) return errorPage(c, 'Bad rule id.'); 434 + deleteRuleForUser.run(id, ctx.user.id); 435 + refreshActiveGuilds(); 436 + return c.redirect('/rules'); 437 + }); 438 + 439 + app.post('/rules/:id/toggle', (c) => { 440 + const ctx = requireLinkedUser(c); 441 + if (!ctx) return c.redirect('/'); 442 + const id = Number(c.req.param('id')); 443 + if (!Number.isInteger(id)) return errorPage(c, 'Bad rule id.'); 444 + toggleRuleForUser.run(id, ctx.user.id); 445 + refreshActiveGuilds(); 446 + return c.redirect('/rules'); 447 + }); 448 + 449 + 450 + // ───── Stripe ────────────────────────────────────────────────────────────── 451 + 452 + app.post('/subscribe', async (c) => { 453 + const ctx = requireLinkedUser(c); 454 + if (!ctx) return c.redirect('/'); 455 + const stripe = requireStripe(); 456 + const priceId = process.env.STRIPE_PRICE_ID; 457 + if (!priceId) return errorPage(c, 'STRIPE_PRICE_ID is not configured.', 500); 458 + 459 + try { 460 + const session = await stripe.checkout.sessions.create({ 461 + mode: 'subscription', 462 + line_items: [{ price: priceId, quantity: 1 }], 463 + client_reference_id: String(ctx.user.id), 464 + customer: ctx.user.stripe_customer_id || undefined, 465 + success_url: `${PUBLIC_URL}/?subscribed=1`, 466 + cancel_url: `${PUBLIC_URL}/`, 467 + allow_promotion_codes: true, 468 + }); 469 + return c.redirect(session.url); 470 + } catch (err) { 471 + console.error('checkout session create failed', err); 472 + return errorPage(c, 'Could not start subscription.', 500); 473 + } 474 + }); 475 + 476 + app.post('/portal', async (c) => { 477 + const ctx = requireLinkedUser(c); 478 + if (!ctx) return c.redirect('/'); 479 + if (!ctx.user.stripe_customer_id) return c.redirect('/'); 480 + const stripe = requireStripe(); 481 + 482 + try { 483 + const session = await stripe.billingPortal.sessions.create({ 484 + customer: ctx.user.stripe_customer_id, 485 + return_url: `${PUBLIC_URL}/`, 486 + }); 487 + return c.redirect(session.url); 488 + } catch (err) { 489 + console.error('portal session create failed', err); 490 + return errorPage(c, 'Could not open the customer portal.', 500); 491 + } 492 + }); 493 + 494 + app.post('/webhooks/stripe', async (c) => { 495 + if (!stripe) return c.text('not configured', 503); 496 + const secret = process.env.STRIPE_WEBHOOK_SECRET; 497 + if (!secret) return c.text('webhook secret not configured', 503); 498 + 499 + const sig = c.req.header('stripe-signature'); 500 + const body = await c.req.text(); 501 + 502 + let event; 503 + try { 504 + event = stripe.webhooks.constructEvent(body, sig, secret); 505 + } catch (err) { 506 + console.error('stripe webhook signature verification failed', err.message); 507 + return c.text('bad signature', 400); 508 + } 509 + 510 + try { 511 + switch (event.type) { 512 + case 'checkout.session.completed': { 513 + const s = event.data.object; 514 + const userId = Number(s.client_reference_id); 515 + const customerId = typeof s.customer === 'string' ? s.customer : s.customer?.id; 516 + if (Number.isInteger(userId) && customerId) { 517 + setUserSubscription(userId, customerId, 1); 518 + refreshActiveGuilds(); 519 + } 520 + break; 521 + } 522 + case 'customer.subscription.deleted': { 523 + const sub = event.data.object; 524 + const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer?.id; 525 + if (customerId) { 526 + setActiveByCustomer(customerId, 0); 527 + refreshActiveGuilds(); 528 + } 529 + break; 530 + } 531 + case 'customer.subscription.updated': 532 + case 'customer.subscription.created': { 533 + const sub = event.data.object; 534 + const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer?.id; 535 + const active = ['active', 'trialing'].includes(sub.status) ? 1 : 0; 536 + if (customerId) { 537 + setActiveByCustomer(customerId, active); 538 + refreshActiveGuilds(); 539 + } 540 + break; 541 + } 542 + default: 543 + break; 544 + } 545 + } catch (err) { 546 + console.error('stripe webhook handler failed', { type: event.type, err: err.message }); 547 + return c.text('handler error', 500); 548 + } 549 + 550 + return c.text('ok'); 551 + }); 552 + 553 + // ───── helpers ───────────────────────────────────────────────────────────── 554 + 555 + function ruleRow(r, guilds) { 556 + const guildLabel = r.guild_id 557 + ? (guilds.find(g => g.id === r.guild_id)?.name ?? r.guild_id) 558 + : '(any)'; 559 + const authorLabel = r.author_id === 'self' ? 'just me' 560 + : r.author_id === null ? '(anyone)' 561 + : r.author_id; 562 + const destLabel = r.destination_config 563 + ? `${r.destination_kind} <small>${esc(r.destination_config)}</small>` 564 + : r.destination_kind; 565 + return ` 566 + <tr> 567 + <td>${esc(guildLabel)}</td> 568 + <td>${esc(r.channel_id ?? '(any)')}</td> 569 + <td>${esc(authorLabel)}</td> 570 + <td>${destLabel}</td> 571 + <td>${r.enabled ? 'on' : 'off'}</td> 572 + <td> 573 + <form action="/rules/${r.id}/toggle" method="post" style="display:inline"> 574 + <button type="submit">${r.enabled ? 'Disable' : 'Enable'}</button> 575 + </form> 576 + <form action="/rules/${r.id}/delete" method="post" style="display:inline"> 577 + <button type="submit">Delete</button> 578 + </form> 579 + </td> 580 + </tr>`; 581 + } 582 + 583 + function layout(title, body) { 584 + return `<!doctype html> 585 + <meta charset="utf-8"> 586 + <title>${esc(title)}</title> 587 + <style> 588 + body { font-family: system-ui, sans-serif; max-width: 1100px; margin: 2rem auto; padding: 0 1rem; } 589 + .row { display: flex; gap: 0; flex-wrap: wrap; align-items: stretch; } 590 + .card { flex: 1 1 0; min-width: 220px; border: 1px solid #ccc; border-radius: 6px; padding: 0.75rem; font-size: 0.9rem; } 591 + .card h2 { margin-top: 0; font-size: 0.95rem; } 592 + .card ul { padding-left: 1.1rem; margin: 0.25rem 0; } 593 + .card p { margin: 0.5rem 0; } 594 + .connector { flex: 0 0 1.25rem; align-self: center; height: 1px; background: #ccc; position: relative; } 595 + .connector::after { 596 + content: ''; position: absolute; right: -1px; top: 50%; 597 + width: 0; height: 0; 598 + border-top: 5px solid transparent; border-bottom: 5px solid transparent; 599 + border-left: 6px solid #ccc; transform: translateY(-50%); 600 + } 601 + code { word-break: break-all; } 602 + .how { margin-top: 2rem; color: #333; font-size: 0.95rem; } 603 + .how h2 { font-size: 1rem; margin-top: 0; } 604 + .how ol, .how ul { padding-left: 1.25rem; } 605 + .how li { margin-bottom: 0.25rem; } 606 + table.rules { width: 100%; border-collapse: collapse; margin: 1rem 0; font-size: 0.9rem; } 607 + table.rules th, table.rules td { border: 1px solid #ddd; padding: 0.4rem 0.5rem; text-align: left; vertical-align: top; } 608 + table.rules th { background: #f5f5f5; font-weight: 600; } 609 + form.add-rule fieldset { border: 1px solid #ccc; padding: 0.5rem 0.75rem; margin: 0.5rem 0; } 610 + form.add-rule legend { padding: 0 0.25rem; } 611 + form.add-rule input[type=text], form.add-rule input:not([type]), form.add-rule select, form.add-rule textarea { 612 + width: 100%; max-width: 480px; box-sizing: border-box; 613 + } 614 + form.add-rule label small { display: block; color: #666; font-weight: normal; margin-top: 0.25rem; } 615 + .usage { background: #f5f7fa; border: 1px solid #dde3ea; border-radius: 6px; padding: 0.5rem 0.75rem; margin: 1rem 0; font-size: 0.9rem; } 616 + .usage small { color: #666; } 617 + </style> 618 + ${body}`; 619 + } 620 + 621 + function esc(s) { 622 + return String(s ?? '').replace(/[&<>"']/g, c => ({ 623 + '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;', 624 + }[c])); 625 + }