[READ-ONLY] Mirror of https://github.com/flo-bit/atproto-notify.
0

Configure Feed

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

first commit

author
Florian
date (May 23, 2026, 3:21 AM +0200) commit a9d0d130
+6605
+30
.gitignore
··· 1 + # Dependencies 2 + node_modules/ 3 + 4 + # Build output 5 + dist/ 6 + *.tsbuildinfo 7 + 8 + # Cloudflare / Wrangler 9 + .wrangler/ 10 + .dev.vars 11 + .dev.vars.* 12 + wrangler.toml.local 13 + 14 + # Generated lexicon types 15 + packages/lexicons/src/lexicons/ 16 + 17 + # Logs 18 + *.log 19 + npm-debug.log* 20 + pnpm-debug.log* 21 + 22 + # Editor / OS 23 + .DS_Store 24 + .idea/ 25 + .vscode/ 26 + *.swp 27 + 28 + # Env 29 + .env 30 + .env.*
+736
PROMPT-1-relay.md
··· 1 + # Build the atproto notification relay (monorepo + lexicons + Cloudflare Worker) 2 + 3 + You're building a Cloudflare Workers service that lets any AT Protocol app send notifications to its users. Users approve which apps can send via a separate SvelteKit website (built in a follow-up prompt). Delivery in v1 is via Telegram bot. 4 + 5 + This prompt sets up the monorepo, the shared lexicons package, and the relay Worker. The SvelteKit website goes into `apps/web` later — leave that empty for now, but include it in `pnpm-workspace.yaml`. 6 + 7 + --- 8 + 9 + ## Configuration constants (used throughout — surface in a single README section) 10 + 11 + - **Relay domain**: `notifs.atmo.tools` 12 + - **Relay DID**: `did:web:notifs.atmo.tools` 13 + - **Relay service ID**: `#notif_relay` (so service-ref form is `did:web:notifs.atmo.tools#notif_relay`) 14 + - **Lexicon NSID prefix**: `tools.atmo.notifs` 15 + - **Pending request TTL**: 7 days 16 + - **Link token TTL**: 10 minutes 17 + - **DID doc cache TTL (in KV)**: 5 minutes 18 + - **Profile cache TTL (in `senders` table)**: 24 hours 19 + 20 + These values appear in many places. Put a clear "Configuration" section at the top of the root README listing where they live so the user can find-replace later if needed. 21 + 22 + --- 23 + 24 + ## Stack (non-negotiable) 25 + 26 + - **Package manager**: pnpm (workspaces) 27 + - **Language**: TypeScript, strict mode 28 + - **Runtime**: Cloudflare Workers (modules format) 29 + - **XRPC framework**: `@atcute/xrpc-server` (the plain package works on Workers natively — the cloudflare adapter is only for WebSocket subscriptions, which this project doesn't use) 30 + - **JWT verification**: `@atcute/xrpc-server/auth` (`ServiceJwtVerifier`) 31 + - **DID resolution**: `@atcute/identity-resolver` with `CompositeDidDocumentResolver` (plc + web) 32 + - **Outgoing atproto calls** (fetching sender profiles): `@atcute/client` 33 + - **Lexicon types**: `@atcute/lex-cli` — set up `pnpm generate` in the lexicons package 34 + - **Persistent state**: Cloudflare D1 35 + - **Caching**: Cloudflare KV 36 + - **Async dispatch**: Cloudflare Queues 37 + - **Telegram**: Bot API via plain `fetch` (no SDK needed) 38 + - **Testing**: vitest with `@cloudflare/vitest-pool-workers` 39 + 40 + --- 41 + 42 + ## Monorepo layout 43 + 44 + Create exactly this structure: 45 + 46 + ``` 47 + . 48 + ├── package.json # workspace root, private, scripts 49 + ├── pnpm-workspace.yaml # includes apps/* and packages/* 50 + ├── tsconfig.base.json # shared strict config 51 + ├── .gitignore # node_modules, .wrangler, dist, etc. 52 + ├── README.md # overview, setup, config, deployment 53 + ├── apps/ 54 + │ ├── relay/ 55 + │ │ ├── package.json 56 + │ │ ├── wrangler.toml 57 + │ │ ├── tsconfig.json 58 + │ │ ├── src/ 59 + │ │ │ ├── index.ts # default export { fetch, queue, scheduled } 60 + │ │ │ ├── env.ts # Env type for bindings 61 + │ │ │ ├── router.ts # builds the XRPCRouter, wires handlers 62 + │ │ │ ├── auth/ 63 + │ │ │ │ ├── verifier.ts # constructs ServiceJwtVerifier 64 + │ │ │ │ ├── sender.ts # verifySenderRequest(req, lxm) 65 + │ │ │ │ └── user.ts # verifyUserRequest(req, lxm) 66 + │ │ │ ├── xrpc/ 67 + │ │ │ │ ├── requestPermission.ts 68 + │ │ │ │ ├── send.ts 69 + │ │ │ │ ├── grant.ts 70 + │ │ │ │ ├── revoke.ts 71 + │ │ │ │ ├── denyPending.ts 72 + │ │ │ │ ├── muteGrant.ts 73 + │ │ │ │ ├── listGrants.ts 74 + │ │ │ │ ├── listPending.ts 75 + │ │ │ │ ├── linkChannel.ts 76 + │ │ │ │ ├── unlinkChannel.ts 77 + │ │ │ │ ├── listChannels.ts 78 + │ │ │ │ ├── getSettings.ts 79 + │ │ │ │ └── updateSettings.ts 80 + │ │ │ ├── delivery/ 81 + │ │ │ │ ├── telegram.ts # low-level sendMessage / answerCallbackQuery 82 + │ │ │ │ └── dispatcher.ts # queue consumer 83 + │ │ │ ├── telegram/ 84 + │ │ │ │ ├── webhook.ts # POST /telegram/webhook/:secret 85 + │ │ │ │ ├── commands.ts # /start, /list, /revoke, /settings 86 + │ │ │ │ └── callbacks.ts # approve:/deny:/toggle: handlers 87 + │ │ │ ├── db/ 88 + │ │ │ │ ├── schema.sql # canonical schema reference (matches migrations) 89 + │ │ │ │ └── queries.ts # typed query functions 90 + │ │ │ ├── identity/ 91 + │ │ │ │ └── resolve.ts # DID resolution with KV caching 92 + │ │ │ ├── profile/ 93 + │ │ │ │ └── fetch.ts # fetch Bluesky profile for a sender DID 94 + │ │ │ ├── ratelimit.ts # KV-backed counters 95 + │ │ │ ├── well-known.ts # serves did.json + lexicon JSONs 96 + │ │ │ └── lib/ 97 + │ │ │ ├── errors.ts # convenience XRPCError wrappers 98 + │ │ │ ├── ids.ts # cuid2/nanoid-based id helpers 99 + │ │ │ └── time.ts # now(), addDays(), etc. 100 + │ │ ├── migrations/ 101 + │ │ │ └── 0001_init.sql 102 + │ │ └── test/ 103 + │ │ ├── auth.test.ts 104 + │ │ ├── requestPermission.test.ts 105 + │ │ ├── grant.test.ts 106 + │ │ ├── send.test.ts 107 + │ │ ├── telegram-link.test.ts 108 + │ │ └── ratelimit.test.ts 109 + │ └── web/ # placeholder; SvelteKit goes here later 110 + │ └── .gitkeep 111 + └── packages/ 112 + └── lexicons/ 113 + ├── package.json # name: "@atmo/notifs-lexicons", exports types 114 + ├── tsconfig.json 115 + ├── lex.config.json # @atcute/lex-cli config 116 + ├── lexicons/ 117 + │ └── tools/atmo/notifs/ 118 + │ ├── requestPermission.json 119 + │ ├── send.json 120 + │ ├── grant.json 121 + │ ├── revoke.json 122 + │ ├── denyPending.json 123 + │ ├── muteGrant.json 124 + │ ├── listGrants.json 125 + │ ├── listPending.json 126 + │ ├── linkChannel.json 127 + │ ├── unlinkChannel.json 128 + │ ├── listChannels.json 129 + │ ├── getSettings.json 130 + │ ├── updateSettings.json 131 + │ ├── authSender.json 132 + │ └── authUser.json 133 + └── src/ 134 + └── index.ts # re-exports generated types 135 + ``` 136 + 137 + --- 138 + 139 + ## Lexicons — write all 15 as actual JSON files 140 + 141 + All use `"lexicon": 1`. All NSIDs follow `tools.atmo.notifs.<name>`. 142 + 143 + ### Sender-authenticated procedures 144 + 145 + **`tools.atmo.notifs.requestPermission`** (procedure): 146 + 147 + - input: `{ recipient: string (format: did), reason?: string (maxLength: 200) }` 148 + - output: `{ id: string, status: "pending" | "alreadyGranted" }` 149 + - errors: `NotAuthorized`, `RateLimitExceeded` 150 + 151 + **`tools.atmo.notifs.send`** (procedure): 152 + 153 + - input: `{ recipient: string (format: did), title: string (maxLength: 100), body: string (maxLength: 500), uri?: string, threadKey?: string }` 154 + - output: `{ id: string, delivered: integer }` 155 + - errors: `NotAuthorized`, `RateLimitExceeded` 156 + 157 + ### User-authenticated procedures and queries 158 + 159 + **`tools.atmo.notifs.grant`** (procedure): 160 + 161 + - input: `{ sender: string (format: did), requestId?: string }` 162 + - output: `{ granted: boolean }` 163 + 164 + **`tools.atmo.notifs.revoke`** (procedure): 165 + 166 + - input: `{ sender: string (format: did) }` 167 + - output: `{ revoked: boolean }` 168 + 169 + **`tools.atmo.notifs.denyPending`** (procedure): 170 + 171 + - input: `{ requestId: string }` 172 + - output: `{ denied: boolean }` 173 + - (Deletes the pending request without granting. Doesn't blocklist the sender; the sender can request again after rate limits allow.) 174 + 175 + **`tools.atmo.notifs.muteGrant`** (procedure): 176 + 177 + - input: `{ sender: string (format: did), muted: boolean }` 178 + - output: `{ muted: boolean }` 179 + 180 + **`tools.atmo.notifs.listGrants`** (query): 181 + 182 + - output: `{ grants: array of objects with: sender (did), senderHandle?, senderDisplayName?, senderAvatar?, grantedAt (datetime), muted (boolean) }` 183 + 184 + **`tools.atmo.notifs.listPending`** (query): 185 + 186 + - output: `{ pending: array of objects with: id, sender (did), senderHandle?, senderDisplayName?, senderAvatar?, reason?, createdAt (datetime), expiresAt (datetime) }` 187 + 188 + **`tools.atmo.notifs.linkChannel`** (procedure): 189 + 190 + - input: `{ platform: "telegram" }` (use closed enum, only one value for v1) 191 + - output: `{ token: string, deepLink: string }` 192 + 193 + **`tools.atmo.notifs.unlinkChannel`** (procedure): 194 + 195 + - input: `{ platform: "telegram" }` 196 + - output: `{ unlinked: boolean }` 197 + 198 + **`tools.atmo.notifs.listChannels`** (query): 199 + 200 + - output: `{ channels: array of objects with: platform, linkedAt (datetime), displayName? }` 201 + 202 + **`tools.atmo.notifs.getSettings`** (query): 203 + 204 + - output: `{ notifyPendingViaTelegram: boolean }` 205 + 206 + **`tools.atmo.notifs.updateSettings`** (procedure): 207 + 208 + - input: `{ notifyPendingViaTelegram?: boolean }` (all fields optional, partial update) 209 + - output: `{ notifyPendingViaTelegram: boolean }` 210 + 211 + ### Permission sets 212 + 213 + **`tools.atmo.notifs.authSender`**: 214 + 215 + ```json 216 + { 217 + "lexicon": 1, 218 + "id": "tools.atmo.notifs.authSender", 219 + "defs": { 220 + "main": { 221 + "type": "permission-set", 222 + "title": "Send notifications", 223 + "detail": "Allow this app to request permission to send you notifications and to deliver them to your linked channels (like Telegram).", 224 + "permissions": [ 225 + { 226 + "type": "permission", 227 + "resource": "rpc", 228 + "inheritAud": true, 229 + "lxm": [ 230 + "tools.atmo.notifs.requestPermission", 231 + "tools.atmo.notifs.send" 232 + ] 233 + } 234 + ] 235 + } 236 + } 237 + } 238 + ``` 239 + 240 + **`tools.atmo.notifs.authUser`**: 241 + 242 + ```json 243 + { 244 + "lexicon": 1, 245 + "id": "tools.atmo.notifs.authUser", 246 + "defs": { 247 + "main": { 248 + "type": "permission-set", 249 + "title": "Manage your notifications", 250 + "detail": "Allow this app to view and manage which apps can send you notifications, link delivery channels, and adjust your notification settings.", 251 + "permissions": [ 252 + { 253 + "type": "permission", 254 + "resource": "rpc", 255 + "inheritAud": true, 256 + "lxm": [ 257 + "tools.atmo.notifs.grant", 258 + "tools.atmo.notifs.revoke", 259 + "tools.atmo.notifs.denyPending", 260 + "tools.atmo.notifs.muteGrant", 261 + "tools.atmo.notifs.listGrants", 262 + "tools.atmo.notifs.listPending", 263 + "tools.atmo.notifs.linkChannel", 264 + "tools.atmo.notifs.unlinkChannel", 265 + "tools.atmo.notifs.listChannels", 266 + "tools.atmo.notifs.getSettings", 267 + "tools.atmo.notifs.updateSettings" 268 + ] 269 + } 270 + ] 271 + } 272 + } 273 + } 274 + ``` 275 + 276 + --- 277 + 278 + ## D1 schema (`migrations/0001_init.sql`) 279 + 280 + ```sql 281 + CREATE TABLE users ( 282 + did TEXT PRIMARY KEY, 283 + created_at INTEGER NOT NULL, 284 + notify_pending_via_telegram INTEGER NOT NULL DEFAULT 0 285 + ); 286 + 287 + CREATE TABLE channels ( 288 + did TEXT NOT NULL, 289 + platform TEXT NOT NULL, 290 + platform_user_id TEXT NOT NULL, 291 + display_name TEXT, 292 + linked_at INTEGER NOT NULL, 293 + PRIMARY KEY (did, platform) 294 + ); 295 + CREATE UNIQUE INDEX channels_by_platform_user ON channels (platform, platform_user_id); 296 + 297 + CREATE TABLE link_tokens ( 298 + token TEXT PRIMARY KEY, 299 + did TEXT NOT NULL, 300 + platform TEXT NOT NULL, 301 + expires_at INTEGER NOT NULL 302 + ); 303 + CREATE INDEX link_tokens_by_did ON link_tokens (did); 304 + CREATE INDEX link_tokens_by_expires ON link_tokens (expires_at); 305 + 306 + CREATE TABLE senders ( 307 + did TEXT PRIMARY KEY, 308 + handle TEXT, 309 + display_name TEXT, 310 + avatar_url TEXT, 311 + profile_fetched_at INTEGER 312 + ); 313 + 314 + CREATE TABLE pending_requests ( 315 + id TEXT PRIMARY KEY, 316 + recipient_did TEXT NOT NULL, 317 + sender_did TEXT NOT NULL, 318 + reason TEXT, 319 + created_at INTEGER NOT NULL, 320 + expires_at INTEGER NOT NULL, 321 + UNIQUE (recipient_did, sender_did) 322 + ); 323 + CREATE INDEX pending_by_recipient ON pending_requests (recipient_did); 324 + CREATE INDEX pending_by_expires ON pending_requests (expires_at); 325 + 326 + CREATE TABLE grants ( 327 + recipient_did TEXT NOT NULL, 328 + sender_did TEXT NOT NULL, 329 + granted_at INTEGER NOT NULL, 330 + muted INTEGER NOT NULL DEFAULT 0, 331 + PRIMARY KEY (recipient_did, sender_did) 332 + ); 333 + CREATE INDEX grants_by_recipient ON grants (recipient_did); 334 + 335 + CREATE TABLE delivery_log ( 336 + id TEXT PRIMARY KEY, 337 + recipient_did TEXT NOT NULL, 338 + sender_did TEXT NOT NULL, 339 + title TEXT, 340 + delivered_count INTEGER NOT NULL, 341 + created_at INTEGER NOT NULL 342 + ); 343 + CREATE INDEX delivery_by_recipient ON delivery_log (recipient_did, created_at DESC); 344 + ``` 345 + 346 + All timestamps are unix milliseconds (`Date.now()`). Use parameterised queries throughout — never string interpolation. Put each query in `db/queries.ts` as a named, typed function. 347 + 348 + --- 349 + 350 + ## Auth model 351 + 352 + Two distinct paths, both using `ServiceJwtVerifier` from `@atcute/xrpc-server/auth`: 353 + 354 + ```ts 355 + import { ServiceJwtVerifier } from '@atcute/xrpc-server/auth'; 356 + import { 357 + CompositeDidDocumentResolver, 358 + PlcDidDocumentResolver, 359 + WebDidDocumentResolver, 360 + } from '@atcute/identity-resolver'; 361 + 362 + export function makeVerifier() { 363 + return new ServiceJwtVerifier({ 364 + acceptAudiences: [ 365 + 'did:web:notifs.atmo.tools', 366 + 'did:web:notifs.atmo.tools#notif_relay', 367 + ], 368 + resolver: new CompositeDidDocumentResolver({ 369 + methods: { 370 + plc: new PlcDidDocumentResolver(), 371 + web: new WebDidDocumentResolver(), 372 + }, 373 + }), 374 + maxAge: 300, 375 + clockLeeway: 5, 376 + }); 377 + } 378 + ``` 379 + 380 + Wrap DID resolution with KV caching (5-minute TTL) — the verifier accepts a resolver, but you can also cache at the resolver layer by wrapping `PlcDidDocumentResolver`. 381 + 382 + **Sender path** (used by `requestPermission`, `send`): the JWT issuer is the sender's DID, signed with the sender's signing key from their DID document. Helper: `verifySenderRequest(req, lxm) → { senderDid }`. 383 + 384 + **User path** (used by every other procedure/query): the JWT issuer is the end user's DID, signed by the user's PDS via `com.atproto.server.getServiceAuth`. The website obtains these JWTs on the user's behalf and passes them to the relay. Helper: `verifyUserRequest(req, lxm) → { userDid }`. 385 + 386 + Both helpers throw `AuthRequiredError` with a populated `wwwAuthenticate` on every failure path (the `ServiceJwtVerifier` already does this). 387 + 388 + The relay does **not** mint outgoing JWTs and does **not** need a private signing key. 389 + 390 + --- 391 + 392 + ## XRPC handler behavior (precise) 393 + 394 + ### `requestPermission` (sender path) 395 + 396 + 1. `verifySenderRequest` → `senderDid`. 397 + 2. If a grant already exists for `(senderDid, input.recipient)`, return `{ id: <some stable id>, status: "alreadyGranted" }`. 398 + 3. Per-pair pending cap: if a non-expired pending request exists for `(senderDid, input.recipient)`, return that row's `{ id, status: "pending" }` — do **not** insert another. 399 + 4. Per-sender global rate limit: KV counter keyed `rl:req:<senderDid>` with 1-hour window; max 100 new pending requests per hour. If exceeded, throw `RateLimitExceededError`. 400 + 5. Insert pending row with `expires_at = now + 7 days`, `id = cuid2-or-nanoid`. 401 + 6. Fire-and-forget: ensure sender profile is fresh in `senders` (refetch if missing or `profile_fetched_at` is older than 24h). Use `@atcute/client` against `https://public.api.bsky.app` calling `app.bsky.actor.getProfile`. Cache outcome including failure. 402 + 7. If recipient has linked Telegram **and** `users.notify_pending_via_telegram = 1`, enqueue a `pendingRequest` dispatcher job. 403 + 8. Return `{ id, status: "pending" }`. 404 + 405 + ### `send` (sender path) 406 + 407 + 1. `verifySenderRequest` → `senderDid`. 408 + 2. Lookup grant for `(senderDid, recipient)`. If missing, throw `XRPCError({ status: 403, error: 'NotAuthorized' })`. If `muted = 1`, accept silently: write a delivery_log row with `delivered_count = 0`, return `{ id, delivered: 0 }`. 409 + 3. Rate limits (both via KV counters): 410 + - Per-pair per-second: 1/sec. Exceed → 429 with `Retry-After: 1`. 411 + - Per-pair daily: 100/24h. Exceed → 429 with `Retry-After: <seconds until window resets>`. 412 + 4. Lookup recipient's `channels`. If empty, write delivery_log with `delivered_count = 0` and return `{ id, delivered: 0 }`. 413 + 5. For each channel, enqueue a `notification` dispatcher job containing `{ channel, title, body, uri, threadKey, senderDid }`. 414 + 6. Write delivery_log with `delivered_count = channels.length`. 415 + 7. Return `{ id, delivered: channels.length }`. 416 + 417 + ### `grant` (user path) 418 + 419 + 1. `verifyUserRequest` → `userDid`. 420 + 2. Upsert into `grants` (`recipient_did = userDid`, `sender_did = input.sender`, `granted_at = now`, `muted = 0`). 421 + 3. If `input.requestId` provided, `DELETE FROM pending_requests WHERE id = ? AND recipient_did = userDid`. 422 + 4. Ensure `users` row exists (insert if missing). 423 + 5. Return `{ granted: true }`. 424 + 425 + ### `revoke` (user path) 426 + 427 + 1. `verifyUserRequest` → `userDid`. 428 + 2. Delete grant for `(userDid, input.sender)`. 429 + 3. Also delete any pending request for the same pair (it's irrelevant now). 430 + 4. Return `{ revoked: <boolean from rows affected> }`. 431 + 432 + ### `denyPending` (user path) 433 + 434 + 1. `verifyUserRequest` → `userDid`. 435 + 2. `DELETE FROM pending_requests WHERE id = ? AND recipient_did = userDid`. 436 + 3. Return `{ denied: <boolean> }`. 437 + 438 + ### `muteGrant` (user path) 439 + 440 + 1. `verifyUserRequest` → `userDid`. 441 + 2. Update grant's `muted` column. 442 + 3. Return `{ muted: input.muted }`. 443 + 444 + ### `listGrants` (user path) 445 + 446 + 1. `verifyUserRequest` → `userDid`. 447 + 2. `SELECT g.*, s.handle, s.display_name, s.avatar_url FROM grants g LEFT JOIN senders s ON s.did = g.sender_did WHERE g.recipient_did = ? ORDER BY g.granted_at DESC`. 448 + 3. Return as `{ grants: [...] }`. 449 + 450 + ### `listPending` (user path) 451 + 452 + 1. `verifyUserRequest` → `userDid`. 453 + 2. Same join pattern, filter `expires_at > now`. 454 + 3. Return as `{ pending: [...] }`. 455 + 456 + ### `linkChannel` (user path) 457 + 458 + 1. `verifyUserRequest` → `userDid`. 459 + 2. Ensure `users` row exists. 460 + 3. Generate a 32-char URL-safe random token (use `crypto.getRandomValues` + base64url, not `Math.random`). 461 + 4. Insert into `link_tokens` with `expires_at = now + 10 min`. 462 + 5. Build deepLink: `https://t.me/<BOT_USERNAME>?start=<token>`. 463 + 6. Return `{ token, deepLink }`. 464 + 465 + ### `unlinkChannel` (user path) 466 + 467 + 1. `verifyUserRequest` → `userDid`. 468 + 2. Delete from `channels` where `(did, platform)` matches. 469 + 3. Return `{ unlinked: <boolean> }`. 470 + 471 + ### `listChannels`, `getSettings`, `updateSettings` 472 + 473 + Straightforward. `updateSettings` is a partial PATCH — only update fields present in the input. `getSettings` ensures the user row exists (returns defaults if not). 474 + 475 + --- 476 + 477 + ## Telegram bot integration 478 + 479 + ### Webhook endpoint 480 + 481 + Route: `POST /telegram/webhook/:secret`. The path-segment secret must match `env.TELEGRAM_WEBHOOK_SECRET`. Telegram is configured (out of band) with this URL via `setWebhook`. The README must explain this and provide a snippet of how to call `setWebhook` with curl. 482 + 483 + The webhook receives JSON updates. Dispatch by update type: 484 + 485 + - `update.message` with a `/` prefix → `commands.ts` 486 + - `update.callback_query` → `callbacks.ts` 487 + - Anything else → 200 OK, ignore 488 + 489 + Always respond 200 OK to Telegram even on internal errors — Telegram retries aggressively otherwise. Log internal errors to console; surface to the user via a follow-up sendMessage if appropriate. 490 + 491 + ### Commands (`/...`) 492 + 493 + - **`/start <token>`** — lookup `link_tokens` row. If valid (not expired, exists), find or insert `users` row for the DID, insert into `channels` (replacing any existing row for that DID + telegram), capture `update.message.from.username` as `display_name` if available, delete the token row, reply with markdown: `✅ Linked to @<handle-or-did>`. If invalid/expired, reply with "This link expired. Generate a new one at https://notifs.atmo.tools/dashboard". 494 + - **`/start`** (no arg) — reply with a welcome message and the dashboard URL. 495 + - **`/list`** — look up the DID from the chat_id (via `channels`), fetch grants, render as a markdown list with handles. If no grants: "You haven't authorized any apps yet." 496 + - **`/revoke <handle-or-did>`** — resolve handle to DID if needed, delete the grant, reply with confirmation. If no matching grant, reply with that. 497 + - **`/settings`** — show current settings and an inline keyboard with toggle buttons. 498 + - **Anything else** — short help message listing available commands. 499 + 500 + ### Callback queries (inline button taps) 501 + 502 + `callback_query.data` formats: 503 + 504 + - `approve:<requestId>` — look up pending, verify it belongs to the DID linked to this chat_id, write grant, delete pending, edit the original message to "✅ Approved <sender>". Always `answerCallbackQuery` to dismiss the spinner. 505 + - `deny:<requestId>` — delete pending, edit to "❌ Denied <sender>". 506 + - `toggle:notifyPending` — flip `users.notify_pending_via_telegram`, edit the settings message with the new state. 507 + 508 + If the chat_id isn't linked, reply via `answerCallbackQuery({ text: "Account not linked. Visit the website.", show_alert: true })`. 509 + 510 + ### Markdown formatting 511 + 512 + Use Markdown V2. Escape special characters carefully — `_`, `*`, `[`, `]`, `(`, `)`, `~`, `` ` ``, `>`, `#`, `+`, `-`, `=`, `|`, `{`, `}`, `.`, `!` all need escaping when they appear in user content. Write a small `escapeMd(text: string)` helper. 513 + 514 + --- 515 + 516 + ## Queue dispatcher 517 + 518 + Single Queue, single consumer Worker (same Worker, exported `queue` handler). 519 + 520 + Job shape: 521 + 522 + ```ts 523 + type DispatchJob = 524 + | { 525 + kind: 'notification'; 526 + channel: { platform: 'telegram'; platformUserId: string }; 527 + title: string; 528 + body: string; 529 + uri?: string; 530 + senderDid: string; 531 + } 532 + | { 533 + kind: 'pendingRequest'; 534 + channel: { platform: 'telegram'; platformUserId: string }; 535 + requestId: string; 536 + senderHandle: string; 537 + senderDisplayName?: string; 538 + reason?: string; 539 + }; 540 + ``` 541 + 542 + Handler: 543 + 544 + - `notification` → Telegram `sendMessage` formatted as: 545 + 546 + ``` 547 + *<escaped title>* 548 + <escaped body> 549 + ``` 550 + 551 + Plus a single inline button with `text: "Open"` and `url: <uri>` when `uri` is present. 552 + 553 + - `pendingRequest` → `sendMessage` with text including sender display name + handle + reason, and an inline keyboard: 554 + 555 + ``` 556 + [ ✅ Allow ] [ ❌ Deny ] 557 + ``` 558 + 559 + Callback data `approve:<requestId>` and `deny:<requestId>`. 560 + 561 + Use Queues' built-in retry. On `403 Forbidden: bot was blocked by the user`, mark this channel dead — `DELETE FROM channels` for the row. Don't retry. Same for `400 chat not found`. 562 + 563 + --- 564 + 565 + ## Well-known endpoints 566 + 567 + - **`GET /.well-known/did.json`** — serve the relay's DID document: 568 + 569 + ```json 570 + { 571 + "@context": ["https://www.w3.org/ns/did/v1"], 572 + "id": "did:web:notifs.atmo.tools", 573 + "service": [ 574 + { 575 + "id": "#notif_relay", 576 + "type": "AtprotoNotificationRelay", 577 + "serviceEndpoint": "https://notifs.atmo.tools" 578 + } 579 + ] 580 + } 581 + ``` 582 + 583 + No `verificationMethod` block — the relay doesn't sign anything. 584 + 585 + - **`GET /xrpc/_health`** — return `{ status: "ok" }`. Use `XRPCRouter`'s `handleHealthCheck`. 586 + 587 + - **`GET /lexicons/:nsid`** — serve the corresponding lexicon JSON file. Bundle the JSONs into the Worker (import them as JSON modules) so they're served from KV/CDN-cacheable static responses. Set `Cache-Control: public, max-age=3600`. 588 + 589 + --- 590 + 591 + ## Router wiring 592 + 593 + Use `@atcute/xrpc-server`'s `XRPCRouter`. Mount each handler with `router.addQuery` / `router.addProcedure`. Wrap auth and rate-limiting inside each handler — don't try to do middleware globally, since some handlers use sender path and others user path. 594 + 595 + Top-level `fetch` handler dispatches: 596 + 597 + - `/.well-known/did.json` → static 598 + - `/lexicons/*` → static 599 + - `/telegram/webhook/*` → telegram webhook 600 + - everything else → `router.fetch(request)` 601 + 602 + Use the `onError` hook on the router to log unexpected errors to console (which Workers ships to Logpush / observability automatically). 603 + 604 + --- 605 + 606 + ## Rate limiting helper 607 + 608 + ```ts 609 + // ratelimit.ts 610 + export async function checkAndIncrement( 611 + kv: KVNamespace, 612 + key: string, 613 + limit: number, 614 + windowSeconds: number, 615 + ): Promise<{ allowed: boolean; remaining: number; resetIn: number }>; 616 + ``` 617 + 618 + Implementation: read the current count from KV; if absent, set to 1 with `expirationTtl = windowSeconds`. If present, read remaining TTL via a `metadata.expiresAt` you wrote on initial set, increment, write back with the same TTL. Returns whether the increment was within `limit`. Note that KV's eventual consistency means small over-counts are possible — that's acceptable here. 619 + 620 + --- 621 + 622 + ## Cron trigger 623 + 624 + In `wrangler.toml` configure a cron `0 3 * * *` (daily 3am UTC). Handler `scheduled`: 625 + 626 + - `DELETE FROM pending_requests WHERE expires_at < now` 627 + - `DELETE FROM link_tokens WHERE expires_at < now` 628 + - Optionally: `DELETE FROM delivery_log WHERE created_at < now - 30 days` for housekeeping 629 + 630 + --- 631 + 632 + ## `wrangler.toml` template 633 + 634 + ```toml 635 + name = "notifs-relay" 636 + main = "src/index.ts" 637 + compatibility_date = "2026-04-01" 638 + compatibility_flags = ["nodejs_compat"] 639 + 640 + routes = [ 641 + { pattern = "notifs.atmo.tools/*", custom_domain = true } 642 + ] 643 + 644 + [[d1_databases]] 645 + binding = "DB" 646 + database_name = "notifs-relay" 647 + database_id = "REPLACE_ME" 648 + migrations_dir = "migrations" 649 + 650 + [[kv_namespaces]] 651 + binding = "CACHE" 652 + id = "REPLACE_ME" 653 + 654 + [[queues.producers]] 655 + binding = "DISPATCH_QUEUE" 656 + queue = "notifs-dispatch" 657 + 658 + [[queues.consumers]] 659 + queue = "notifs-dispatch" 660 + max_batch_size = 10 661 + max_batch_timeout = 5 662 + max_retries = 3 663 + 664 + [triggers] 665 + crons = ["0 3 * * *"] 666 + 667 + [vars] 668 + RELAY_DID = "did:web:notifs.atmo.tools" 669 + BOT_USERNAME = "REPLACE_ME" # e.g. "atmonotifsbot" 670 + 671 + # Secrets set via `wrangler secret put`: 672 + # - TELEGRAM_BOT_TOKEN 673 + # - TELEGRAM_WEBHOOK_SECRET 674 + ``` 675 + 676 + --- 677 + 678 + ## Tests 679 + 680 + Use `@cloudflare/vitest-pool-workers`. Write at least these: 681 + 682 + - **`auth.test.ts`** — happy path JWT verify; expired; bad audience; wrong lxm; unknown DID. 683 + - **`requestPermission.test.ts`** — first request creates pending; duplicate within window returns the same pending; alreadyGranted path; rate-limit returns 429. 684 + - **`grant.test.ts`** — grant inserts a row; with requestId deletes pending; revoke removes the grant. 685 + - **`send.test.ts`** — no grant → 403; grant but no channel → `delivered: 0`; with linked channel → enqueues; muted → silent zero. 686 + - **`telegram-link.test.ts`** — `/start <valid token>` writes channels row; `/start <expired>` errors; `/start` (no arg) responds with welcome. 687 + - **`ratelimit.test.ts`** — under limit allowed; over limit denied; window resets after TTL. 688 + 689 + Mock outbound HTTP (Telegram, AppView profile fetch, PLC) — don't hit the network in tests. 690 + 691 + --- 692 + 693 + ## Root `package.json` scripts 694 + 695 + - `pnpm dev` → starts `wrangler dev` in `apps/relay` 696 + - `pnpm build` → builds all workspaces 697 + - `pnpm test` → vitest across all workspaces 698 + - `pnpm generate` → runs `@atcute/lex-cli` in `packages/lexicons` to regenerate types 699 + - `pnpm db:migrate` → `wrangler d1 migrations apply notifs-relay` for the relay 700 + - `pnpm typecheck` → `tsc -b` 701 + 702 + --- 703 + 704 + ## README content (root) 705 + 706 + Include sections for: 707 + 708 + 1. **What this is** — one-paragraph summary. 709 + 2. **Repo layout** — the tree above. 710 + 3. **Configuration** — list every place `notifs.atmo.tools` / `did:web:notifs.atmo.tools` / `tools.atmo.notifs` appears, so a future find-replace is straightforward. 711 + 4. **Local setup** — `pnpm install`, `pnpm generate`, create D1, create KV, create Queues, copy `wrangler.toml` and fill in IDs. 712 + 5. **Telegram bot setup** — message @BotFather, get a token, set commands, run `wrangler secret put TELEGRAM_BOT_TOKEN`, generate a webhook secret with `openssl rand -hex 32`, `wrangler secret put TELEGRAM_WEBHOOK_SECRET`, register webhook with a curl one-liner. 713 + 6. **Deploying** — `wrangler deploy`, add the custom domain in CF dashboard, verify `/.well-known/did.json` returns the expected JSON. 714 + 7. **For sender developers** — how to set up a `did:web` for your own app, how to sign service-auth JWTs (link to atcute's `createServiceJwt`), example call to `tools.atmo.notifs.requestPermission` and `tools.atmo.notifs.send`. 715 + 716 + --- 717 + 718 + ## What to do, what not to do 719 + 720 + **Do**: 721 + 722 + - Strict TypeScript everywhere, no `any` except at boundaries where you also write a runtime check. 723 + - Use named exports, not default, for everything except the Worker entry. 724 + - Parameterised D1 queries, always. 725 + - Throw `XRPCError` subclasses, never return ad-hoc error JSON. 726 + - Comment any non-obvious business logic (especially the rate-limit semantics). 727 + 728 + **Don't**: 729 + 730 + - Don't add libraries beyond the stack listed above without a clear reason. 731 + - Don't try to use `@atcute/xrpc-server-cloudflare` — it's only for WebSocket subscriptions, which this project doesn't use. 732 + - Don't write a `verificationMethod` block into the DID doc — the relay doesn't sign and doesn't need a key. 733 + - Don't add Jetstream consumers, repo records, or anything else atproto-data-plane — this relay is pure XRPC. 734 + - Don't write the SvelteKit website code — `apps/web` is a placeholder for a follow-up prompt. 735 + 736 + When in doubt about scope: implement the smallest thing that makes the integration tests pass, then stop.
+280
README.md
··· 1 + # atmo notifications relay 2 + 3 + A Cloudflare Workers service that lets any AT Protocol app send notifications to 4 + its users. A user approves which apps may notify them (via a separate SvelteKit 5 + website, built later), and delivery in v1 happens through a Telegram bot. 6 + 7 + The relay is a pure XRPC service: it verifies service-auth JWTs, stores grants / 8 + pending requests / channels in D1, rate-limits with KV, and dispatches deliveries 9 + through a Cloudflare Queue. It does **not** read the atproto firehose, hold repo 10 + records, or sign anything (it verifies inbound JWTs but never mints them). 11 + 12 + This monorepo currently contains: 13 + 14 + - **`packages/lexicons`** — the shared `tools.atmo.notifs.*` lexicons and their 15 + generated TypeScript types. 16 + - **`apps/relay`** — the Cloudflare Worker. 17 + - **`apps/web`** — placeholder for the SvelteKit dashboard (built in a follow-up). 18 + 19 + --- 20 + 21 + ## Configuration 22 + 23 + These values are baked into the code/config in several places. To rebrand or 24 + re-home the relay, change them everywhere listed below. 25 + 26 + | Constant | Value | Where it lives | 27 + | --- | --- | --- | 28 + | Relay domain | `notifs.atmo.tools` | `apps/relay/wrangler.toml` (`routes`, derived `RELAY_DID`); dashboard links in `apps/relay/src/telegram/commands.ts` (`DASHBOARD_URL`, `NOT_LINKED`); `apps/relay/test/helpers.ts` | 29 + | Relay DID | `did:web:notifs.atmo.tools` | `apps/relay/wrangler.toml` (`[vars].RELAY_DID`); consumed by `apps/relay/src/auth/verifier.ts` and `apps/relay/src/well-known.ts` (service endpoint derived from it); `apps/relay/test/helpers.ts` (`RELAY_DID`) | 30 + | Relay service id | `#notif_relay` | `apps/relay/src/well-known.ts` (DID-doc `service[].id`); `apps/relay/src/auth/verifier.ts` (`acceptAudiences` fragment) | 31 + | Lexicon NSID prefix | `tools.atmo.notifs` | Every file under `packages/lexicons/lexicons/` (each `id`); regenerated types under `packages/lexicons/src/lexicons/`; the `LXM` constant in every `apps/relay/src/xrpc/*.ts`; the map + imports in `apps/relay/src/well-known.ts`; the permission-set `lxm` arrays | 32 + | Pending request TTL | 7 days | `apps/relay/src/xrpc/requestPermission.ts` (`addDays(createdAt, 7)`) | 33 + | Link token TTL | 10 minutes | `apps/relay/src/xrpc/linkChannel.ts` (`addMinutes(now(), 10)`) | 34 + | DID-doc cache TTL (KV) | 5 minutes | `apps/relay/src/identity/resolve.ts` (`DID_DOC_CACHE_TTL_SECONDS`) | 35 + | Profile cache TTL (`senders`) | 24 hours | `apps/relay/src/profile/fetch.ts` (`PROFILE_TTL_MS`) | 36 + | `requestPermission` rate limit | 100 / hour / sender | `apps/relay/src/xrpc/requestPermission.ts` (`REQ_LIMIT`, `REQ_WINDOW_SECONDS`) | 37 + | `send` rate limits | 1 / sec & 100 / day / pair | `apps/relay/src/xrpc/send.ts` (`PER_SECOND_*`, `PER_DAY_*`) | 38 + | Bot username | `REPLACE_ME` | `apps/relay/wrangler.toml` (`[vars].BOT_USERNAME`) → deep links in `linkChannel` | 39 + 40 + > **Note on `lex.config.js`** — the prompt's tree named this `lex.config.json`, 41 + > but `@atcute/lex-cli` loads its config via dynamic `import()`, which only 42 + > resolves `lex.config.js`/`.ts`. We use `.js` so `pnpm generate` works without 43 + > extra Node flags. 44 + 45 + --- 46 + 47 + ## Repo layout 48 + 49 + ``` 50 + . 51 + ├── package.json # workspace root, scripts 52 + ├── pnpm-workspace.yaml # apps/* and packages/* 53 + ├── tsconfig.base.json # shared strict config 54 + ├── tsconfig.json # references for `tsc -b` 55 + ├── apps/ 56 + │ ├── relay/ 57 + │ │ ├── wrangler.toml 58 + │ │ ├── vitest.config.ts 59 + │ │ ├── migrations/0001_init.sql 60 + │ │ ├── src/ 61 + │ │ │ ├── index.ts # default export { fetch, queue, scheduled } 62 + │ │ │ ├── env.ts # Env + DispatchJob + AppContext types 63 + │ │ │ ├── router.ts # builds the XRPCRouter, wires handlers 64 + │ │ │ ├── auth/ # verifier.ts, sender.ts, user.ts 65 + │ │ │ ├── xrpc/ # one file per procedure/query 66 + │ │ │ ├── delivery/ # telegram.ts (API), dispatcher.ts (queue consumer) 67 + │ │ │ ├── telegram/ # webhook.ts, commands.ts, callbacks.ts 68 + │ │ │ ├── db/ # schema.sql, queries.ts 69 + │ │ │ ├── identity/resolve.ts 70 + │ │ │ ├── profile/fetch.ts 71 + │ │ │ ├── ratelimit.ts 72 + │ │ │ ├── well-known.ts 73 + │ │ │ └── lib/ # errors.ts, ids.ts, time.ts 74 + │ │ └── test/ # vitest (pool-workers) 75 + │ └── web/ # placeholder 76 + └── packages/ 77 + └── lexicons/ 78 + ├── lex.config.js # @atcute/lex-cli config 79 + ├── lexicons/tools/atmo/notifs/*.json # 15 lexicons 80 + └── src/index.ts # re-exports generated types 81 + ``` 82 + 83 + --- 84 + 85 + ## Local setup 86 + 87 + Requires **Node 22+** and **pnpm 11** (`corepack prepare pnpm@11 --activate`). 88 + 89 + ```sh 90 + pnpm install 91 + pnpm generate # generate lexicon types into packages/lexicons/src/lexicons/ 92 + pnpm typecheck # tsc -b across the workspace 93 + pnpm test # vitest (runs in the Workers runtime via miniflare) 94 + ``` 95 + 96 + Create the Cloudflare resources, then paste the ids into `apps/relay/wrangler.toml` 97 + (replace each `REPLACE_ME`): 98 + 99 + ```sh 100 + # from apps/relay/ 101 + pnpm exec wrangler d1 create notifs-relay # -> database_id 102 + pnpm exec wrangler kv namespace create CACHE # -> id 103 + pnpm exec wrangler queues create notifs-dispatch 104 + ``` 105 + 106 + Apply migrations (locally and/or remotely): 107 + 108 + ```sh 109 + pnpm db:migrate # remote 110 + # or, for local dev state: 111 + pnpm exec wrangler d1 migrations apply notifs-relay --local 112 + ``` 113 + 114 + Run the worker locally: 115 + 116 + ```sh 117 + pnpm dev # wrangler dev in apps/relay 118 + ``` 119 + 120 + ### Useful scripts (root) 121 + 122 + | Script | Action | 123 + | --- | --- | 124 + | `pnpm dev` | `wrangler dev` in `apps/relay` | 125 + | `pnpm build` | builds all workspaces (`pnpm -r build`) | 126 + | `pnpm test` | vitest across workspaces | 127 + | `pnpm generate` | regenerate lexicon types with `@atcute/lex-cli` | 128 + | `pnpm db:migrate` | `wrangler d1 migrations apply notifs-relay` | 129 + | `pnpm typecheck` | `tsc -b` | 130 + 131 + --- 132 + 133 + ## Telegram bot setup 134 + 135 + 1. Message [@BotFather](https://t.me/BotFather), `/newbot`, and note the **bot 136 + token** and **username**. Put the username in `wrangler.toml` `[vars].BOT_USERNAME`. 137 + 2. (Optional) Set the command list with BotFather's `/setcommands`: 138 + ``` 139 + start - Link your account 140 + list - List authorized apps 141 + revoke - Revoke an app 142 + settings - Notification settings 143 + ``` 144 + 3. Store the bot token as a secret: 145 + ```sh 146 + pnpm exec wrangler secret put TELEGRAM_BOT_TOKEN 147 + ``` 148 + 4. Generate and store a webhook secret (it becomes the last path segment of the 149 + webhook URL, so attackers can't post fake updates): 150 + ```sh 151 + openssl rand -hex 32 # copy the output 152 + pnpm exec wrangler secret put TELEGRAM_WEBHOOK_SECRET 153 + ``` 154 + 5. Register the webhook with Telegram (after deploying, so the URL resolves): 155 + ```sh 156 + BOT_TOKEN="123:abc" 157 + WEBHOOK_SECRET="<the hex you generated>" 158 + curl -sS "https://api.telegram.org/bot${BOT_TOKEN}/setWebhook" \ 159 + -H 'content-type: application/json' \ 160 + -d "{\"url\":\"https://notifs.atmo.tools/telegram/webhook/${WEBHOOK_SECRET}\"}" 161 + ``` 162 + 163 + --- 164 + 165 + ## Deploying 166 + 167 + ```sh 168 + # from apps/relay/ 169 + pnpm exec wrangler deploy 170 + ``` 171 + 172 + Then: 173 + 174 + 1. In the Cloudflare dashboard, attach the **custom domain** `notifs.atmo.tools` 175 + to the Worker (Workers & Pages → the worker → Settings → Domains & Routes). 176 + The `routes` entry in `wrangler.toml` already declares it as a custom domain. 177 + 2. Verify the DID document resolves: 178 + ```sh 179 + curl -s https://notifs.atmo.tools/.well-known/did.json 180 + ``` 181 + Expected: 182 + ```json 183 + { 184 + "@context": ["https://www.w3.org/ns/did/v1"], 185 + "id": "did:web:notifs.atmo.tools", 186 + "service": [ 187 + { 188 + "id": "#notif_relay", 189 + "type": "AtprotoNotificationRelay", 190 + "serviceEndpoint": "https://notifs.atmo.tools" 191 + } 192 + ] 193 + } 194 + ``` 195 + 3. Health check: `curl -s https://notifs.atmo.tools/xrpc/_health` → `{"status":"ok"}`. 196 + 4. Lexicons are served at `https://notifs.atmo.tools/lexicons/<nsid>`. 197 + 198 + --- 199 + 200 + ## For sender developers 201 + 202 + To send notifications from your own app you authenticate as **your app's DID** 203 + using atproto service-auth JWTs. There is no registration step — the relay 204 + verifies your signature against your DID document. 205 + 206 + 1. **Set up a `did:web` (or `did:plc`) for your app** with an atproto signing key. 207 + For `did:web:yourapp.example`, host a DID document at 208 + `https://yourapp.example/.well-known/did.json` containing a 209 + `verificationMethod` whose id ends in `#atproto` (a `Multikey` with your 210 + public key in `publicKeyMultibase`). The relay resolves plc + web DIDs. 211 + 212 + 2. **Mint a service-auth JWT** for each call. The `aud` must be the relay 213 + (`did:web:notifs.atmo.tools` or `did:web:notifs.atmo.tools#notif_relay`) and 214 + the `lxm` must match the method you're calling. Use atcute's 215 + [`createServiceJwt`](https://www.npmjs.com/package/@atcute/xrpc-server): 216 + 217 + ```ts 218 + import { P256PrivateKeyExportable } from '@atcute/crypto'; 219 + import { createServiceJwt } from '@atcute/xrpc-server/auth'; 220 + 221 + const keypair = await P256PrivateKeyExportable.importRaw(yourPrivateKeyBytes); 222 + 223 + async function jwt(lxm: string) { 224 + return createServiceJwt({ 225 + keypair, 226 + issuer: 'did:web:yourapp.example', 227 + audience: 'did:web:notifs.atmo.tools', 228 + lxm, 229 + expiresIn: 60, 230 + }); 231 + } 232 + ``` 233 + 234 + 3. **Request permission** (the user approves it in the dashboard or Telegram): 235 + 236 + ```ts 237 + await fetch('https://notifs.atmo.tools/xrpc/tools.atmo.notifs.requestPermission', { 238 + method: 'POST', 239 + headers: { 240 + authorization: `Bearer ${await jwt('tools.atmo.notifs.requestPermission')}`, 241 + 'content-type': 'application/json', 242 + }, 243 + body: JSON.stringify({ 244 + recipient: 'did:plc:therecipient', 245 + reason: 'Get notified when someone replies to you', 246 + }), 247 + }); 248 + // -> { id, status: "pending" | "alreadyGranted" } 249 + ``` 250 + 251 + 4. **Send a notification** once granted: 252 + 253 + ```ts 254 + await fetch('https://notifs.atmo.tools/xrpc/tools.atmo.notifs.send', { 255 + method: 'POST', 256 + headers: { 257 + authorization: `Bearer ${await jwt('tools.atmo.notifs.send')}`, 258 + 'content-type': 'application/json', 259 + }, 260 + body: JSON.stringify({ 261 + recipient: 'did:plc:therecipient', 262 + title: 'New reply', 263 + body: 'alice replied to your post', 264 + uri: 'https://yourapp.example/thread/123', 265 + }), 266 + }); 267 + // -> { id, delivered } (delivered = number of channels dispatched to) 268 + ``` 269 + 270 + `send` returns `403 NotAuthorized` if there is no grant, and `429 271 + RateLimitExceeded` (with `Retry-After`) when limits are hit. A muted grant is 272 + accepted silently with `delivered: 0`. 273 + 274 + ### Permission sets 275 + 276 + The relay publishes two OAuth permission sets so the website can request scopes: 277 + 278 + - `tools.atmo.notifs.authSender` — `requestPermission` + `send` (for apps). 279 + - `tools.atmo.notifs.authUser` — all user-facing management methods (for the 280 + dashboard acting on a user's behalf).
+66
apps/relay/migrations/0001_init.sql
··· 1 + -- Initial schema for the atproto notification relay. 2 + -- All timestamps are unix milliseconds (Date.now()). 3 + 4 + CREATE TABLE users ( 5 + did TEXT PRIMARY KEY, 6 + created_at INTEGER NOT NULL, 7 + notify_pending_via_telegram INTEGER NOT NULL DEFAULT 0 8 + ); 9 + 10 + CREATE TABLE channels ( 11 + did TEXT NOT NULL, 12 + platform TEXT NOT NULL, 13 + platform_user_id TEXT NOT NULL, 14 + display_name TEXT, 15 + linked_at INTEGER NOT NULL, 16 + PRIMARY KEY (did, platform) 17 + ); 18 + CREATE UNIQUE INDEX channels_by_platform_user ON channels (platform, platform_user_id); 19 + 20 + CREATE TABLE link_tokens ( 21 + token TEXT PRIMARY KEY, 22 + did TEXT NOT NULL, 23 + platform TEXT NOT NULL, 24 + expires_at INTEGER NOT NULL 25 + ); 26 + CREATE INDEX link_tokens_by_did ON link_tokens (did); 27 + CREATE INDEX link_tokens_by_expires ON link_tokens (expires_at); 28 + 29 + CREATE TABLE senders ( 30 + did TEXT PRIMARY KEY, 31 + handle TEXT, 32 + display_name TEXT, 33 + avatar_url TEXT, 34 + profile_fetched_at INTEGER 35 + ); 36 + 37 + CREATE TABLE pending_requests ( 38 + id TEXT PRIMARY KEY, 39 + recipient_did TEXT NOT NULL, 40 + sender_did TEXT NOT NULL, 41 + reason TEXT, 42 + created_at INTEGER NOT NULL, 43 + expires_at INTEGER NOT NULL, 44 + UNIQUE (recipient_did, sender_did) 45 + ); 46 + CREATE INDEX pending_by_recipient ON pending_requests (recipient_did); 47 + CREATE INDEX pending_by_expires ON pending_requests (expires_at); 48 + 49 + CREATE TABLE grants ( 50 + recipient_did TEXT NOT NULL, 51 + sender_did TEXT NOT NULL, 52 + granted_at INTEGER NOT NULL, 53 + muted INTEGER NOT NULL DEFAULT 0, 54 + PRIMARY KEY (recipient_did, sender_did) 55 + ); 56 + CREATE INDEX grants_by_recipient ON grants (recipient_did); 57 + 58 + CREATE TABLE delivery_log ( 59 + id TEXT PRIMARY KEY, 60 + recipient_did TEXT NOT NULL, 61 + sender_did TEXT NOT NULL, 62 + title TEXT, 63 + delivered_count INTEGER NOT NULL, 64 + created_at INTEGER NOT NULL 65 + ); 66 + CREATE INDEX delivery_by_recipient ON delivery_log (recipient_did, created_at DESC);
+30
apps/relay/package.json
··· 1 + { 2 + "name": "@atmo/notifs-relay", 3 + "version": "0.0.0", 4 + "private": true, 5 + "type": "module", 6 + "scripts": { 7 + "dev": "wrangler dev", 8 + "build": "tsc -b", 9 + "test": "vitest run", 10 + "test:watch": "vitest", 11 + "typecheck": "tsc -b", 12 + "db:migrate": "wrangler d1 migrations apply notifs-relay" 13 + }, 14 + "dependencies": { 15 + "@atcute/client": "^5.0.0", 16 + "@atcute/identity-resolver": "^2.0.0", 17 + "@atcute/lexicons": "^2.0.0", 18 + "@atcute/xrpc-server": "^2.0.0", 19 + "@atmo/notifs-lexicons": "workspace:*", 20 + "nanoid": "^5.1.11" 21 + }, 22 + "devDependencies": { 23 + "@atcute/crypto": "^2.4.1", 24 + "@cloudflare/vitest-pool-workers": "^0.16.9", 25 + "@cloudflare/workers-types": "^4.20260522.1", 26 + "typescript": "^5.8.3", 27 + "vitest": "^4.1.7", 28 + "wrangler": "^4.94.0" 29 + } 30 + }
+18
apps/relay/src/auth/sender.ts
··· 1 + import type { Did, Nsid } from '@atcute/lexicons'; 2 + import type { ServiceJwtVerifier } from '@atcute/xrpc-server/auth'; 3 + 4 + /** 5 + * Sender path (used by `requestPermission`, `send`). 6 + * 7 + * The bearer JWT is issued by the *sender's* DID and signed with the sender's 8 + * atproto signing key (resolved from their DID document). On any failure the 9 + * verifier throws `AuthRequiredError` with a populated `WWW-Authenticate`. 10 + */ 11 + export async function verifySenderRequest( 12 + verifier: ServiceJwtVerifier, 13 + request: Request, 14 + lxm: Nsid, 15 + ): Promise<{ senderDid: Did }> { 16 + const { issuer } = await verifier.verifyRequest(request, { lxm }); 17 + return { senderDid: issuer }; 18 + }
+19
apps/relay/src/auth/user.ts
··· 1 + import type { Did, Nsid } from '@atcute/lexicons'; 2 + import type { ServiceJwtVerifier } from '@atcute/xrpc-server/auth'; 3 + 4 + /** 5 + * User path (used by every procedure/query except `requestPermission`/`send`). 6 + * 7 + * The bearer JWT is issued by the *end user's* DID, minted by their PDS via 8 + * `com.atproto.server.getServiceAuth` (the website obtains these on the user's 9 + * behalf). On any failure the verifier throws `AuthRequiredError` with a 10 + * populated `WWW-Authenticate`. 11 + */ 12 + export async function verifyUserRequest( 13 + verifier: ServiceJwtVerifier, 14 + request: Request, 15 + lxm: Nsid, 16 + ): Promise<{ userDid: Did }> { 17 + const { issuer } = await verifier.verifyRequest(request, { lxm }); 18 + return { userDid: issuer }; 19 + }
+30
apps/relay/src/auth/verifier.ts
··· 1 + import type { Did } from '@atcute/lexicons'; 2 + import type { AtprotoAudience } from '@atcute/lexicons/syntax'; 3 + import { ServiceJwtVerifier } from '@atcute/xrpc-server/auth'; 4 + 5 + import type { Env } from '../env'; 6 + import { makeResolver } from '../identity/resolve'; 7 + 8 + // One verifier per Env (constructing the resolver is cheap but pointless to 9 + // repeat each request; the underlying DID-doc cache lives in KV anyway). 10 + const verifiers = new WeakMap<Env, ServiceJwtVerifier>(); 11 + 12 + export function getVerifier(env: Env): ServiceJwtVerifier { 13 + let verifier = verifiers.get(env); 14 + if (verifier === undefined) { 15 + verifier = makeVerifier(env); 16 + verifiers.set(env, verifier); 17 + } 18 + return verifier; 19 + } 20 + 21 + export function makeVerifier(env: Env): ServiceJwtVerifier { 22 + const relayDid = env.RELAY_DID as Did; 23 + return new ServiceJwtVerifier({ 24 + // Accept tokens addressed to the bare relay DID or the service-ref form. 25 + acceptAudiences: [relayDid, `${relayDid}#notif_relay` as AtprotoAudience], 26 + resolver: makeResolver(env.CACHE), 27 + maxAge: 300, 28 + clockLeeway: 5, 29 + }); 30 + }
+400
apps/relay/src/db/queries.ts
··· 1 + import type { Did } from '@atcute/lexicons'; 2 + 3 + // Row shapes mirror migrations/0001_init.sql. D1 returns plain JSON; we treat 4 + // the `.first()/.all()` boundary as the place to assert these types (DID columns 5 + // are branded `Did`). Booleans are stored as 0/1 integers. 6 + 7 + export interface UserRow { 8 + did: Did; 9 + created_at: number; 10 + notify_pending_via_telegram: number; 11 + } 12 + 13 + export interface ChannelRow { 14 + did: Did; 15 + platform: string; 16 + platform_user_id: string; 17 + display_name: string | null; 18 + linked_at: number; 19 + } 20 + 21 + export interface LinkTokenRow { 22 + token: string; 23 + did: Did; 24 + platform: string; 25 + expires_at: number; 26 + } 27 + 28 + export interface SenderRow { 29 + did: Did; 30 + handle: string | null; 31 + display_name: string | null; 32 + avatar_url: string | null; 33 + profile_fetched_at: number | null; 34 + } 35 + 36 + export interface PendingRequestRow { 37 + id: string; 38 + recipient_did: Did; 39 + sender_did: Did; 40 + reason: string | null; 41 + created_at: number; 42 + expires_at: number; 43 + } 44 + 45 + export interface GrantRow { 46 + recipient_did: Did; 47 + sender_did: Did; 48 + granted_at: number; 49 + muted: number; 50 + } 51 + 52 + /** A grant joined with the (optional) cached sender profile. */ 53 + export interface GrantWithSenderRow extends GrantRow { 54 + handle: string | null; 55 + display_name: string | null; 56 + avatar_url: string | null; 57 + } 58 + 59 + /** A pending request joined with the (optional) cached sender profile. */ 60 + export interface PendingWithSenderRow extends PendingRequestRow { 61 + handle: string | null; 62 + display_name: string | null; 63 + avatar_url: string | null; 64 + } 65 + 66 + const toInt = (b: boolean): number => (b ? 1 : 0); 67 + const changed = (result: D1Result): boolean => (result.meta.changes ?? 0) > 0; 68 + 69 + // --------------------------------------------------------------------------- 70 + // users 71 + // --------------------------------------------------------------------------- 72 + 73 + export function getUser(db: D1Database, did: Did): Promise<UserRow | null> { 74 + return db.prepare('SELECT * FROM users WHERE did = ?').bind(did).first<UserRow>(); 75 + } 76 + 77 + /** Insert a users row if absent. No-op when it already exists. */ 78 + export async function ensureUser(db: D1Database, did: Did, createdAt: number): Promise<void> { 79 + await db 80 + .prepare('INSERT OR IGNORE INTO users (did, created_at, notify_pending_via_telegram) VALUES (?, ?, 0)') 81 + .bind(did, createdAt) 82 + .run(); 83 + } 84 + 85 + export async function setNotifyPending(db: D1Database, did: Did, value: boolean): Promise<void> { 86 + await db 87 + .prepare('UPDATE users SET notify_pending_via_telegram = ? WHERE did = ?') 88 + .bind(toInt(value), did) 89 + .run(); 90 + } 91 + 92 + // --------------------------------------------------------------------------- 93 + // channels 94 + // --------------------------------------------------------------------------- 95 + 96 + export async function listChannelsForDid(db: D1Database, did: Did): Promise<ChannelRow[]> { 97 + const { results } = await db 98 + .prepare('SELECT * FROM channels WHERE did = ? ORDER BY linked_at DESC') 99 + .bind(did) 100 + .all<ChannelRow>(); 101 + return results; 102 + } 103 + 104 + export function getChannelByPlatformUser( 105 + db: D1Database, 106 + platform: string, 107 + platformUserId: string, 108 + ): Promise<ChannelRow | null> { 109 + return db 110 + .prepare('SELECT * FROM channels WHERE platform = ? AND platform_user_id = ?') 111 + .bind(platform, platformUserId) 112 + .first<ChannelRow>(); 113 + } 114 + 115 + export interface UpsertChannelInput { 116 + did: Did; 117 + platform: string; 118 + platformUserId: string; 119 + displayName: string | null; 120 + linkedAt: number; 121 + } 122 + 123 + /** 124 + * Insert/replace a channel. `INSERT OR REPLACE` also clears any existing row 125 + * that collides on the `(platform, platform_user_id)` unique index, so linking 126 + * a Telegram account that was previously tied to another DID moves it cleanly. 127 + */ 128 + export async function upsertChannel(db: D1Database, input: UpsertChannelInput): Promise<void> { 129 + await db 130 + .prepare( 131 + 'INSERT OR REPLACE INTO channels (did, platform, platform_user_id, display_name, linked_at) VALUES (?, ?, ?, ?, ?)', 132 + ) 133 + .bind(input.did, input.platform, input.platformUserId, input.displayName, input.linkedAt) 134 + .run(); 135 + } 136 + 137 + export async function deleteChannel(db: D1Database, did: Did, platform: string): Promise<boolean> { 138 + const result = await db 139 + .prepare('DELETE FROM channels WHERE did = ? AND platform = ?') 140 + .bind(did, platform) 141 + .run(); 142 + return changed(result); 143 + } 144 + 145 + /** Used to reap a channel after Telegram reports the user blocked the bot. */ 146 + export async function deleteChannelByPlatformUser( 147 + db: D1Database, 148 + platform: string, 149 + platformUserId: string, 150 + ): Promise<boolean> { 151 + const result = await db 152 + .prepare('DELETE FROM channels WHERE platform = ? AND platform_user_id = ?') 153 + .bind(platform, platformUserId) 154 + .run(); 155 + return changed(result); 156 + } 157 + 158 + // --------------------------------------------------------------------------- 159 + // link_tokens 160 + // --------------------------------------------------------------------------- 161 + 162 + export interface InsertLinkTokenInput { 163 + token: string; 164 + did: Did; 165 + platform: string; 166 + expiresAt: number; 167 + } 168 + 169 + export async function insertLinkToken(db: D1Database, input: InsertLinkTokenInput): Promise<void> { 170 + await db 171 + .prepare('INSERT INTO link_tokens (token, did, platform, expires_at) VALUES (?, ?, ?, ?)') 172 + .bind(input.token, input.did, input.platform, input.expiresAt) 173 + .run(); 174 + } 175 + 176 + export function getLinkToken(db: D1Database, token: string): Promise<LinkTokenRow | null> { 177 + return db.prepare('SELECT * FROM link_tokens WHERE token = ?').bind(token).first<LinkTokenRow>(); 178 + } 179 + 180 + export async function deleteLinkToken(db: D1Database, token: string): Promise<void> { 181 + await db.prepare('DELETE FROM link_tokens WHERE token = ?').bind(token).run(); 182 + } 183 + 184 + export async function deleteExpiredLinkTokens(db: D1Database, nowMs: number): Promise<number> { 185 + const result = await db.prepare('DELETE FROM link_tokens WHERE expires_at < ?').bind(nowMs).run(); 186 + return result.meta.changes ?? 0; 187 + } 188 + 189 + // --------------------------------------------------------------------------- 190 + // senders 191 + // --------------------------------------------------------------------------- 192 + 193 + export function getSender(db: D1Database, did: Did): Promise<SenderRow | null> { 194 + return db.prepare('SELECT * FROM senders WHERE did = ?').bind(did).first<SenderRow>(); 195 + } 196 + 197 + export function getSenderByHandle(db: D1Database, handle: string): Promise<SenderRow | null> { 198 + return db.prepare('SELECT * FROM senders WHERE handle = ?').bind(handle).first<SenderRow>(); 199 + } 200 + 201 + export interface UpsertSenderInput { 202 + did: Did; 203 + handle: string | null; 204 + displayName: string | null; 205 + avatarUrl: string | null; 206 + profileFetchedAt: number; 207 + } 208 + 209 + export async function upsertSender(db: D1Database, input: UpsertSenderInput): Promise<void> { 210 + await db 211 + .prepare( 212 + `INSERT INTO senders (did, handle, display_name, avatar_url, profile_fetched_at) 213 + VALUES (?, ?, ?, ?, ?) 214 + ON CONFLICT(did) DO UPDATE SET 215 + handle = excluded.handle, 216 + display_name = excluded.display_name, 217 + avatar_url = excluded.avatar_url, 218 + profile_fetched_at = excluded.profile_fetched_at`, 219 + ) 220 + .bind(input.did, input.handle, input.displayName, input.avatarUrl, input.profileFetchedAt) 221 + .run(); 222 + } 223 + 224 + // --------------------------------------------------------------------------- 225 + // pending_requests 226 + // --------------------------------------------------------------------------- 227 + 228 + export function getPendingByPair( 229 + db: D1Database, 230 + recipientDid: Did, 231 + senderDid: Did, 232 + ): Promise<PendingRequestRow | null> { 233 + return db 234 + .prepare('SELECT * FROM pending_requests WHERE recipient_did = ? AND sender_did = ?') 235 + .bind(recipientDid, senderDid) 236 + .first<PendingRequestRow>(); 237 + } 238 + 239 + export function getPendingById(db: D1Database, id: string): Promise<PendingRequestRow | null> { 240 + return db.prepare('SELECT * FROM pending_requests WHERE id = ?').bind(id).first<PendingRequestRow>(); 241 + } 242 + 243 + export interface InsertPendingInput { 244 + id: string; 245 + recipientDid: Did; 246 + senderDid: Did; 247 + reason: string | null; 248 + createdAt: number; 249 + expiresAt: number; 250 + } 251 + 252 + export async function insertPending(db: D1Database, input: InsertPendingInput): Promise<void> { 253 + await db 254 + .prepare( 255 + 'INSERT INTO pending_requests (id, recipient_did, sender_did, reason, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)', 256 + ) 257 + .bind(input.id, input.recipientDid, input.senderDid, input.reason, input.createdAt, input.expiresAt) 258 + .run(); 259 + } 260 + 261 + export async function deletePendingById(db: D1Database, id: string, recipientDid: Did): Promise<boolean> { 262 + const result = await db 263 + .prepare('DELETE FROM pending_requests WHERE id = ? AND recipient_did = ?') 264 + .bind(id, recipientDid) 265 + .run(); 266 + return changed(result); 267 + } 268 + 269 + export async function deletePendingByPair( 270 + db: D1Database, 271 + recipientDid: Did, 272 + senderDid: Did, 273 + ): Promise<boolean> { 274 + const result = await db 275 + .prepare('DELETE FROM pending_requests WHERE recipient_did = ? AND sender_did = ?') 276 + .bind(recipientDid, senderDid) 277 + .run(); 278 + return changed(result); 279 + } 280 + 281 + export async function listPendingForRecipient( 282 + db: D1Database, 283 + recipientDid: Did, 284 + nowMs: number, 285 + ): Promise<PendingWithSenderRow[]> { 286 + const { results } = await db 287 + .prepare( 288 + `SELECT p.*, s.handle, s.display_name, s.avatar_url 289 + FROM pending_requests p 290 + LEFT JOIN senders s ON s.did = p.sender_did 291 + WHERE p.recipient_did = ? AND p.expires_at > ? 292 + ORDER BY p.created_at DESC`, 293 + ) 294 + .bind(recipientDid, nowMs) 295 + .all<PendingWithSenderRow>(); 296 + return results; 297 + } 298 + 299 + export async function deleteExpiredPending(db: D1Database, nowMs: number): Promise<number> { 300 + const result = await db 301 + .prepare('DELETE FROM pending_requests WHERE expires_at < ?') 302 + .bind(nowMs) 303 + .run(); 304 + return result.meta.changes ?? 0; 305 + } 306 + 307 + // --------------------------------------------------------------------------- 308 + // grants 309 + // --------------------------------------------------------------------------- 310 + 311 + export function getGrant(db: D1Database, recipientDid: Did, senderDid: Did): Promise<GrantRow | null> { 312 + return db 313 + .prepare('SELECT * FROM grants WHERE recipient_did = ? AND sender_did = ?') 314 + .bind(recipientDid, senderDid) 315 + .first<GrantRow>(); 316 + } 317 + 318 + /** Insert or refresh a grant. Re-granting resets `muted` to 0. */ 319 + export async function upsertGrant( 320 + db: D1Database, 321 + recipientDid: Did, 322 + senderDid: Did, 323 + grantedAt: number, 324 + ): Promise<void> { 325 + await db 326 + .prepare( 327 + `INSERT INTO grants (recipient_did, sender_did, granted_at, muted) 328 + VALUES (?, ?, ?, 0) 329 + ON CONFLICT(recipient_did, sender_did) DO UPDATE SET 330 + granted_at = excluded.granted_at, 331 + muted = 0`, 332 + ) 333 + .bind(recipientDid, senderDid, grantedAt) 334 + .run(); 335 + } 336 + 337 + export async function setGrantMuted( 338 + db: D1Database, 339 + recipientDid: Did, 340 + senderDid: Did, 341 + muted: boolean, 342 + ): Promise<boolean> { 343 + const result = await db 344 + .prepare('UPDATE grants SET muted = ? WHERE recipient_did = ? AND sender_did = ?') 345 + .bind(toInt(muted), recipientDid, senderDid) 346 + .run(); 347 + return changed(result); 348 + } 349 + 350 + export async function deleteGrant(db: D1Database, recipientDid: Did, senderDid: Did): Promise<boolean> { 351 + const result = await db 352 + .prepare('DELETE FROM grants WHERE recipient_did = ? AND sender_did = ?') 353 + .bind(recipientDid, senderDid) 354 + .run(); 355 + return changed(result); 356 + } 357 + 358 + export async function listGrantsForRecipient( 359 + db: D1Database, 360 + recipientDid: Did, 361 + ): Promise<GrantWithSenderRow[]> { 362 + const { results } = await db 363 + .prepare( 364 + `SELECT g.*, s.handle, s.display_name, s.avatar_url 365 + FROM grants g 366 + LEFT JOIN senders s ON s.did = g.sender_did 367 + WHERE g.recipient_did = ? 368 + ORDER BY g.granted_at DESC`, 369 + ) 370 + .bind(recipientDid) 371 + .all<GrantWithSenderRow>(); 372 + return results; 373 + } 374 + 375 + // --------------------------------------------------------------------------- 376 + // delivery_log 377 + // --------------------------------------------------------------------------- 378 + 379 + export interface InsertDeliveryLogInput { 380 + id: string; 381 + recipientDid: Did; 382 + senderDid: Did; 383 + title: string | null; 384 + deliveredCount: number; 385 + createdAt: number; 386 + } 387 + 388 + export async function insertDeliveryLog(db: D1Database, input: InsertDeliveryLogInput): Promise<void> { 389 + await db 390 + .prepare( 391 + 'INSERT INTO delivery_log (id, recipient_did, sender_did, title, delivered_count, created_at) VALUES (?, ?, ?, ?, ?, ?)', 392 + ) 393 + .bind(input.id, input.recipientDid, input.senderDid, input.title, input.deliveredCount, input.createdAt) 394 + .run(); 395 + } 396 + 397 + export async function deleteOldDeliveryLog(db: D1Database, beforeMs: number): Promise<number> { 398 + const result = await db.prepare('DELETE FROM delivery_log WHERE created_at < ?').bind(beforeMs).run(); 399 + return result.meta.changes ?? 0; 400 + }
+71
apps/relay/src/db/schema.sql
··· 1 + -- Canonical schema reference for the relay's D1 database. 2 + -- 3 + -- This file is documentation: the source of truth applied to D1 is the numbered 4 + -- migrations in `apps/relay/migrations/`. Keep this in sync with them (currently 5 + -- it mirrors migrations/0001_init.sql). 6 + -- 7 + -- All timestamps are unix milliseconds (Date.now()). 8 + 9 + CREATE TABLE users ( 10 + did TEXT PRIMARY KEY, 11 + created_at INTEGER NOT NULL, 12 + notify_pending_via_telegram INTEGER NOT NULL DEFAULT 0 13 + ); 14 + 15 + CREATE TABLE channels ( 16 + did TEXT NOT NULL, 17 + platform TEXT NOT NULL, 18 + platform_user_id TEXT NOT NULL, 19 + display_name TEXT, 20 + linked_at INTEGER NOT NULL, 21 + PRIMARY KEY (did, platform) 22 + ); 23 + CREATE UNIQUE INDEX channels_by_platform_user ON channels (platform, platform_user_id); 24 + 25 + CREATE TABLE link_tokens ( 26 + token TEXT PRIMARY KEY, 27 + did TEXT NOT NULL, 28 + platform TEXT NOT NULL, 29 + expires_at INTEGER NOT NULL 30 + ); 31 + CREATE INDEX link_tokens_by_did ON link_tokens (did); 32 + CREATE INDEX link_tokens_by_expires ON link_tokens (expires_at); 33 + 34 + CREATE TABLE senders ( 35 + did TEXT PRIMARY KEY, 36 + handle TEXT, 37 + display_name TEXT, 38 + avatar_url TEXT, 39 + profile_fetched_at INTEGER 40 + ); 41 + 42 + CREATE TABLE pending_requests ( 43 + id TEXT PRIMARY KEY, 44 + recipient_did TEXT NOT NULL, 45 + sender_did TEXT NOT NULL, 46 + reason TEXT, 47 + created_at INTEGER NOT NULL, 48 + expires_at INTEGER NOT NULL, 49 + UNIQUE (recipient_did, sender_did) 50 + ); 51 + CREATE INDEX pending_by_recipient ON pending_requests (recipient_did); 52 + CREATE INDEX pending_by_expires ON pending_requests (expires_at); 53 + 54 + CREATE TABLE grants ( 55 + recipient_did TEXT NOT NULL, 56 + sender_did TEXT NOT NULL, 57 + granted_at INTEGER NOT NULL, 58 + muted INTEGER NOT NULL DEFAULT 0, 59 + PRIMARY KEY (recipient_did, sender_did) 60 + ); 61 + CREATE INDEX grants_by_recipient ON grants (recipient_did); 62 + 63 + CREATE TABLE delivery_log ( 64 + id TEXT PRIMARY KEY, 65 + recipient_did TEXT NOT NULL, 66 + sender_did TEXT NOT NULL, 67 + title TEXT, 68 + delivered_count INTEGER NOT NULL, 69 + created_at INTEGER NOT NULL 70 + ); 71 + CREATE INDEX delivery_by_recipient ON delivery_log (recipient_did, created_at DESC);
+85
apps/relay/src/delivery/dispatcher.ts
··· 1 + import { deleteChannelByPlatformUser } from '../db/queries'; 2 + import type { DispatchJob, Env } from '../env'; 3 + 4 + import { 5 + escapeMd, 6 + type InlineKeyboardMarkup, 7 + sendMessage, 8 + TelegramApiError, 9 + } from './telegram'; 10 + 11 + /** 12 + * Queue consumer. Each message is an independent delivery; we ack on success and 13 + * on permanent failure (dead channel), and retry on transient failure so Queues' 14 + * built-in retry/backoff handles it. 15 + */ 16 + export async function handleQueue(batch: MessageBatch<DispatchJob>, env: Env): Promise<void> { 17 + for (const message of batch.messages) { 18 + try { 19 + await dispatch(env, message.body); 20 + message.ack(); 21 + } catch (err) { 22 + if (err instanceof TelegramApiError && isDeadChannel(err)) { 23 + // The user blocked the bot or the chat is gone: reap the channel and 24 + // stop retrying this message. 25 + const { channel } = message.body; 26 + await deleteChannelByPlatformUser(env.DB, channel.platform, channel.platformUserId); 27 + console.error(`dispatch: dropping dead channel ${channel.platformUserId}: ${err.description}`); 28 + message.ack(); 29 + } else { 30 + console.error('dispatch: transient failure, retrying', err); 31 + message.retry(); 32 + } 33 + } 34 + } 35 + } 36 + 37 + /** Telegram errors that mean the channel is permanently undeliverable. */ 38 + function isDeadChannel(err: TelegramApiError): boolean { 39 + if (err.errorCode === 403) { 40 + // "Forbidden: bot was blocked by the user" (and similar 403s). 41 + return true; 42 + } 43 + if (err.errorCode === 400 && /chat not found/i.test(err.description)) { 44 + return true; 45 + } 46 + return false; 47 + } 48 + 49 + async function dispatch(env: Env, job: DispatchJob): Promise<void> { 50 + if (job.kind === 'notification') { 51 + const text = `*${escapeMd(job.title)}*\n${escapeMd(job.body)}`; 52 + const replyMarkup: InlineKeyboardMarkup | undefined = 53 + job.uri !== undefined ? { inline_keyboard: [[{ text: 'Open', url: job.uri }]] } : undefined; 54 + await sendMessage(env, { 55 + chat_id: job.channel.platformUserId, 56 + text, 57 + parse_mode: 'MarkdownV2', 58 + reply_markup: replyMarkup, 59 + }); 60 + return; 61 + } 62 + 63 + // pendingRequest 64 + const handle = `@${escapeMd(job.senderHandle)}`; 65 + const name = 66 + job.senderDisplayName !== undefined 67 + ? `${escapeMd(job.senderDisplayName)} \\(${handle}\\)` 68 + : handle; 69 + const reasonLine = job.reason !== undefined ? `\n\n_${escapeMd(job.reason)}_` : ''; 70 + const text = `🔔 *${name}* wants to send you notifications${reasonLine}`; 71 + const replyMarkup: InlineKeyboardMarkup = { 72 + inline_keyboard: [ 73 + [ 74 + { text: '✅ Allow', callback_data: `approve:${job.requestId}` }, 75 + { text: '❌ Deny', callback_data: `deny:${job.requestId}` }, 76 + ], 77 + ], 78 + }; 79 + await sendMessage(env, { 80 + chat_id: job.channel.platformUserId, 81 + text, 82 + parse_mode: 'MarkdownV2', 83 + reply_markup: replyMarkup, 84 + }); 85 + }
+89
apps/relay/src/delivery/telegram.ts
··· 1 + import type { Env } from '../env'; 2 + 3 + const TELEGRAM_API = 'https://api.telegram.org'; 4 + 5 + /** Thrown when the Telegram Bot API returns `{ ok: false }`. */ 6 + export class TelegramApiError extends Error { 7 + readonly errorCode: number; 8 + readonly description: string; 9 + 10 + constructor(errorCode: number, description: string) { 11 + super(`Telegram API error ${errorCode}: ${description}`); 12 + this.name = 'TelegramApiError'; 13 + this.errorCode = errorCode; 14 + this.description = description; 15 + } 16 + } 17 + 18 + export interface InlineKeyboardButton { 19 + text: string; 20 + url?: string; 21 + callback_data?: string; 22 + } 23 + 24 + export interface InlineKeyboardMarkup { 25 + inline_keyboard: InlineKeyboardButton[][]; 26 + } 27 + 28 + export interface SendMessageParams { 29 + chat_id: string | number; 30 + text: string; 31 + parse_mode?: 'MarkdownV2'; 32 + reply_markup?: InlineKeyboardMarkup; 33 + link_preview_options?: { is_disabled: boolean }; 34 + } 35 + 36 + export interface EditMessageTextParams { 37 + chat_id: string | number; 38 + message_id: number; 39 + text: string; 40 + parse_mode?: 'MarkdownV2'; 41 + reply_markup?: InlineKeyboardMarkup; 42 + } 43 + 44 + export interface AnswerCallbackQueryParams { 45 + callback_query_id: string; 46 + text?: string; 47 + show_alert?: boolean; 48 + } 49 + 50 + interface TelegramApiResponse<T> { 51 + ok: boolean; 52 + result?: T; 53 + error_code?: number; 54 + description?: string; 55 + } 56 + 57 + async function callTelegram<T>(env: Env, method: string, body: unknown): Promise<T> { 58 + const res = await fetch(`${TELEGRAM_API}/bot${env.TELEGRAM_BOT_TOKEN}/${method}`, { 59 + method: 'POST', 60 + headers: { 'content-type': 'application/json' }, 61 + body: JSON.stringify(body), 62 + }); 63 + const data = (await res.json()) as TelegramApiResponse<T>; 64 + if (!data.ok) { 65 + throw new TelegramApiError(data.error_code ?? res.status, data.description ?? 'unknown error'); 66 + } 67 + return data.result as T; 68 + } 69 + 70 + export function sendMessage(env: Env, params: SendMessageParams): Promise<{ message_id: number }> { 71 + return callTelegram(env, 'sendMessage', params); 72 + } 73 + 74 + export function editMessageText(env: Env, params: EditMessageTextParams): Promise<unknown> { 75 + return callTelegram(env, 'editMessageText', params); 76 + } 77 + 78 + export function answerCallbackQuery(env: Env, params: AnswerCallbackQueryParams): Promise<unknown> { 79 + return callTelegram(env, 'answerCallbackQuery', params); 80 + } 81 + 82 + // Telegram MarkdownV2 reserves these characters; they must be backslash-escaped 83 + // anywhere they appear in user-supplied content. 84 + const MARKDOWN_V2_SPECIAL = /[_*[\]()~`>#+\-=|{}.!\\]/g; 85 + 86 + /** Escape a string for safe interpolation into a Telegram MarkdownV2 message. */ 87 + export function escapeMd(text: string): string { 88 + return text.replace(MARKDOWN_V2_SPECIAL, (char) => `\\${char}`); 89 + }
+61
apps/relay/src/env.ts
··· 1 + import type { ServiceJwtVerifier } from '@atcute/xrpc-server/auth'; 2 + 3 + /** A delivery channel on a third-party platform. v1 supports Telegram only. */ 4 + export interface TelegramChannel { 5 + platform: 'telegram'; 6 + /** Telegram chat id (stored as `channels.platform_user_id`). */ 7 + platformUserId: string; 8 + } 9 + 10 + /** 11 + * Work item placed on `DISPATCH_QUEUE` and handled by the `queue` consumer. 12 + * Discriminated on `kind`. 13 + */ 14 + export type DispatchJob = 15 + | { 16 + kind: 'notification'; 17 + channel: TelegramChannel; 18 + title: string; 19 + body: string; 20 + uri?: string; 21 + senderDid: string; 22 + } 23 + | { 24 + kind: 'pendingRequest'; 25 + channel: TelegramChannel; 26 + requestId: string; 27 + senderHandle: string; 28 + senderDisplayName?: string; 29 + reason?: string; 30 + }; 31 + 32 + /** Cloudflare bindings + vars + secrets, as declared in `wrangler.toml`. */ 33 + export interface Env { 34 + /** D1 database holding all persistent state. */ 35 + DB: D1Database; 36 + /** KV namespace used for DID-doc caching and rate-limit counters. */ 37 + CACHE: KVNamespace; 38 + /** Queue producer for async delivery. */ 39 + DISPATCH_QUEUE: Queue<DispatchJob>; 40 + 41 + /** `did:web:notifs.atmo.tools` — this relay's DID (var). */ 42 + RELAY_DID: string; 43 + /** Telegram bot username, used to build deep links (var). */ 44 + BOT_USERNAME: string; 45 + 46 + /** Telegram bot token (secret). */ 47 + TELEGRAM_BOT_TOKEN: string; 48 + /** Shared secret embedded in the Telegram webhook path (secret). */ 49 + TELEGRAM_WEBHOOK_SECRET: string; 50 + } 51 + 52 + /** 53 + * Per-request application context threaded through XRPC handlers. The verifier 54 + * is memoized per `Env` (see `auth/verifier.ts`); `ctx` is request-scoped and 55 + * used for `waitUntil` fire-and-forget work. 56 + */ 57 + export interface AppContext { 58 + env: Env; 59 + ctx: ExecutionContext; 60 + verifier: ServiceJwtVerifier; 61 + }
+56
apps/relay/src/identity/resolve.ts
··· 1 + import { 2 + CompositeDidDocumentResolver, 3 + type DidDocumentResolver, 4 + PlcDidDocumentResolver, 5 + type ResolveDidDocumentOptions, 6 + WebDidDocumentResolver, 7 + } from '@atcute/identity-resolver'; 8 + import type { Did } from '@atcute/lexicons'; 9 + 10 + // The resolved DID document type, derived from the resolver interface so we 11 + // don't need a direct dependency on `@atcute/identity` just for the type. 12 + type DidDocument = Awaited<ReturnType<DidDocumentResolver['resolve']>>; 13 + 14 + const DID_DOC_CACHE_TTL_SECONDS = 5 * 60; // 5 minutes (see README "Configuration") 15 + 16 + /** 17 + * Wraps an inner DID-document resolver with a KV cache. DID documents change 18 + * rarely, so a short TTL massively cuts PLC/web lookups during JWT verification. 19 + */ 20 + export class CachedDidDocumentResolver implements DidDocumentResolver { 21 + readonly #inner: DidDocumentResolver; 22 + readonly #cache: KVNamespace; 23 + 24 + constructor(inner: DidDocumentResolver, cache: KVNamespace) { 25 + this.#inner = inner; 26 + this.#cache = cache; 27 + } 28 + 29 + async resolve(did: Did, options?: ResolveDidDocumentOptions): Promise<DidDocument> { 30 + const key = `diddoc:${did}`; 31 + 32 + if (!options?.noCache) { 33 + const cached = await this.#cache.get<DidDocument>(key, 'json'); 34 + if (cached !== null) { 35 + return cached; 36 + } 37 + } 38 + 39 + const doc = await this.#inner.resolve(did, options); 40 + await this.#cache.put(key, JSON.stringify(doc), { 41 + expirationTtl: DID_DOC_CACHE_TTL_SECONDS, 42 + }); 43 + return doc; 44 + } 45 + } 46 + 47 + /** Build the plc+web composite resolver, wrapped in the KV cache. */ 48 + export function makeResolver(cache: KVNamespace): DidDocumentResolver { 49 + const composite = new CompositeDidDocumentResolver({ 50 + methods: { 51 + plc: new PlcDidDocumentResolver(), 52 + web: new WebDidDocumentResolver(), 53 + }, 54 + }); 55 + return new CachedDidDocumentResolver(composite, cache); 56 + }
+49
apps/relay/src/index.ts
··· 1 + import { 2 + deleteExpiredLinkTokens, 3 + deleteExpiredPending, 4 + deleteOldDeliveryLog, 5 + } from './db/queries'; 6 + import { handleQueue } from './delivery/dispatcher'; 7 + import type { DispatchJob, Env } from './env'; 8 + import { DAY_MS } from './lib/time'; 9 + import { buildRouter } from './router'; 10 + import { handleTelegramWebhook } from './telegram/webhook'; 11 + import { handleLexicon, handleWellKnownDid } from './well-known'; 12 + 13 + const TELEGRAM_WEBHOOK_RE = /^\/telegram\/webhook\/(.+)$/; 14 + const DELIVERY_LOG_RETENTION_MS = 30 * DAY_MS; 15 + 16 + export default { 17 + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { 18 + const url = new URL(request.url); 19 + 20 + if (url.pathname === '/.well-known/did.json') { 21 + return handleWellKnownDid(env); 22 + } 23 + 24 + if (url.pathname.startsWith('/lexicons/')) { 25 + const nsid = decodeURIComponent(url.pathname.slice('/lexicons/'.length)); 26 + return handleLexicon(nsid); 27 + } 28 + 29 + const telegramMatch = TELEGRAM_WEBHOOK_RE.exec(url.pathname); 30 + if (telegramMatch !== null) { 31 + return handleTelegramWebhook(request, env, telegramMatch[1]!); 32 + } 33 + 34 + // Everything else (/xrpc/*, /xrpc/_health) is handled by the XRPC router. 35 + return buildRouter(env, ctx).fetch(request); 36 + }, 37 + 38 + async queue(batch: MessageBatch<DispatchJob>, env: Env): Promise<void> { 39 + await handleQueue(batch, env); 40 + }, 41 + 42 + async scheduled(_controller: ScheduledController, env: Env): Promise<void> { 43 + // Daily housekeeping (cron "0 3 * * *"). 44 + const ts = Date.now(); 45 + await deleteExpiredPending(env.DB, ts); 46 + await deleteExpiredLinkTokens(env.DB, ts); 47 + await deleteOldDeliveryLog(env.DB, ts - DELIVERY_LOG_RETENTION_MS); 48 + }, 49 + } satisfies ExportedHandler<Env, DispatchJob>;
+35
apps/relay/src/lib/errors.ts
··· 1 + import { RateLimitExceededError, XRPCError } from '@atcute/xrpc-server'; 2 + 3 + // Re-export the error classes handlers throw, so call sites import from one place. 4 + // The router's default exception handler turns any thrown `XRPCError` into the 5 + // correct HTTP response, so handlers should always `throw` these (never return 6 + // ad-hoc error JSON). 7 + export { 8 + AuthRequiredError, 9 + ForbiddenError, 10 + RateLimitExceededError, 11 + XRPCError, 12 + } from '@atcute/xrpc-server'; 13 + 14 + /** 15 + * 403 with the lexicon-declared `NotAuthorized` error name (used by `send` and 16 + * `requestPermission`). We use a raw `XRPCError` rather than `ForbiddenError` 17 + * because the latter's error name is `Forbidden`. 18 + */ 19 + export function notAuthorized(message = 'Not authorized to notify this recipient'): XRPCError { 20 + return new XRPCError({ status: 403, error: 'NotAuthorized', message }); 21 + } 22 + 23 + /** 24 + * 429 `RateLimitExceeded` with a `Retry-After` header (whole seconds, min 1). 25 + */ 26 + export function rateLimited( 27 + retryAfterSeconds: number, 28 + message = 'Rate limit exceeded', 29 + ): RateLimitExceededError { 30 + const seconds = Math.max(1, Math.ceil(retryAfterSeconds)); 31 + return new RateLimitExceededError({ 32 + message, 33 + headers: { 'Retry-After': String(seconds) }, 34 + }); 35 + }
+26
apps/relay/src/lib/ids.ts
··· 1 + import { nanoid } from 'nanoid'; 2 + 3 + /** Generate a collision-resistant id for pending requests, delivery logs, etc. */ 4 + export function newId(): string { 5 + return nanoid(); 6 + } 7 + 8 + /** 9 + * Generate a 32-character URL-safe link token. 10 + * 11 + * Uses `crypto.getRandomValues` (CSPRNG) rather than `Math.random`. 24 random 12 + * bytes encode to exactly 32 base64url characters with no padding. 13 + */ 14 + export function newLinkToken(): string { 15 + const bytes = new Uint8Array(24); 16 + crypto.getRandomValues(bytes); 17 + return base64UrlEncode(bytes); 18 + } 19 + 20 + function base64UrlEncode(bytes: Uint8Array): string { 21 + let binary = ''; 22 + for (const byte of bytes) { 23 + binary += String.fromCharCode(byte); 24 + } 25 + return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, ''); 26 + }
+28
apps/relay/src/lib/time.ts
··· 1 + /** Time helpers. All timestamps in this service are unix milliseconds. */ 2 + 3 + export const SECOND_MS = 1_000; 4 + export const MINUTE_MS = 60 * SECOND_MS; 5 + export const HOUR_MS = 60 * MINUTE_MS; 6 + export const DAY_MS = 24 * HOUR_MS; 7 + 8 + /** Current time in unix milliseconds. */ 9 + export function now(): number { 10 + return Date.now(); 11 + } 12 + 13 + export function addSeconds(ts: number, seconds: number): number { 14 + return ts + seconds * SECOND_MS; 15 + } 16 + 17 + export function addMinutes(ts: number, minutes: number): number { 18 + return ts + minutes * MINUTE_MS; 19 + } 20 + 21 + export function addDays(ts: number, days: number): number { 22 + return ts + days * DAY_MS; 23 + } 24 + 25 + /** Render a unix-ms timestamp as an ISO-8601 datetime string (lexicon format). */ 26 + export function toIsoDatetime(ts: number): string { 27 + return new Date(ts).toISOString(); 28 + }
+70
apps/relay/src/profile/fetch.ts
··· 1 + import { simpleFetchHandler } from '@atcute/client'; 2 + import type { Did } from '@atcute/lexicons'; 3 + 4 + import { getSender, upsertSender } from '../db/queries'; 5 + import type { Env } from '../env'; 6 + import { DAY_MS, now } from '../lib/time'; 7 + 8 + const APPVIEW_URL = 'https://public.api.bsky.app'; 9 + const PROFILE_TTL_MS = DAY_MS; // 24h (see README "Configuration") 10 + 11 + interface NormalizedProfile { 12 + handle: string | null; 13 + displayName: string | null; 14 + avatar: string | null; 15 + } 16 + 17 + /** 18 + * Ensure the `senders` cache has a reasonably fresh Bluesky profile for a sender 19 + * DID. Refetches only when missing or older than 24h. The outcome — including a 20 + * failed/empty fetch — is always written, so a missing profile isn't re-fetched 21 + * on every request (`profile_fetched_at` gates the next retry). 22 + * 23 + * Intended to be called fire-and-forget via `ctx.waitUntil`. 24 + */ 25 + export async function ensureSenderProfile(env: Env, senderDid: Did): Promise<void> { 26 + const existing = await getSender(env.DB, senderDid); 27 + const fresh = 28 + existing !== null && 29 + existing.profile_fetched_at !== null && 30 + now() - existing.profile_fetched_at < PROFILE_TTL_MS; 31 + if (fresh) { 32 + return; 33 + } 34 + 35 + const profile = await fetchBskyProfile(senderDid); 36 + await upsertSender(env.DB, { 37 + did: senderDid, 38 + handle: profile?.handle ?? null, 39 + displayName: profile?.displayName ?? null, 40 + avatarUrl: profile?.avatar ?? null, 41 + profileFetchedAt: now(), 42 + }); 43 + } 44 + 45 + async function fetchBskyProfile(did: Did): Promise<NormalizedProfile | null> { 46 + try { 47 + // Unauthenticated AppView query via @atcute/client's fetch handler. 48 + const handler = simpleFetchHandler({ service: APPVIEW_URL }); 49 + const res = await handler( 50 + `/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`, 51 + { method: 'GET' }, 52 + ); 53 + if (!res.ok) { 54 + return null; 55 + } 56 + // Boundary: validate the few fields we read rather than trust the shape. 57 + const data: unknown = await res.json(); 58 + if (typeof data !== 'object' || data === null) { 59 + return null; 60 + } 61 + const record = data as Record<string, unknown>; 62 + return { 63 + handle: typeof record.handle === 'string' ? record.handle : null, 64 + displayName: typeof record.displayName === 'string' ? record.displayName : null, 65 + avatar: typeof record.avatar === 'string' ? record.avatar : null, 66 + }; 67 + } catch { 68 + return null; 69 + } 70 + }
+68
apps/relay/src/ratelimit.ts
··· 1 + /** 2 + * KV-backed fixed-window rate limiting. 3 + * 4 + * Each key holds the current count as the value, plus `metadata.expiresAt` (the 5 + * unix-ms instant the logical window ends). We track the window end in metadata 6 + * — rather than relying solely on KV's `expirationTtl` — because Cloudflare KV 7 + * enforces a 60-second minimum TTL, yet some windows here are sub-minute (the 8 + * per-pair "1 request/second" send limit). So the physical TTL is clamped to 9 + * >= 60s, while window expiry is decided logically from `expiresAt`. 10 + * 11 + * KV is eventually consistent, so concurrent requests across colos can slightly 12 + * over-count. That's acceptable for these limits. 13 + */ 14 + 15 + const KV_MIN_TTL_SECONDS = 60; 16 + 17 + export interface RateLimitResult { 18 + /** Whether this request is within the limit (the count after incrementing). */ 19 + allowed: boolean; 20 + /** Requests remaining in the current window (never negative). */ 21 + remaining: number; 22 + /** Seconds until the current window resets. */ 23 + resetIn: number; 24 + } 25 + 26 + interface CounterMetadata { 27 + expiresAt: number; 28 + } 29 + 30 + export async function checkAndIncrement( 31 + kv: KVNamespace, 32 + key: string, 33 + limit: number, 34 + windowSeconds: number, 35 + ): Promise<RateLimitResult> { 36 + const nowMs = Date.now(); 37 + const { value, metadata } = await kv.getWithMetadata<CounterMetadata>(key, 'text'); 38 + 39 + // Window is active when a value exists and its logical expiry is in the future. 40 + const windowActive = 41 + value !== null && metadata !== null && metadata.expiresAt > nowMs; 42 + 43 + let count: number; 44 + let expiresAt: number; 45 + if (windowActive) { 46 + count = Number.parseInt(value, 10) + 1; 47 + expiresAt = metadata.expiresAt; 48 + } else { 49 + // Start a fresh window (also covers the "logically expired but physically 50 + // still present" case, since we ignore the stale value). 51 + count = 1; 52 + expiresAt = nowMs + windowSeconds * 1000; 53 + } 54 + 55 + const resetInSeconds = Math.max(1, Math.ceil((expiresAt - nowMs) / 1000)); 56 + const physicalTtl = Math.max(KV_MIN_TTL_SECONDS, resetInSeconds); 57 + 58 + await kv.put(key, String(count), { 59 + expirationTtl: physicalTtl, 60 + metadata: { expiresAt } satisfies CounterMetadata, 61 + }); 62 + 63 + return { 64 + allowed: count <= limit, 65 + remaining: Math.max(0, limit - count), 66 + resetIn: resetInSeconds, 67 + }; 68 + }
+69
apps/relay/src/router.ts
··· 1 + import { 2 + ToolsAtmoNotifsDenyPending, 3 + ToolsAtmoNotifsGetSettings, 4 + ToolsAtmoNotifsGrant, 5 + ToolsAtmoNotifsLinkChannel, 6 + ToolsAtmoNotifsListChannels, 7 + ToolsAtmoNotifsListGrants, 8 + ToolsAtmoNotifsListPending, 9 + ToolsAtmoNotifsMuteGrant, 10 + ToolsAtmoNotifsRequestPermission, 11 + ToolsAtmoNotifsRevoke, 12 + ToolsAtmoNotifsSend, 13 + ToolsAtmoNotifsUnlinkChannel, 14 + ToolsAtmoNotifsUpdateSettings, 15 + } from '@atmo/notifs-lexicons'; 16 + import { XRPCRouter } from '@atcute/xrpc-server'; 17 + 18 + import { getVerifier } from './auth/verifier'; 19 + import type { AppContext, Env } from './env'; 20 + import { makeDenyPending } from './xrpc/denyPending'; 21 + import { makeGetSettings } from './xrpc/getSettings'; 22 + import { makeGrant } from './xrpc/grant'; 23 + import { makeLinkChannel } from './xrpc/linkChannel'; 24 + import { makeListChannels } from './xrpc/listChannels'; 25 + import { makeListGrants } from './xrpc/listGrants'; 26 + import { makeListPending } from './xrpc/listPending'; 27 + import { makeMuteGrant } from './xrpc/muteGrant'; 28 + import { makeRequestPermission } from './xrpc/requestPermission'; 29 + import { makeRevoke } from './xrpc/revoke'; 30 + import { makeSend } from './xrpc/send'; 31 + import { makeUnlinkChannel } from './xrpc/unlinkChannel'; 32 + import { makeUpdateSettings } from './xrpc/updateSettings'; 33 + 34 + /** 35 + * Build the XRPC router for a single request. Auth + rate limiting live inside 36 + * each handler (sender path vs user path differ), so there is no global 37 + * middleware. The router is cheap to construct; the verifier is memoized per Env. 38 + */ 39 + export function buildRouter(env: Env, ctx: ExecutionContext): XRPCRouter { 40 + const app: AppContext = { env, ctx, verifier: getVerifier(env) }; 41 + 42 + const router = new XRPCRouter({ 43 + handleHealthCheck: () => Response.json({ status: 'ok' }), 44 + onError: ({ error, request }) => { 45 + // Unexpected errors only (XRPCError subclasses are handled separately). 46 + // console.error ships to Workers observability / Logpush. 47 + console.error('unexpected XRPC error', request.url, error); 48 + }, 49 + }); 50 + 51 + // Sender path. 52 + router.addProcedure(ToolsAtmoNotifsRequestPermission.mainSchema, makeRequestPermission(app)); 53 + router.addProcedure(ToolsAtmoNotifsSend.mainSchema, makeSend(app)); 54 + 55 + // User path. 56 + router.addProcedure(ToolsAtmoNotifsGrant.mainSchema, makeGrant(app)); 57 + router.addProcedure(ToolsAtmoNotifsRevoke.mainSchema, makeRevoke(app)); 58 + router.addProcedure(ToolsAtmoNotifsDenyPending.mainSchema, makeDenyPending(app)); 59 + router.addProcedure(ToolsAtmoNotifsMuteGrant.mainSchema, makeMuteGrant(app)); 60 + router.addProcedure(ToolsAtmoNotifsLinkChannel.mainSchema, makeLinkChannel(app)); 61 + router.addProcedure(ToolsAtmoNotifsUnlinkChannel.mainSchema, makeUnlinkChannel(app)); 62 + router.addProcedure(ToolsAtmoNotifsUpdateSettings.mainSchema, makeUpdateSettings(app)); 63 + router.addQuery(ToolsAtmoNotifsListGrants.mainSchema, makeListGrants(app)); 64 + router.addQuery(ToolsAtmoNotifsListPending.mainSchema, makeListPending(app)); 65 + router.addQuery(ToolsAtmoNotifsListChannels.mainSchema, makeListChannels(app)); 66 + router.addQuery(ToolsAtmoNotifsGetSettings.mainSchema, makeGetSettings(app)); 67 + 68 + return router; 69 + }
+124
apps/relay/src/telegram/callbacks.ts
··· 1 + import type { Did } from '@atcute/lexicons'; 2 + 3 + import * as q from '../db/queries'; 4 + import type { Env } from '../env'; 5 + import { answerCallbackQuery, editMessageText } from '../delivery/telegram'; 6 + import { now } from '../lib/time'; 7 + 8 + import { settingsKeyboard, settingsText } from './commands'; 9 + import type { TelegramCallbackQuery } from './webhook'; 10 + 11 + const PLATFORM = 'telegram'; 12 + 13 + /** Handle an inline-button tap. Always answers the callback to dismiss the spinner. */ 14 + export async function handleCallback(env: Env, query: TelegramCallbackQuery): Promise<void> { 15 + const chatId = query.from.id; 16 + const channel = await q.getChannelByPlatformUser(env.DB, PLATFORM, String(chatId)); 17 + if (channel === null) { 18 + await answerCallbackQuery(env, { 19 + callback_query_id: query.id, 20 + text: 'Account not linked. Visit the website.', 21 + show_alert: true, 22 + }); 23 + return; 24 + } 25 + 26 + const data = query.data ?? ''; 27 + const messageId = query.message?.message_id; 28 + 29 + if (data.startsWith('approve:')) { 30 + await handleApprove(env, query, channel.did, data.slice('approve:'.length), messageId); 31 + return; 32 + } 33 + if (data.startsWith('deny:')) { 34 + await handleDeny(env, query, channel.did, data.slice('deny:'.length), messageId); 35 + return; 36 + } 37 + if (data === 'toggle:notifyPending') { 38 + await handleToggle(env, query, channel.did, messageId); 39 + return; 40 + } 41 + 42 + await answerCallbackQuery(env, { callback_query_id: query.id }); 43 + } 44 + 45 + async function handleApprove( 46 + env: Env, 47 + query: TelegramCallbackQuery, 48 + recipientDid: Did, 49 + requestId: string, 50 + messageId: number | undefined, 51 + ): Promise<void> { 52 + const pending = await q.getPendingById(env.DB, requestId); 53 + if (pending === null || pending.recipient_did !== recipientDid) { 54 + await answerCallbackQuery(env, { 55 + callback_query_id: query.id, 56 + text: 'This request is no longer available.', 57 + }); 58 + return; 59 + } 60 + 61 + await q.upsertGrant(env.DB, pending.recipient_did, pending.sender_did, now()); 62 + await q.deletePendingById(env.DB, requestId, recipientDid); 63 + if (messageId !== undefined) { 64 + await editMessageText(env, { 65 + chat_id: query.from.id, 66 + message_id: messageId, 67 + text: `✅ Approved ${await senderLabel(env, pending.sender_did)}`, 68 + }); 69 + } 70 + await answerCallbackQuery(env, { callback_query_id: query.id, text: 'Approved' }); 71 + } 72 + 73 + async function handleDeny( 74 + env: Env, 75 + query: TelegramCallbackQuery, 76 + recipientDid: Did, 77 + requestId: string, 78 + messageId: number | undefined, 79 + ): Promise<void> { 80 + const pending = await q.getPendingById(env.DB, requestId); 81 + if (pending !== null && pending.recipient_did === recipientDid) { 82 + await q.deletePendingById(env.DB, requestId, recipientDid); 83 + if (messageId !== undefined) { 84 + await editMessageText(env, { 85 + chat_id: query.from.id, 86 + message_id: messageId, 87 + text: `❌ Denied ${await senderLabel(env, pending.sender_did)}`, 88 + }); 89 + } 90 + } 91 + await answerCallbackQuery(env, { callback_query_id: query.id, text: 'Denied' }); 92 + } 93 + 94 + async function handleToggle( 95 + env: Env, 96 + query: TelegramCallbackQuery, 97 + recipientDid: Did, 98 + messageId: number | undefined, 99 + ): Promise<void> { 100 + await q.ensureUser(env.DB, recipientDid, now()); 101 + const user = await q.getUser(env.DB, recipientDid); 102 + const next = (user?.notify_pending_via_telegram ?? 0) === 0; 103 + await q.setNotifyPending(env.DB, recipientDid, next); 104 + 105 + if (messageId !== undefined) { 106 + await editMessageText(env, { 107 + chat_id: query.from.id, 108 + message_id: messageId, 109 + text: settingsText(next), 110 + parse_mode: 'MarkdownV2', 111 + reply_markup: settingsKeyboard(next), 112 + }); 113 + } 114 + await answerCallbackQuery(env, { 115 + callback_query_id: query.id, 116 + text: next ? 'Enabled' : 'Disabled', 117 + }); 118 + } 119 + 120 + /** A human-friendly label for a sender (cached handle, falling back to DID). */ 121 + async function senderLabel(env: Env, did: Did): Promise<string> { 122 + const sender = await q.getSender(env.DB, did); 123 + return sender?.handle !== null && sender?.handle !== undefined ? `@${sender.handle}` : did; 124 + }
+194
apps/relay/src/telegram/commands.ts
··· 1 + import type { Did } from '@atcute/lexicons'; 2 + 3 + import * as q from '../db/queries'; 4 + import type { Env } from '../env'; 5 + import { 6 + escapeMd, 7 + type InlineKeyboardMarkup, 8 + sendMessage, 9 + } from '../delivery/telegram'; 10 + import { now } from '../lib/time'; 11 + 12 + import type { TelegramMessage } from './webhook'; 13 + 14 + const DASHBOARD_URL = 'https://notifs.atmo.tools/dashboard'; 15 + const PLATFORM = 'telegram'; 16 + 17 + /** Dispatch a `/command` message to its handler. */ 18 + export async function handleCommand(env: Env, message: TelegramMessage): Promise<void> { 19 + const text = (message.text ?? '').trim(); 20 + const [rawCommand, ...rest] = text.split(/\s+/); 21 + // Strip a possible `@botname` suffix (Telegram appends it in group chats). 22 + const command = (rawCommand ?? '').split('@')[0]; 23 + const arg = rest.join(' ').trim(); 24 + 25 + switch (command) { 26 + case '/start': 27 + await handleStart(env, message, arg); 28 + return; 29 + case '/list': 30 + await handleList(env, message.chat.id); 31 + return; 32 + case '/revoke': 33 + await handleRevoke(env, message.chat.id, arg); 34 + return; 35 + case '/settings': 36 + await handleSettings(env, message.chat.id); 37 + return; 38 + default: 39 + await handleHelp(env, message.chat.id); 40 + } 41 + } 42 + 43 + async function handleStart(env: Env, message: TelegramMessage, token: string): Promise<void> { 44 + const chatId = message.chat.id; 45 + 46 + if (token === '') { 47 + await replyText( 48 + env, 49 + chatId, 50 + `👋 Welcome to the atmo notifications bot!\n\nLink your account from the dashboard to start receiving notifications:\n${DASHBOARD_URL}`, 51 + ); 52 + return; 53 + } 54 + 55 + const row = await q.getLinkToken(env.DB, token); 56 + if (row === null || row.expires_at < now()) { 57 + await replyText(env, chatId, `This link expired. Generate a new one at ${DASHBOARD_URL}`); 58 + return; 59 + } 60 + 61 + const did = row.did; 62 + const username = message.from?.username ?? null; 63 + await q.ensureUser(env.DB, did, now()); 64 + await q.upsertChannel(env.DB, { 65 + did, 66 + platform: PLATFORM, 67 + platformUserId: String(chatId), 68 + displayName: username, 69 + linkedAt: now(), 70 + }); 71 + await q.deleteLinkToken(env.DB, token); 72 + 73 + const label = username !== null ? `@${username}` : did; 74 + await replyText(env, chatId, `✅ Linked to ${label}`); 75 + } 76 + 77 + async function handleList(env: Env, chatId: number): Promise<void> { 78 + const channel = await q.getChannelByPlatformUser(env.DB, PLATFORM, String(chatId)); 79 + if (channel === null) { 80 + await replyText(env, chatId, NOT_LINKED); 81 + return; 82 + } 83 + 84 + const grants = await q.listGrantsForRecipient(env.DB, channel.did); 85 + if (grants.length === 0) { 86 + await replyText(env, chatId, "You haven't authorized any apps yet."); 87 + return; 88 + } 89 + 90 + const lines = grants.map((grant) => { 91 + const name = grant.handle !== null ? `@${grant.handle}` : grant.sender_did; 92 + const muted = grant.muted === 1 ? ' \\(muted\\)' : ''; 93 + return `• ${escapeMd(name)}${muted}`; 94 + }); 95 + await replyMarkdown(env, chatId, `*Authorized apps*\n${lines.join('\n')}`); 96 + } 97 + 98 + async function handleRevoke(env: Env, chatId: number, arg: string): Promise<void> { 99 + const channel = await q.getChannelByPlatformUser(env.DB, PLATFORM, String(chatId)); 100 + if (channel === null) { 101 + await replyText(env, chatId, NOT_LINKED); 102 + return; 103 + } 104 + if (arg === '') { 105 + await replyText(env, chatId, 'Usage: /revoke <handle-or-did>'); 106 + return; 107 + } 108 + 109 + const senderDid = await resolveToDid(env, arg); 110 + if (senderDid === null) { 111 + await replyText(env, chatId, `No matching grant for ${arg}.`); 112 + return; 113 + } 114 + 115 + const removed = await q.deleteGrant(env.DB, channel.did, senderDid); 116 + await q.deletePendingByPair(env.DB, channel.did, senderDid); 117 + await replyText( 118 + env, 119 + chatId, 120 + removed ? `✅ Revoked ${arg}.` : `No matching grant for ${arg}.`, 121 + ); 122 + } 123 + 124 + async function handleSettings(env: Env, chatId: number): Promise<void> { 125 + const channel = await q.getChannelByPlatformUser(env.DB, PLATFORM, String(chatId)); 126 + if (channel === null) { 127 + await replyText(env, chatId, NOT_LINKED); 128 + return; 129 + } 130 + 131 + await q.ensureUser(env.DB, channel.did, now()); 132 + const user = await q.getUser(env.DB, channel.did); 133 + const enabled = (user?.notify_pending_via_telegram ?? 0) === 1; 134 + await sendMessage(env, { 135 + chat_id: chatId, 136 + text: settingsText(enabled), 137 + parse_mode: 'MarkdownV2', 138 + reply_markup: settingsKeyboard(enabled), 139 + }); 140 + } 141 + 142 + async function handleHelp(env: Env, chatId: number): Promise<void> { 143 + await replyText( 144 + env, 145 + chatId, 146 + [ 147 + 'Commands:', 148 + '/start — link your account', 149 + '/list — list authorized apps', 150 + '/revoke <handle-or-did> — revoke an app', 151 + '/settings — notification settings', 152 + ].join('\n'), 153 + ); 154 + } 155 + 156 + /** Resolve a `/revoke` argument (handle or DID) to a sender DID, best-effort. */ 157 + async function resolveToDid(env: Env, input: string): Promise<Did | null> { 158 + if (input.startsWith('did:')) { 159 + return input as Did; 160 + } 161 + const handle = input.replace(/^@/, ''); 162 + const sender = await q.getSenderByHandle(env.DB, handle); 163 + return sender?.did ?? null; 164 + } 165 + 166 + // --- shared settings rendering (also used by callbacks.ts) ------------------ 167 + 168 + export function settingsText(enabled: boolean): string { 169 + return `*Settings*\n\nNotify me on Telegram about new permission requests: *${enabled ? 'ON' : 'OFF'}*`; 170 + } 171 + 172 + export function settingsKeyboard(enabled: boolean): InlineKeyboardMarkup { 173 + return { 174 + inline_keyboard: [ 175 + [ 176 + { 177 + text: enabled ? '🔔 Pending alerts: ON' : '🔕 Pending alerts: OFF', 178 + callback_data: 'toggle:notifyPending', 179 + }, 180 + ], 181 + ], 182 + }; 183 + } 184 + 185 + const NOT_LINKED = 186 + 'This Telegram account is not linked yet. Link it from https://notifs.atmo.tools/dashboard'; 187 + 188 + function replyText(env: Env, chatId: number, text: string): Promise<{ message_id: number }> { 189 + return sendMessage(env, { chat_id: chatId, text }); 190 + } 191 + 192 + function replyMarkdown(env: Env, chatId: number, text: string): Promise<{ message_id: number }> { 193 + return sendMessage(env, { chat_id: chatId, text, parse_mode: 'MarkdownV2' }); 194 + }
+75
apps/relay/src/telegram/webhook.ts
··· 1 + import type { Env } from '../env'; 2 + 3 + import { handleCallback } from './callbacks'; 4 + import { handleCommand } from './commands'; 5 + 6 + export interface TelegramUser { 7 + id: number; 8 + username?: string; 9 + first_name?: string; 10 + } 11 + 12 + export interface TelegramChat { 13 + id: number; 14 + type: string; 15 + } 16 + 17 + export interface TelegramMessage { 18 + message_id: number; 19 + from?: TelegramUser; 20 + chat: TelegramChat; 21 + text?: string; 22 + } 23 + 24 + export interface TelegramCallbackQuery { 25 + id: string; 26 + from: TelegramUser; 27 + message?: TelegramMessage; 28 + data?: string; 29 + } 30 + 31 + export interface TelegramUpdate { 32 + update_id: number; 33 + message?: TelegramMessage; 34 + callback_query?: TelegramCallbackQuery; 35 + } 36 + 37 + const OK = (): Response => new Response('ok', { status: 200 }); 38 + 39 + /** 40 + * Telegram webhook entrypoint, mounted at `POST /telegram/webhook/:secret`. 41 + * 42 + * The path secret must match `TELEGRAM_WEBHOOK_SECRET`. We always reply 200 to 43 + * Telegram (even on internal errors) so it doesn't aggressively retry; internal 44 + * errors are logged and, where relevant, surfaced to the user via a follow-up 45 + * message inside the command/callback handlers. 46 + */ 47 + export async function handleTelegramWebhook( 48 + request: Request, 49 + env: Env, 50 + secret: string, 51 + ): Promise<Response> { 52 + if (secret !== env.TELEGRAM_WEBHOOK_SECRET) { 53 + return new Response('forbidden', { status: 403 }); 54 + } 55 + 56 + let update: TelegramUpdate; 57 + try { 58 + update = await request.json<TelegramUpdate>(); 59 + } catch { 60 + return OK(); 61 + } 62 + 63 + try { 64 + if (update.message?.text !== undefined && update.message.text.startsWith('/')) { 65 + await handleCommand(env, update.message); 66 + } else if (update.callback_query !== undefined) { 67 + await handleCallback(env, update.callback_query); 68 + } 69 + // Anything else: ignore. 70 + } catch (err) { 71 + console.error('telegram webhook handler error', err); 72 + } 73 + 74 + return OK(); 75 + }
+69
apps/relay/src/well-known.ts
··· 1 + import authSender from '@atmo/notifs-lexicons/lexicons/tools/atmo/notifs/authSender.json'; 2 + import authUser from '@atmo/notifs-lexicons/lexicons/tools/atmo/notifs/authUser.json'; 3 + import denyPending from '@atmo/notifs-lexicons/lexicons/tools/atmo/notifs/denyPending.json'; 4 + import getSettings from '@atmo/notifs-lexicons/lexicons/tools/atmo/notifs/getSettings.json'; 5 + import grant from '@atmo/notifs-lexicons/lexicons/tools/atmo/notifs/grant.json'; 6 + import linkChannel from '@atmo/notifs-lexicons/lexicons/tools/atmo/notifs/linkChannel.json'; 7 + import listChannels from '@atmo/notifs-lexicons/lexicons/tools/atmo/notifs/listChannels.json'; 8 + import listGrants from '@atmo/notifs-lexicons/lexicons/tools/atmo/notifs/listGrants.json'; 9 + import listPending from '@atmo/notifs-lexicons/lexicons/tools/atmo/notifs/listPending.json'; 10 + import muteGrant from '@atmo/notifs-lexicons/lexicons/tools/atmo/notifs/muteGrant.json'; 11 + import requestPermission from '@atmo/notifs-lexicons/lexicons/tools/atmo/notifs/requestPermission.json'; 12 + import revoke from '@atmo/notifs-lexicons/lexicons/tools/atmo/notifs/revoke.json'; 13 + import send from '@atmo/notifs-lexicons/lexicons/tools/atmo/notifs/send.json'; 14 + import unlinkChannel from '@atmo/notifs-lexicons/lexicons/tools/atmo/notifs/unlinkChannel.json'; 15 + import updateSettings from '@atmo/notifs-lexicons/lexicons/tools/atmo/notifs/updateSettings.json'; 16 + 17 + import type { Env } from './env'; 18 + 19 + // Lexicon JSONs are bundled into the Worker (imported as JSON modules) and served 20 + // statically from `/lexicons/:nsid`, keyed by NSID. 21 + const LEXICONS: Record<string, unknown> = { 22 + 'tools.atmo.notifs.requestPermission': requestPermission, 23 + 'tools.atmo.notifs.send': send, 24 + 'tools.atmo.notifs.grant': grant, 25 + 'tools.atmo.notifs.revoke': revoke, 26 + 'tools.atmo.notifs.denyPending': denyPending, 27 + 'tools.atmo.notifs.muteGrant': muteGrant, 28 + 'tools.atmo.notifs.listGrants': listGrants, 29 + 'tools.atmo.notifs.listPending': listPending, 30 + 'tools.atmo.notifs.linkChannel': linkChannel, 31 + 'tools.atmo.notifs.unlinkChannel': unlinkChannel, 32 + 'tools.atmo.notifs.listChannels': listChannels, 33 + 'tools.atmo.notifs.getSettings': getSettings, 34 + 'tools.atmo.notifs.updateSettings': updateSettings, 35 + 'tools.atmo.notifs.authSender': authSender, 36 + 'tools.atmo.notifs.authUser': authUser, 37 + }; 38 + 39 + /** 40 + * `GET /.well-known/did.json` — the relay's `did:web` document. No 41 + * `verificationMethod`: the relay verifies inbound JWTs but never signs anything. 42 + * The service endpoint is derived from `RELAY_DID` so config has one source. 43 + */ 44 + export function handleWellKnownDid(env: Env): Response { 45 + const host = env.RELAY_DID.replace(/^did:web:/, ''); 46 + const doc = { 47 + '@context': ['https://www.w3.org/ns/did/v1'], 48 + id: env.RELAY_DID, 49 + service: [ 50 + { 51 + id: '#notif_relay', 52 + type: 'AtprotoNotificationRelay', 53 + serviceEndpoint: `https://${host}`, 54 + }, 55 + ], 56 + }; 57 + return Response.json(doc); 58 + } 59 + 60 + /** `GET /lexicons/:nsid` — serve a bundled lexicon document, CDN-cacheable. */ 61 + export function handleLexicon(nsid: string): Response { 62 + const doc = LEXICONS[nsid]; 63 + if (doc === undefined) { 64 + return new Response('Not found', { status: 404 }); 65 + } 66 + return Response.json(doc, { 67 + headers: { 'Cache-Control': 'public, max-age=3600' }, 68 + }); 69 + }
+24
apps/relay/src/xrpc/denyPending.ts
··· 1 + import { ToolsAtmoNotifsDenyPending } from '@atmo/notifs-lexicons'; 2 + import { json, type ProcedureConfig } from '@atcute/xrpc-server'; 3 + 4 + import { verifyUserRequest } from '../auth/user'; 5 + import * as q from '../db/queries'; 6 + import type { AppContext } from '../env'; 7 + 8 + const LXM = 'tools.atmo.notifs.denyPending'; 9 + 10 + export function makeDenyPending( 11 + app: AppContext, 12 + ): ProcedureConfig<ToolsAtmoNotifsDenyPending.mainSchema> { 13 + return { 14 + handler: async ({ request, input }) => { 15 + const { userDid } = await verifyUserRequest(app.verifier, request, LXM); 16 + 17 + // Delete the pending request without granting. Does not blocklist the 18 + // sender; they may request again once rate limits allow. 19 + const denied = await q.deletePendingById(app.env.DB, input.requestId, userDid); 20 + 21 + return json({ denied }); 22 + }, 23 + }; 24 + }
+27
apps/relay/src/xrpc/getSettings.ts
··· 1 + import { ToolsAtmoNotifsGetSettings } from '@atmo/notifs-lexicons'; 2 + import { json, type QueryConfig } from '@atcute/xrpc-server'; 3 + 4 + import { verifyUserRequest } from '../auth/user'; 5 + import * as q from '../db/queries'; 6 + import type { AppContext } from '../env'; 7 + import { now } from '../lib/time'; 8 + 9 + const LXM = 'tools.atmo.notifs.getSettings'; 10 + 11 + export function makeGetSettings( 12 + app: AppContext, 13 + ): QueryConfig<ToolsAtmoNotifsGetSettings.mainSchema> { 14 + return { 15 + handler: async ({ request }) => { 16 + const { userDid } = await verifyUserRequest(app.verifier, request, LXM); 17 + 18 + // Ensure the row exists so we return stored defaults rather than guessing. 19 + await q.ensureUser(app.env.DB, userDid, now()); 20 + const user = await q.getUser(app.env.DB, userDid); 21 + 22 + return json({ 23 + notifyPendingViaTelegram: (user?.notify_pending_via_telegram ?? 0) === 1, 24 + }); 25 + }, 26 + }; 27 + }
+25
apps/relay/src/xrpc/grant.ts
··· 1 + import { ToolsAtmoNotifsGrant } from '@atmo/notifs-lexicons'; 2 + import { json, type ProcedureConfig } from '@atcute/xrpc-server'; 3 + 4 + import { verifyUserRequest } from '../auth/user'; 5 + import * as q from '../db/queries'; 6 + import type { AppContext } from '../env'; 7 + import { now } from '../lib/time'; 8 + 9 + const LXM = 'tools.atmo.notifs.grant'; 10 + 11 + export function makeGrant(app: AppContext): ProcedureConfig<ToolsAtmoNotifsGrant.mainSchema> { 12 + return { 13 + handler: async ({ request, input }) => { 14 + const { userDid } = await verifyUserRequest(app.verifier, request, LXM); 15 + 16 + await q.ensureUser(app.env.DB, userDid, now()); 17 + await q.upsertGrant(app.env.DB, userDid, input.sender, now()); 18 + if (input.requestId !== undefined) { 19 + await q.deletePendingById(app.env.DB, input.requestId, userDid); 20 + } 21 + 22 + return json({ granted: true }); 23 + }, 24 + }; 25 + }
+32
apps/relay/src/xrpc/linkChannel.ts
··· 1 + import { ToolsAtmoNotifsLinkChannel } from '@atmo/notifs-lexicons'; 2 + import { json, type ProcedureConfig } from '@atcute/xrpc-server'; 3 + 4 + import { verifyUserRequest } from '../auth/user'; 5 + import * as q from '../db/queries'; 6 + import type { AppContext } from '../env'; 7 + import { newLinkToken } from '../lib/ids'; 8 + import { addMinutes, now } from '../lib/time'; 9 + 10 + const LXM = 'tools.atmo.notifs.linkChannel'; 11 + 12 + export function makeLinkChannel( 13 + app: AppContext, 14 + ): ProcedureConfig<ToolsAtmoNotifsLinkChannel.mainSchema> { 15 + return { 16 + handler: async ({ request, input }) => { 17 + const { userDid } = await verifyUserRequest(app.verifier, request, LXM); 18 + 19 + await q.ensureUser(app.env.DB, userDid, now()); 20 + const token = newLinkToken(); 21 + await q.insertLinkToken(app.env.DB, { 22 + token, 23 + did: userDid, 24 + platform: input.platform, 25 + expiresAt: addMinutes(now(), 10), 26 + }); 27 + 28 + const deepLink = `https://t.me/${app.env.BOT_USERNAME}?start=${token}`; 29 + return json({ token, deepLink }); 30 + }, 31 + }; 32 + }
+28
apps/relay/src/xrpc/listChannels.ts
··· 1 + import { ToolsAtmoNotifsListChannels } from '@atmo/notifs-lexicons'; 2 + import { json, type QueryConfig } from '@atcute/xrpc-server'; 3 + 4 + import { verifyUserRequest } from '../auth/user'; 5 + import * as q from '../db/queries'; 6 + import type { AppContext } from '../env'; 7 + import { toIsoDatetime } from '../lib/time'; 8 + 9 + const LXM = 'tools.atmo.notifs.listChannels'; 10 + 11 + export function makeListChannels( 12 + app: AppContext, 13 + ): QueryConfig<ToolsAtmoNotifsListChannels.mainSchema> { 14 + return { 15 + handler: async ({ request }) => { 16 + const { userDid } = await verifyUserRequest(app.verifier, request, LXM); 17 + const rows = await q.listChannelsForDid(app.env.DB, userDid); 18 + 19 + return json({ 20 + channels: rows.map((row) => ({ 21 + platform: row.platform, 22 + linkedAt: toIsoDatetime(row.linked_at), 23 + displayName: row.display_name ?? undefined, 24 + })), 25 + }); 26 + }, 27 + }; 28 + }
+29
apps/relay/src/xrpc/listGrants.ts
··· 1 + import { ToolsAtmoNotifsListGrants } from '@atmo/notifs-lexicons'; 2 + import { json, type QueryConfig } from '@atcute/xrpc-server'; 3 + 4 + import { verifyUserRequest } from '../auth/user'; 5 + import * as q from '../db/queries'; 6 + import type { AppContext } from '../env'; 7 + import { toIsoDatetime } from '../lib/time'; 8 + 9 + const LXM = 'tools.atmo.notifs.listGrants'; 10 + 11 + export function makeListGrants(app: AppContext): QueryConfig<ToolsAtmoNotifsListGrants.mainSchema> { 12 + return { 13 + handler: async ({ request }) => { 14 + const { userDid } = await verifyUserRequest(app.verifier, request, LXM); 15 + const rows = await q.listGrantsForRecipient(app.env.DB, userDid); 16 + 17 + return json({ 18 + grants: rows.map((row) => ({ 19 + sender: row.sender_did, 20 + senderHandle: row.handle ?? undefined, 21 + senderDisplayName: row.display_name ?? undefined, 22 + senderAvatar: row.avatar_url ?? undefined, 23 + grantedAt: toIsoDatetime(row.granted_at), 24 + muted: row.muted === 1, 25 + })), 26 + }); 27 + }, 28 + }; 29 + }
+33
apps/relay/src/xrpc/listPending.ts
··· 1 + import { ToolsAtmoNotifsListPending } from '@atmo/notifs-lexicons'; 2 + import { json, type QueryConfig } from '@atcute/xrpc-server'; 3 + 4 + import { verifyUserRequest } from '../auth/user'; 5 + import * as q from '../db/queries'; 6 + import type { AppContext } from '../env'; 7 + import { now, toIsoDatetime } from '../lib/time'; 8 + 9 + const LXM = 'tools.atmo.notifs.listPending'; 10 + 11 + export function makeListPending( 12 + app: AppContext, 13 + ): QueryConfig<ToolsAtmoNotifsListPending.mainSchema> { 14 + return { 15 + handler: async ({ request }) => { 16 + const { userDid } = await verifyUserRequest(app.verifier, request, LXM); 17 + const rows = await q.listPendingForRecipient(app.env.DB, userDid, now()); 18 + 19 + return json({ 20 + pending: rows.map((row) => ({ 21 + id: row.id, 22 + sender: row.sender_did, 23 + senderHandle: row.handle ?? undefined, 24 + senderDisplayName: row.display_name ?? undefined, 25 + senderAvatar: row.avatar_url ?? undefined, 26 + reason: row.reason ?? undefined, 27 + createdAt: toIsoDatetime(row.created_at), 28 + expiresAt: toIsoDatetime(row.expires_at), 29 + })), 30 + }); 31 + }, 32 + }; 33 + }
+22
apps/relay/src/xrpc/muteGrant.ts
··· 1 + import { ToolsAtmoNotifsMuteGrant } from '@atmo/notifs-lexicons'; 2 + import { json, type ProcedureConfig } from '@atcute/xrpc-server'; 3 + 4 + import { verifyUserRequest } from '../auth/user'; 5 + import * as q from '../db/queries'; 6 + import type { AppContext } from '../env'; 7 + 8 + const LXM = 'tools.atmo.notifs.muteGrant'; 9 + 10 + export function makeMuteGrant( 11 + app: AppContext, 12 + ): ProcedureConfig<ToolsAtmoNotifsMuteGrant.mainSchema> { 13 + return { 14 + handler: async ({ request, input }) => { 15 + const { userDid } = await verifyUserRequest(app.verifier, request, LXM); 16 + 17 + await q.setGrantMuted(app.env.DB, userDid, input.sender, input.muted); 18 + 19 + return json({ muted: input.muted }); 20 + }, 21 + }; 22 + }
+113
apps/relay/src/xrpc/requestPermission.ts
··· 1 + import { ToolsAtmoNotifsRequestPermission } from '@atmo/notifs-lexicons'; 2 + import type { Did } from '@atcute/lexicons'; 3 + import { json, type ProcedureConfig } from '@atcute/xrpc-server'; 4 + 5 + import { verifySenderRequest } from '../auth/sender'; 6 + import * as q from '../db/queries'; 7 + import type { AppContext } from '../env'; 8 + import { rateLimited } from '../lib/errors'; 9 + import { newId } from '../lib/ids'; 10 + import { addDays, now } from '../lib/time'; 11 + import { ensureSenderProfile } from '../profile/fetch'; 12 + import { checkAndIncrement } from '../ratelimit'; 13 + 14 + const LXM = 'tools.atmo.notifs.requestPermission'; 15 + 16 + // Per-sender cap on new pending requests: 100 per rolling hour. 17 + const REQ_LIMIT = 100; 18 + const REQ_WINDOW_SECONDS = 60 * 60; 19 + 20 + export function makeRequestPermission( 21 + app: AppContext, 22 + ): ProcedureConfig<ToolsAtmoNotifsRequestPermission.mainSchema> { 23 + return { 24 + handler: async ({ request, input }) => { 25 + const { senderDid } = await verifySenderRequest(app.verifier, request, LXM); 26 + const recipient = input.recipient; 27 + 28 + // 2. Already granted? Return a stable id and the alreadyGranted status. 29 + const existingGrant = await q.getGrant(app.env.DB, recipient, senderDid); 30 + if (existingGrant !== null) { 31 + return json({ id: pseudoGrantId(recipient, senderDid), status: 'alreadyGranted' }); 32 + } 33 + 34 + // 3. Per-pair pending cap: reuse a live pending request instead of inserting a duplicate. 35 + const existingPending = await q.getPendingByPair(app.env.DB, recipient, senderDid); 36 + if (existingPending !== null && existingPending.expires_at > now()) { 37 + return json({ id: existingPending.id, status: 'pending' }); 38 + } 39 + 40 + // 4. Per-sender global rate limit. 41 + const rl = await checkAndIncrement( 42 + app.env.CACHE, 43 + `rl:req:${senderDid}`, 44 + REQ_LIMIT, 45 + REQ_WINDOW_SECONDS, 46 + ); 47 + if (!rl.allowed) { 48 + throw rateLimited(rl.resetIn, 'Too many permission requests; try again later'); 49 + } 50 + 51 + // 5. Insert the pending request (replacing any stale/expired row for the pair 52 + // so the UNIQUE(recipient_did, sender_did) constraint holds). 53 + if (existingPending !== null) { 54 + await q.deletePendingByPair(app.env.DB, recipient, senderDid); 55 + } 56 + const id = newId(); 57 + const reason = input.reason ?? null; 58 + const createdAt = now(); 59 + await q.insertPending(app.env.DB, { 60 + id, 61 + recipientDid: recipient, 62 + senderDid, 63 + reason, 64 + createdAt, 65 + expiresAt: addDays(createdAt, 7), 66 + }); 67 + 68 + // 6. Fire-and-forget: refresh the sender profile cache. 69 + app.ctx.waitUntil(ensureSenderProfile(app.env, senderDid)); 70 + // 7. Fire-and-forget: optionally notify the recipient on Telegram. 71 + app.ctx.waitUntil(maybeNotifyPending(app, recipient, senderDid, id, reason)); 72 + 73 + return json({ id, status: 'pending' }); 74 + }, 75 + }; 76 + } 77 + 78 + /** A stable, opaque id for the alreadyGranted case (no pending row exists). */ 79 + function pseudoGrantId(recipient: Did, senderDid: Did): string { 80 + return `granted:${recipient}:${senderDid}`; 81 + } 82 + 83 + /** 84 + * If the recipient linked Telegram and opted into pending-request alerts, enqueue 85 + * a `pendingRequest` job so they can approve/deny from the chat. 86 + */ 87 + async function maybeNotifyPending( 88 + app: AppContext, 89 + recipient: Did, 90 + senderDid: Did, 91 + requestId: string, 92 + reason: string | null, 93 + ): Promise<void> { 94 + const user = await q.getUser(app.env.DB, recipient); 95 + if (user === null || user.notify_pending_via_telegram !== 1) { 96 + return; 97 + } 98 + const channels = await q.listChannelsForDid(app.env.DB, recipient); 99 + const telegram = channels.find((channel) => channel.platform === 'telegram'); 100 + if (telegram === undefined) { 101 + return; 102 + } 103 + 104 + const sender = await q.getSender(app.env.DB, senderDid); 105 + await app.env.DISPATCH_QUEUE.send({ 106 + kind: 'pendingRequest', 107 + channel: { platform: 'telegram', platformUserId: telegram.platform_user_id }, 108 + requestId, 109 + senderHandle: sender?.handle ?? senderDid, 110 + senderDisplayName: sender?.display_name ?? undefined, 111 + reason: reason ?? undefined, 112 + }); 113 + }
+22
apps/relay/src/xrpc/revoke.ts
··· 1 + import { ToolsAtmoNotifsRevoke } from '@atmo/notifs-lexicons'; 2 + import { json, type ProcedureConfig } from '@atcute/xrpc-server'; 3 + 4 + import { verifyUserRequest } from '../auth/user'; 5 + import * as q from '../db/queries'; 6 + import type { AppContext } from '../env'; 7 + 8 + const LXM = 'tools.atmo.notifs.revoke'; 9 + 10 + export function makeRevoke(app: AppContext): ProcedureConfig<ToolsAtmoNotifsRevoke.mainSchema> { 11 + return { 12 + handler: async ({ request, input }) => { 13 + const { userDid } = await verifyUserRequest(app.verifier, request, LXM); 14 + 15 + const revoked = await q.deleteGrant(app.env.DB, userDid, input.sender); 16 + // The pending request (if any) for this pair is now irrelevant. 17 + await q.deletePendingByPair(app.env.DB, userDid, input.sender); 18 + 19 + return json({ revoked }); 20 + }, 21 + }; 22 + }
+107
apps/relay/src/xrpc/send.ts
··· 1 + import { ToolsAtmoNotifsSend } from '@atmo/notifs-lexicons'; 2 + import type { Did } from '@atcute/lexicons'; 3 + import { json, type ProcedureConfig } from '@atcute/xrpc-server'; 4 + 5 + import { verifySenderRequest } from '../auth/sender'; 6 + import * as q from '../db/queries'; 7 + import type { AppContext, DispatchJob } from '../env'; 8 + import { notAuthorized, rateLimited } from '../lib/errors'; 9 + import { newId } from '../lib/ids'; 10 + import { now } from '../lib/time'; 11 + import { checkAndIncrement } from '../ratelimit'; 12 + 13 + const LXM = 'tools.atmo.notifs.send'; 14 + 15 + // Per-pair limits: at most 1 notification/second and 100/day. 16 + const PER_SECOND_LIMIT = 1; 17 + const PER_SECOND_WINDOW = 1; 18 + const PER_DAY_LIMIT = 100; 19 + const PER_DAY_WINDOW = 24 * 60 * 60; 20 + 21 + export function makeSend(app: AppContext): ProcedureConfig<ToolsAtmoNotifsSend.mainSchema> { 22 + return { 23 + handler: async ({ request, input }) => { 24 + const { senderDid } = await verifySenderRequest(app.verifier, request, LXM); 25 + const recipient = input.recipient; 26 + const id = newId(); 27 + 28 + // 2. Must have an active grant. Muted grants accept silently (delivered: 0). 29 + const grant = await q.getGrant(app.env.DB, recipient, senderDid); 30 + if (grant === null) { 31 + throw notAuthorized(); 32 + } 33 + if (grant.muted === 1) { 34 + await logDelivery(app, id, recipient, senderDid, input.title, 0); 35 + return json({ id, delivered: 0 }); 36 + } 37 + 38 + // 3. Rate limits (per recipient+sender pair). 39 + const perSecond = await checkAndIncrement( 40 + app.env.CACHE, 41 + `rl:send:1s:${senderDid}:${recipient}`, 42 + PER_SECOND_LIMIT, 43 + PER_SECOND_WINDOW, 44 + ); 45 + if (!perSecond.allowed) { 46 + throw rateLimited(perSecond.resetIn, 'Sending too fast'); 47 + } 48 + const perDay = await checkAndIncrement( 49 + app.env.CACHE, 50 + `rl:send:1d:${senderDid}:${recipient}`, 51 + PER_DAY_LIMIT, 52 + PER_DAY_WINDOW, 53 + ); 54 + if (!perDay.allowed) { 55 + throw rateLimited(perDay.resetIn, 'Daily notification limit reached for this recipient'); 56 + } 57 + 58 + // 4. No linked channels → accept but deliver to nobody. 59 + const channels = await q.listChannelsForDid(app.env.DB, recipient); 60 + if (channels.length === 0) { 61 + await logDelivery(app, id, recipient, senderDid, input.title, 0); 62 + return json({ id, delivered: 0 }); 63 + } 64 + 65 + // 5. Enqueue one dispatch job per channel. 66 + const jobs = channels 67 + .filter((channel) => channel.platform === 'telegram') 68 + .map( 69 + (channel): { body: DispatchJob } => ({ 70 + body: { 71 + kind: 'notification', 72 + channel: { platform: 'telegram', platformUserId: channel.platform_user_id }, 73 + title: input.title, 74 + body: input.body, 75 + uri: input.uri, 76 + senderDid, 77 + }, 78 + }), 79 + ); 80 + if (jobs.length > 0) { 81 + await app.env.DISPATCH_QUEUE.sendBatch(jobs); 82 + } 83 + 84 + // 6. Record the delivery. 85 + await logDelivery(app, id, recipient, senderDid, input.title, channels.length); 86 + return json({ id, delivered: channels.length }); 87 + }, 88 + }; 89 + } 90 + 91 + function logDelivery( 92 + app: AppContext, 93 + id: string, 94 + recipient: Did, 95 + senderDid: Did, 96 + title: string, 97 + deliveredCount: number, 98 + ): Promise<void> { 99 + return q.insertDeliveryLog(app.env.DB, { 100 + id, 101 + recipientDid: recipient, 102 + senderDid, 103 + title, 104 + deliveredCount, 105 + createdAt: now(), 106 + }); 107 + }
+22
apps/relay/src/xrpc/unlinkChannel.ts
··· 1 + import { ToolsAtmoNotifsUnlinkChannel } from '@atmo/notifs-lexicons'; 2 + import { json, type ProcedureConfig } from '@atcute/xrpc-server'; 3 + 4 + import { verifyUserRequest } from '../auth/user'; 5 + import * as q from '../db/queries'; 6 + import type { AppContext } from '../env'; 7 + 8 + const LXM = 'tools.atmo.notifs.unlinkChannel'; 9 + 10 + export function makeUnlinkChannel( 11 + app: AppContext, 12 + ): ProcedureConfig<ToolsAtmoNotifsUnlinkChannel.mainSchema> { 13 + return { 14 + handler: async ({ request, input }) => { 15 + const { userDid } = await verifyUserRequest(app.verifier, request, LXM); 16 + 17 + const unlinked = await q.deleteChannel(app.env.DB, userDid, input.platform); 18 + 19 + return json({ unlinked }); 20 + }, 21 + }; 22 + }
+30
apps/relay/src/xrpc/updateSettings.ts
··· 1 + import { ToolsAtmoNotifsUpdateSettings } from '@atmo/notifs-lexicons'; 2 + import { json, type ProcedureConfig } from '@atcute/xrpc-server'; 3 + 4 + import { verifyUserRequest } from '../auth/user'; 5 + import * as q from '../db/queries'; 6 + import type { AppContext } from '../env'; 7 + import { now } from '../lib/time'; 8 + 9 + const LXM = 'tools.atmo.notifs.updateSettings'; 10 + 11 + export function makeUpdateSettings( 12 + app: AppContext, 13 + ): ProcedureConfig<ToolsAtmoNotifsUpdateSettings.mainSchema> { 14 + return { 15 + handler: async ({ request, input }) => { 16 + const { userDid } = await verifyUserRequest(app.verifier, request, LXM); 17 + await q.ensureUser(app.env.DB, userDid, now()); 18 + 19 + // Partial PATCH: only touch fields present in the input. 20 + if (input.notifyPendingViaTelegram !== undefined) { 21 + await q.setNotifyPending(app.env.DB, userDid, input.notifyPendingViaTelegram); 22 + } 23 + 24 + const user = await q.getUser(app.env.DB, userDid); 25 + return json({ 26 + notifyPendingViaTelegram: (user?.notify_pending_via_telegram ?? 0) === 1, 27 + }); 28 + }, 29 + }; 30 + }
+5
apps/relay/test/apply-migrations.ts
··· 1 + import { applyD1Migrations, env } from 'cloudflare:test'; 2 + 3 + // Runs once per test file (before the per-test storage isolation snapshot), so 4 + // every test sees a fully-migrated, empty database. 5 + await applyD1Migrations(env.DB, env.TEST_MIGRATIONS);
+71
apps/relay/test/auth.test.ts
··· 1 + import { createExecutionContext, env, waitOnExecutionContext } from 'cloudflare:test'; 2 + import { beforeAll, expect, it } from 'vitest'; 3 + 4 + import worker from '../src/index'; 5 + 6 + import { installFetchMock, makeIdentity, makeJwt, mockPlc, mockPlcNotFound, xrpcGet } from './helpers'; 7 + 8 + beforeAll(() => { 9 + installFetchMock(); 10 + }); 11 + 12 + const GET_SETTINGS = 'tools.atmo.notifs.getSettings'; 13 + 14 + async function call(req: Request): Promise<Response> { 15 + const ctx = createExecutionContext(); 16 + const res = await worker.fetch(req, env, ctx); 17 + await waitOnExecutionContext(ctx); 18 + return res; 19 + } 20 + 21 + it('accepts a valid user JWT (happy path)', async () => { 22 + const user = await makeIdentity('did:plc:authhappy'); 23 + mockPlc(user); 24 + const jwt = await makeJwt(user, { lxm: GET_SETTINGS }); 25 + 26 + const res = await call(xrpcGet(GET_SETTINGS, jwt)); 27 + 28 + expect(res.status).toBe(200); 29 + expect(await res.json()).toEqual({ notifyPendingViaTelegram: false }); 30 + }); 31 + 32 + it('rejects an expired JWT', async () => { 33 + const user = await makeIdentity('did:plc:authexpired'); 34 + mockPlc(user); 35 + const jwt = await makeJwt(user, { lxm: GET_SETTINGS, expiresIn: -120 }); 36 + 37 + const res = await call(xrpcGet(GET_SETTINGS, jwt)); 38 + 39 + expect(res.status).toBe(401); 40 + }); 41 + 42 + it('rejects a JWT addressed to the wrong audience', async () => { 43 + const user = await makeIdentity('did:plc:authaud'); 44 + mockPlc(user); 45 + const jwt = await makeJwt(user, { lxm: GET_SETTINGS, audience: 'did:web:evil.example' }); 46 + 47 + const res = await call(xrpcGet(GET_SETTINGS, jwt)); 48 + 49 + expect(res.status).toBe(401); 50 + }); 51 + 52 + it('rejects a JWT scoped to a different lexicon method', async () => { 53 + const user = await makeIdentity('did:plc:authlxm'); 54 + mockPlc(user); 55 + // Token authorizes `send`, but we call `getSettings`. 56 + const jwt = await makeJwt(user, { lxm: 'tools.atmo.notifs.send' }); 57 + 58 + const res = await call(xrpcGet(GET_SETTINGS, jwt)); 59 + 60 + expect(res.status).toBe(401); 61 + }); 62 + 63 + it('rejects a JWT from an unresolvable DID', async () => { 64 + const user = await makeIdentity('did:plc:authunknown'); 65 + mockPlcNotFound(user.did); 66 + const jwt = await makeJwt(user, { lxm: GET_SETTINGS }); 67 + 68 + const res = await call(xrpcGet(GET_SETTINGS, jwt)); 69 + 70 + expect(res.status).toBe(401); 71 + });
+11
apps/relay/test/env.d.ts
··· 1 + import type { D1Migration } from '@cloudflare/vitest-pool-workers'; 2 + 3 + import type { Env } from '../src/env.ts'; 4 + 5 + // Augments the `cloudflare:test` module's `env` with our bindings plus the 6 + // migrations array injected by vitest.config.ts. 7 + declare module 'cloudflare:test' { 8 + interface ProvidedEnv extends Env { 9 + TEST_MIGRATIONS: D1Migration[]; 10 + } 11 + }
+55
apps/relay/test/grant.test.ts
··· 1 + import type { Did } from '@atcute/lexicons'; 2 + import { createExecutionContext, env, waitOnExecutionContext } from 'cloudflare:test'; 3 + import { beforeAll, expect, it } from 'vitest'; 4 + 5 + import * as q from '../src/db/queries'; 6 + import worker from '../src/index'; 7 + 8 + import { installFetchMock, makeIdentity, makeJwt, mockPlc, xrpcPost } from './helpers'; 9 + 10 + beforeAll(() => { 11 + installFetchMock(); 12 + }); 13 + 14 + const SENDER: Did = 'did:plc:grantsender'; 15 + 16 + async function call(req: Request): Promise<Response> { 17 + const ctx = createExecutionContext(); 18 + const res = await worker.fetch(req, env, ctx); 19 + await waitOnExecutionContext(ctx); 20 + return res; 21 + } 22 + 23 + it('grant inserts a row and consumes the pending request', async () => { 24 + const user = await makeIdentity('did:plc:grantuser'); 25 + mockPlc(user); 26 + await q.insertPending(env.DB, { 27 + id: 'req-1', 28 + recipientDid: user.did, 29 + senderDid: SENDER, 30 + reason: null, 31 + createdAt: Date.now(), 32 + expiresAt: Date.now() + 1_000_000, 33 + }); 34 + 35 + const jwt = await makeJwt(user, { lxm: 'tools.atmo.notifs.grant' }); 36 + const res = await call(xrpcPost('tools.atmo.notifs.grant', jwt, { sender: SENDER, requestId: 'req-1' })); 37 + 38 + expect(res.status).toBe(200); 39 + expect(await res.json()).toEqual({ granted: true }); 40 + expect(await q.getGrant(env.DB, user.did, SENDER)).not.toBeNull(); 41 + expect(await q.getPendingById(env.DB, 'req-1')).toBeNull(); 42 + }); 43 + 44 + it('revoke removes the grant', async () => { 45 + const user = await makeIdentity('did:plc:revokeuser'); 46 + mockPlc(user); 47 + await q.upsertGrant(env.DB, user.did, SENDER, Date.now()); 48 + 49 + const jwt = await makeJwt(user, { lxm: 'tools.atmo.notifs.revoke' }); 50 + const res = await call(xrpcPost('tools.atmo.notifs.revoke', jwt, { sender: SENDER })); 51 + 52 + expect(res.status).toBe(200); 53 + expect(await res.json()).toEqual({ revoked: true }); 54 + expect(await q.getGrant(env.DB, user.did, SENDER)).toBeNull(); 55 + });
+159
apps/relay/test/helpers.ts
··· 1 + import { P256PrivateKeyExportable } from '@atcute/crypto'; 2 + import type { Did, Nsid } from '@atcute/lexicons'; 3 + import { createServiceJwt } from '@atcute/xrpc-server/auth'; 4 + 5 + export const RELAY_DID = 'did:web:notifs.atmo.tools'; 6 + 7 + // --------------------------------------------------------------------------- 8 + // Outbound fetch mocking 9 + // 10 + // This pool-workers version doesn't expose `fetchMock` on `cloudflare:test`, so 11 + // we stub the global `fetch`. The worker runs in the same isolate as the tests, 12 + // so the stub applies to it too. We install one stable stub function (resolvers 13 + // capture `fetch` at construction time) and mutate a route list per test. 14 + // --------------------------------------------------------------------------- 15 + 16 + interface FetchRoute { 17 + match: (url: URL) => boolean; 18 + respond: (url: URL) => Response; 19 + } 20 + 21 + let routes: FetchRoute[] = []; 22 + let installed = false; 23 + 24 + export function installFetchMock(): void { 25 + routes = []; 26 + if (installed) { 27 + return; 28 + } 29 + installed = true; 30 + globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => { 31 + const href = 32 + typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; 33 + const url = new URL(href); 34 + for (const route of routes) { 35 + if (route.match(url)) { 36 + return route.respond(url); 37 + } 38 + } 39 + throw new Error(`unmocked fetch: ${url.href}`); 40 + }) as typeof fetch; 41 + } 42 + 43 + function jsonResponse(status: number, body: unknown): Response { 44 + return new Response(JSON.stringify(body), { 45 + status, 46 + headers: { 'content-type': 'application/json' }, 47 + }); 48 + } 49 + 50 + function plcPath(did: string): string { 51 + // PlcDidDocumentResolver requests `/${encodeURIComponent(did)}`. 52 + return `/${did}`; 53 + } 54 + 55 + export interface TestIdentity { 56 + did: Did; 57 + keypair: P256PrivateKeyExportable; 58 + didDoc: unknown; 59 + } 60 + 61 + /** Create a fresh did:plc identity with a P-256 signing key and matching DID doc. */ 62 + export async function makeIdentity(did: string): Promise<TestIdentity> { 63 + const keypair = await P256PrivateKeyExportable.createKeypair(); 64 + const multikey = await keypair.exportPublicKey('multikey'); 65 + const didDoc = { 66 + '@context': ['https://www.w3.org/ns/did/v1', 'https://w3id.org/security/multikey/v1'], 67 + id: did, 68 + verificationMethod: [ 69 + { 70 + id: `${did}#atproto`, 71 + type: 'Multikey', 72 + controller: did, 73 + publicKeyMultibase: multikey, 74 + }, 75 + ], 76 + service: [ 77 + { 78 + id: '#atproto_pds', 79 + type: 'AtprotoPersonalDataServer', 80 + serviceEndpoint: 'https://pds.invalid', 81 + }, 82 + ], 83 + }; 84 + return { did: did as Did, keypair, didDoc }; 85 + } 86 + 87 + /** Resolve this identity's DID document from PLC. */ 88 + export function mockPlc(identity: TestIdentity): void { 89 + routes.push({ 90 + match: (url) => 91 + url.hostname === 'plc.directory' && decodeURIComponent(url.pathname) === plcPath(identity.did), 92 + respond: () => jsonResponse(200, identity.didDoc), 93 + }); 94 + } 95 + 96 + /** Make PLC resolution for a DID return 404. */ 97 + export function mockPlcNotFound(did: string): void { 98 + routes.push({ 99 + match: (url) => url.hostname === 'plc.directory' && decodeURIComponent(url.pathname) === plcPath(did), 100 + respond: () => jsonResponse(404, { message: 'not found' }), 101 + }); 102 + } 103 + 104 + /** Accept any Telegram Bot API call with a successful stub response. */ 105 + export function mockTelegramOk(): void { 106 + routes.push({ 107 + match: (url) => url.hostname === 'api.telegram.org', 108 + respond: () => jsonResponse(200, { ok: true, result: { message_id: 1 } }), 109 + }); 110 + } 111 + 112 + /** Stub the AppView profile fetch for any actor. */ 113 + export function makeBskyProfileMock( 114 + profile: { handle?: string; displayName?: string; avatar?: string } = { 115 + handle: 'sender.example', 116 + displayName: 'Test Sender', 117 + }, 118 + ): void { 119 + routes.push({ 120 + match: (url) => url.hostname === 'public.api.bsky.app', 121 + respond: () => jsonResponse(200, profile), 122 + }); 123 + } 124 + 125 + export interface JwtOptions { 126 + lxm: Nsid; 127 + audience?: string; 128 + expiresIn?: number; 129 + } 130 + 131 + export function makeJwt(identity: TestIdentity, options: JwtOptions): Promise<string> { 132 + return createServiceJwt({ 133 + keypair: identity.keypair, 134 + issuer: identity.did, 135 + audience: (options.audience ?? RELAY_DID) as Did, 136 + lxm: options.lxm, 137 + expiresIn: options.expiresIn ?? 60, 138 + }); 139 + } 140 + 141 + /** Build an XRPC POST request with a bearer token and JSON body. */ 142 + export function xrpcPost(nsid: string, jwt: string, body: unknown): Request { 143 + return new Request(`https://notifs.atmo.tools/xrpc/${nsid}`, { 144 + method: 'POST', 145 + headers: { 146 + 'content-type': 'application/json', 147 + authorization: `Bearer ${jwt}`, 148 + }, 149 + body: JSON.stringify(body), 150 + }); 151 + } 152 + 153 + /** Build an XRPC GET (query) request with a bearer token. */ 154 + export function xrpcGet(nsid: string, jwt: string): Request { 155 + return new Request(`https://notifs.atmo.tools/xrpc/${nsid}`, { 156 + method: 'GET', 157 + headers: { authorization: `Bearer ${jwt}` }, 158 + }); 159 + }
+38
apps/relay/test/ratelimit.test.ts
··· 1 + import { env } from 'cloudflare:test'; 2 + import { expect, it, vi } from 'vitest'; 3 + 4 + import { checkAndIncrement } from '../src/ratelimit'; 5 + 6 + it('allows requests under the limit and denies those over it', async () => { 7 + const key = 'rl:test:underover'; 8 + 9 + const first = await checkAndIncrement(env.CACHE, key, 2, 60); 10 + expect(first.allowed).toBe(true); 11 + expect(first.remaining).toBe(1); 12 + 13 + const second = await checkAndIncrement(env.CACHE, key, 2, 60); 14 + expect(second.allowed).toBe(true); 15 + expect(second.remaining).toBe(0); 16 + 17 + const third = await checkAndIncrement(env.CACHE, key, 2, 60); 18 + expect(third.allowed).toBe(false); 19 + expect(third.remaining).toBe(0); 20 + }); 21 + 22 + it('resets once the logical window has elapsed', async () => { 23 + vi.useFakeTimers(); 24 + try { 25 + const t0 = Date.now(); 26 + vi.setSystemTime(t0); 27 + const key = 'rl:test:reset'; 28 + 29 + expect((await checkAndIncrement(env.CACHE, key, 1, 60)).allowed).toBe(true); 30 + expect((await checkAndIncrement(env.CACHE, key, 1, 60)).allowed).toBe(false); 31 + 32 + // Advance past the window end; the counter should reset. 33 + vi.setSystemTime(t0 + 61_000); 34 + expect((await checkAndIncrement(env.CACHE, key, 1, 60)).allowed).toBe(true); 35 + } finally { 36 + vi.useRealTimers(); 37 + } 38 + });
+94
apps/relay/test/requestPermission.test.ts
··· 1 + import type { Did } from '@atcute/lexicons'; 2 + import { createExecutionContext, env, waitOnExecutionContext } from 'cloudflare:test'; 3 + import { beforeAll, expect, it } from 'vitest'; 4 + 5 + import * as q from '../src/db/queries'; 6 + import worker from '../src/index'; 7 + 8 + import { 9 + installFetchMock, 10 + makeBskyProfileMock, 11 + makeIdentity, 12 + makeJwt, 13 + mockPlc, 14 + xrpcPost, 15 + } from './helpers'; 16 + 17 + beforeAll(() => { 18 + installFetchMock(); 19 + // Sender profile refresh runs fire-and-forget; stub it so it never hits network. 20 + makeBskyProfileMock(); 21 + }); 22 + 23 + const REQ = 'tools.atmo.notifs.requestPermission'; 24 + const RECIPIENT: Did = 'did:plc:reqrecipient'; 25 + 26 + async function call(req: Request): Promise<Response> { 27 + const ctx = createExecutionContext(); 28 + const res = await worker.fetch(req, env, ctx); 29 + await waitOnExecutionContext(ctx); 30 + return res; 31 + } 32 + 33 + interface RequestResult { 34 + id: string; 35 + status: string; 36 + } 37 + 38 + it('first request creates a pending request', async () => { 39 + const sender = await makeIdentity('did:plc:reqfirst'); 40 + mockPlc(sender); 41 + const jwt = await makeJwt(sender, { lxm: REQ }); 42 + 43 + const res = await call(xrpcPost(REQ, jwt, { recipient: RECIPIENT, reason: 'because' })); 44 + 45 + expect(res.status).toBe(200); 46 + const data = (await res.json()) as RequestResult; 47 + expect(data.status).toBe('pending'); 48 + expect(typeof data.id).toBe('string'); 49 + expect(await q.getPendingByPair(env.DB, RECIPIENT, sender.did)).not.toBeNull(); 50 + }); 51 + 52 + it('a duplicate within the window returns the same pending request', async () => { 53 + const sender = await makeIdentity('did:plc:reqdup'); 54 + mockPlc(sender); 55 + const jwt = await makeJwt(sender, { lxm: REQ }); 56 + 57 + const first = (await (await call(xrpcPost(REQ, jwt, { recipient: RECIPIENT }))).json()) as RequestResult; 58 + const second = (await (await call(xrpcPost(REQ, jwt, { recipient: RECIPIENT }))).json()) as RequestResult; 59 + 60 + expect(second.id).toBe(first.id); 61 + const count = await env.DB.prepare( 62 + 'SELECT COUNT(*) AS c FROM pending_requests WHERE recipient_did = ? AND sender_did = ?', 63 + ) 64 + .bind(RECIPIENT, sender.did) 65 + .first<{ c: number }>(); 66 + expect(count?.c).toBe(1); 67 + }); 68 + 69 + it('returns alreadyGranted when a grant exists', async () => { 70 + const sender = await makeIdentity('did:plc:reqgranted'); 71 + mockPlc(sender); 72 + await q.upsertGrant(env.DB, RECIPIENT, sender.did, Date.now()); 73 + const jwt = await makeJwt(sender, { lxm: REQ }); 74 + 75 + const res = await call(xrpcPost(REQ, jwt, { recipient: RECIPIENT })); 76 + 77 + const data = (await res.json()) as RequestResult; 78 + expect(data.status).toBe('alreadyGranted'); 79 + }); 80 + 81 + it('returns 429 when the per-sender rate limit is exceeded', async () => { 82 + const sender = await makeIdentity('did:plc:reqratelimited'); 83 + mockPlc(sender); 84 + // Seed the hourly counter at the limit so the next request trips it. 85 + await env.CACHE.put(`rl:req:${sender.did}`, '100', { 86 + expirationTtl: 3600, 87 + metadata: { expiresAt: Date.now() + 3_600_000 }, 88 + }); 89 + const jwt = await makeJwt(sender, { lxm: REQ }); 90 + 91 + const res = await call(xrpcPost(REQ, jwt, { recipient: RECIPIENT })); 92 + 93 + expect(res.status).toBe(429); 94 + });
+93
apps/relay/test/send.test.ts
··· 1 + import type { Did } from '@atcute/lexicons'; 2 + import { createExecutionContext, env, waitOnExecutionContext } from 'cloudflare:test'; 3 + import { beforeAll, expect, it } from 'vitest'; 4 + 5 + import * as q from '../src/db/queries'; 6 + import worker from '../src/index'; 7 + 8 + import { installFetchMock, makeIdentity, makeJwt, mockPlc, mockTelegramOk, xrpcPost } from './helpers'; 9 + 10 + beforeAll(() => { 11 + installFetchMock(); 12 + mockTelegramOk(); 13 + }); 14 + 15 + const SEND = 'tools.atmo.notifs.send'; 16 + const RECIPIENT: Did = 'did:plc:sendrecipient'; 17 + 18 + async function call(req: Request): Promise<Response> { 19 + const ctx = createExecutionContext(); 20 + const res = await worker.fetch(req, env, ctx); 21 + await waitOnExecutionContext(ctx); 22 + return res; 23 + } 24 + 25 + function send(jwt: string): Request { 26 + return xrpcPost(SEND, jwt, { recipient: RECIPIENT, title: 'Hello', body: 'World' }); 27 + } 28 + 29 + it('returns 403 when no grant exists', async () => { 30 + const sender = await makeIdentity('did:plc:sendnogrant'); 31 + mockPlc(sender); 32 + const jwt = await makeJwt(sender, { lxm: SEND }); 33 + 34 + const res = await call(send(jwt)); 35 + 36 + expect(res.status).toBe(403); 37 + }); 38 + 39 + it('accepts but delivers to nobody when there is a grant but no channel', async () => { 40 + const sender = await makeIdentity('did:plc:sendnochannel'); 41 + mockPlc(sender); 42 + await q.upsertGrant(env.DB, RECIPIENT, sender.did, Date.now()); 43 + const jwt = await makeJwt(sender, { lxm: SEND }); 44 + 45 + const res = await call(send(jwt)); 46 + 47 + expect(res.status).toBe(200); 48 + expect(await res.json()).toMatchObject({ delivered: 0 }); 49 + }); 50 + 51 + it('enqueues and reports delivered=1 with a linked channel', async () => { 52 + const sender = await makeIdentity('did:plc:sendchannel'); 53 + mockPlc(sender); 54 + await q.upsertGrant(env.DB, RECIPIENT, sender.did, Date.now()); 55 + await q.upsertChannel(env.DB, { 56 + did: RECIPIENT, 57 + platform: 'telegram', 58 + platformUserId: '12345', 59 + displayName: null, 60 + linkedAt: Date.now(), 61 + }); 62 + const jwt = await makeJwt(sender, { lxm: SEND }); 63 + 64 + const res = await call(send(jwt)); 65 + 66 + expect(res.status).toBe(200); 67 + expect(await res.json()).toMatchObject({ delivered: 1 }); 68 + 69 + const log = await env.DB.prepare('SELECT delivered_count FROM delivery_log WHERE recipient_did = ?') 70 + .bind(RECIPIENT) 71 + .first<{ delivered_count: number }>(); 72 + expect(log?.delivered_count).toBe(1); 73 + }); 74 + 75 + it('accepts silently with delivered=0 when the grant is muted', async () => { 76 + const sender = await makeIdentity('did:plc:sendmuted'); 77 + mockPlc(sender); 78 + await q.upsertGrant(env.DB, RECIPIENT, sender.did, Date.now()); 79 + await q.setGrantMuted(env.DB, RECIPIENT, sender.did, true); 80 + await q.upsertChannel(env.DB, { 81 + did: RECIPIENT, 82 + platform: 'telegram', 83 + platformUserId: '54321', 84 + displayName: null, 85 + linkedAt: Date.now(), 86 + }); 87 + const jwt = await makeJwt(sender, { lxm: SEND }); 88 + 89 + const res = await call(send(jwt)); 90 + 91 + expect(res.status).toBe(200); 92 + expect(await res.json()).toMatchObject({ delivered: 0 }); 93 + });
+105
apps/relay/test/telegram-link.test.ts
··· 1 + import type { Did } from '@atcute/lexicons'; 2 + import { createExecutionContext, env, waitOnExecutionContext } from 'cloudflare:test'; 3 + import { beforeAll, expect, it } from 'vitest'; 4 + 5 + import * as q from '../src/db/queries'; 6 + import worker from '../src/index'; 7 + 8 + import { installFetchMock, mockTelegramOk } from './helpers'; 9 + 10 + const SECRET = 'test-webhook-secret'; 11 + 12 + beforeAll(() => { 13 + installFetchMock(); 14 + mockTelegramOk(); 15 + }); 16 + 17 + async function call(req: Request): Promise<Response> { 18 + const ctx = createExecutionContext(); 19 + const res = await worker.fetch(req, env, ctx); 20 + await waitOnExecutionContext(ctx); 21 + return res; 22 + } 23 + 24 + function webhook(update: unknown, secret = SECRET): Request { 25 + return new Request(`https://notifs.atmo.tools/telegram/webhook/${secret}`, { 26 + method: 'POST', 27 + headers: { 'content-type': 'application/json' }, 28 + body: JSON.stringify(update), 29 + }); 30 + } 31 + 32 + it('/start <valid token> links the Telegram channel and consumes the token', async () => { 33 + const did: Did = 'did:plc:tglinkuser'; 34 + await q.insertLinkToken(env.DB, { 35 + token: 'tok-valid', 36 + did, 37 + platform: 'telegram', 38 + expiresAt: Date.now() + 600_000, 39 + }); 40 + 41 + const res = await call( 42 + webhook({ 43 + update_id: 1, 44 + message: { 45 + message_id: 1, 46 + chat: { id: 999, type: 'private' }, 47 + from: { id: 999, username: 'alice' }, 48 + text: '/start tok-valid', 49 + }, 50 + }), 51 + ); 52 + 53 + expect(res.status).toBe(200); 54 + const channel = await q.getChannelByPlatformUser(env.DB, 'telegram', '999'); 55 + expect(channel?.did).toBe(did); 56 + expect(channel?.display_name).toBe('alice'); 57 + expect(await q.getLinkToken(env.DB, 'tok-valid')).toBeNull(); 58 + }); 59 + 60 + it('/start <expired token> does not link', async () => { 61 + const did: Did = 'did:plc:tgexpired'; 62 + await q.insertLinkToken(env.DB, { 63 + token: 'tok-expired', 64 + did, 65 + platform: 'telegram', 66 + expiresAt: Date.now() - 1000, 67 + }); 68 + 69 + const res = await call( 70 + webhook({ 71 + update_id: 2, 72 + message: { 73 + message_id: 2, 74 + chat: { id: 888, type: 'private' }, 75 + from: { id: 888, username: 'bob' }, 76 + text: '/start tok-expired', 77 + }, 78 + }), 79 + ); 80 + 81 + expect(res.status).toBe(200); 82 + expect(await q.getChannelByPlatformUser(env.DB, 'telegram', '888')).toBeNull(); 83 + }); 84 + 85 + it('/start with no argument responds 200 without linking', async () => { 86 + const res = await call( 87 + webhook({ 88 + update_id: 3, 89 + message: { 90 + message_id: 3, 91 + chat: { id: 777, type: 'private' }, 92 + from: { id: 777 }, 93 + text: '/start', 94 + }, 95 + }), 96 + ); 97 + 98 + expect(res.status).toBe(200); 99 + expect(await q.getChannelByPlatformUser(env.DB, 'telegram', '777')).toBeNull(); 100 + }); 101 + 102 + it('rejects a webhook with the wrong secret', async () => { 103 + const res = await call(webhook({ update_id: 4 }, 'wrong-secret')); 104 + expect(res.status).toBe(403); 105 + });
+10
apps/relay/tsconfig.json
··· 1 + { 2 + "extends": "../../tsconfig.base.json", 3 + "compilerOptions": { 4 + "outDir": "./dist", 5 + "rootDir": "./src", 6 + "lib": ["ES2022"], 7 + "types": ["@cloudflare/workers-types"] 8 + }, 9 + "include": ["src/**/*.ts"] 10 + }
+31
apps/relay/vitest.config.ts
··· 1 + import { fileURLToPath } from 'node:url'; 2 + 3 + import { cloudflareTest, readD1Migrations } from '@cloudflare/vitest-pool-workers'; 4 + import { defineConfig } from 'vitest/config'; 5 + 6 + export default defineConfig({ 7 + plugins: [ 8 + // The Workers pool is wired as a Vite plugin (vitest 4 / pool-workers 0.16). 9 + // The callback receives the same config the old `poolOptions.workers` took. 10 + cloudflareTest(async () => { 11 + const migrationsDir = fileURLToPath(new URL('./migrations', import.meta.url)); 12 + const migrations = await readD1Migrations(migrationsDir); 13 + return { 14 + wrangler: { configPath: './wrangler.toml' }, 15 + miniflare: { 16 + bindings: { 17 + // Handed to the setup file to migrate the in-memory D1 before tests. 18 + TEST_MIGRATIONS: migrations, 19 + // Deterministic, non-secret values for tests. 20 + BOT_USERNAME: 'atmonotifsbot', 21 + TELEGRAM_BOT_TOKEN: 'test-bot-token', 22 + TELEGRAM_WEBHOOK_SECRET: 'test-webhook-secret', 23 + }, 24 + }, 25 + }; 26 + }), 27 + ], 28 + test: { 29 + setupFiles: ['./test/apply-migrations.ts'], 30 + }, 31 + });
+42
apps/relay/wrangler.toml
··· 1 + name = "notifs-relay" 2 + main = "src/index.ts" 3 + compatibility_date = "2026-04-01" 4 + compatibility_flags = ["nodejs_compat"] 5 + 6 + # Custom domains bind the whole hostname (no path/wildcard); the Worker's own 7 + # router dispatches paths internally. 8 + routes = [{ pattern = "notifs.atmo.tools", custom_domain = true }] 9 + 10 + [[d1_databases]] 11 + binding = "DB" 12 + database_name = "notifs-relay" 13 + database_id = "c83e2e03-0081-4fc4-b25f-6897a614e169" 14 + migrations_dir = "migrations" 15 + 16 + [[kv_namespaces]] 17 + binding = "CACHE" 18 + id = "8b1baa80a28c468c83da20f3f6dec3b6" 19 + 20 + [[queues.producers]] 21 + binding = "notifs_dispatch" 22 + queue = "notifs-dispatch" 23 + 24 + [[queues.consumers]] 25 + queue = "notifs-dispatch" 26 + max_batch_size = 10 27 + max_batch_timeout = 5 28 + max_retries = 3 29 + 30 + [triggers] 31 + crons = ["0 3 * * *"] 32 + 33 + [observability] 34 + enabled = true 35 + 36 + [vars] 37 + RELAY_DID = "did:web:notifs.atmo.tools" 38 + BOT_USERNAME = "atmo_notify_bot" # e.g. "atmonotifsbot" 39 + 40 + # Secrets set via `wrangler secret put`: 41 + # - TELEGRAM_BOT_TOKEN 42 + # - TELEGRAM_WEBHOOK_SECRET
apps/web/.gitkeep

This is a binary file and will not be displayed.

+20
package.json
··· 1 + { 2 + "name": "atproto-notifs", 3 + "version": "0.0.0", 4 + "private": true, 5 + "packageManager": "pnpm@11.2.2", 6 + "engines": { 7 + "node": ">=22" 8 + }, 9 + "scripts": { 10 + "dev": "pnpm --filter @atmo/notifs-relay dev", 11 + "build": "pnpm -r build", 12 + "test": "pnpm -r test", 13 + "generate": "pnpm --filter @atmo/notifs-lexicons generate", 14 + "db:migrate": "pnpm --filter @atmo/notifs-relay db:migrate", 15 + "typecheck": "tsc -b" 16 + }, 17 + "devDependencies": { 18 + "typescript": "^5.8.3" 19 + } 20 + }
+13
packages/lexicons/lex.config.js
··· 1 + // @atcute/lex-cli configuration. 2 + // 3 + // NOTE: lex-cli loads its config via dynamic `import()`, which only resolves 4 + // `lex.config.js` / `lex.config.ts` (not `.json`). We use `.js` so `pnpm 5 + // generate` works on the project's Node version without TypeScript loader flags. 6 + import { defineLexiconConfig } from '@atcute/lex-cli'; 7 + 8 + export default defineLexiconConfig({ 9 + generate: { 10 + files: ['lexicons/**/*.json'], 11 + outdir: 'src/lexicons/', 12 + }, 13 + });
+22
packages/lexicons/lexicons/tools/atmo/notifs/authSender.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "tools.atmo.notifs.authSender", 4 + "defs": { 5 + "main": { 6 + "type": "permission-set", 7 + "title": "Send notifications", 8 + "detail": "Allow this app to request permission to send you notifications and to deliver them to your linked channels (like Telegram).", 9 + "permissions": [ 10 + { 11 + "type": "permission", 12 + "resource": "rpc", 13 + "inheritAud": true, 14 + "lxm": [ 15 + "tools.atmo.notifs.requestPermission", 16 + "tools.atmo.notifs.send" 17 + ] 18 + } 19 + ] 20 + } 21 + } 22 + }
+31
packages/lexicons/lexicons/tools/atmo/notifs/authUser.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "tools.atmo.notifs.authUser", 4 + "defs": { 5 + "main": { 6 + "type": "permission-set", 7 + "title": "Manage your notifications", 8 + "detail": "Allow this app to view and manage which apps can send you notifications, link delivery channels, and adjust your notification settings.", 9 + "permissions": [ 10 + { 11 + "type": "permission", 12 + "resource": "rpc", 13 + "inheritAud": true, 14 + "lxm": [ 15 + "tools.atmo.notifs.grant", 16 + "tools.atmo.notifs.revoke", 17 + "tools.atmo.notifs.denyPending", 18 + "tools.atmo.notifs.muteGrant", 19 + "tools.atmo.notifs.listGrants", 20 + "tools.atmo.notifs.listPending", 21 + "tools.atmo.notifs.linkChannel", 22 + "tools.atmo.notifs.unlinkChannel", 23 + "tools.atmo.notifs.listChannels", 24 + "tools.atmo.notifs.getSettings", 25 + "tools.atmo.notifs.updateSettings" 26 + ] 27 + } 28 + ] 29 + } 30 + } 31 + }
+30
packages/lexicons/lexicons/tools/atmo/notifs/denyPending.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "tools.atmo.notifs.denyPending", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Delete a pending permission request without granting it. Does not blocklist the sender. User-authenticated.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["requestId"], 13 + "properties": { 14 + "requestId": { "type": "string" } 15 + } 16 + } 17 + }, 18 + "output": { 19 + "encoding": "application/json", 20 + "schema": { 21 + "type": "object", 22 + "required": ["denied"], 23 + "properties": { 24 + "denied": { "type": "boolean" } 25 + } 26 + } 27 + } 28 + } 29 + } 30 + }
+20
packages/lexicons/lexicons/tools/atmo/notifs/getSettings.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "tools.atmo.notifs.getSettings", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get the authenticated user's notification settings. User-authenticated.", 8 + "output": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["notifyPendingViaTelegram"], 13 + "properties": { 14 + "notifyPendingViaTelegram": { "type": "boolean" } 15 + } 16 + } 17 + } 18 + } 19 + } 20 + }
+34
packages/lexicons/lexicons/tools/atmo/notifs/grant.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "tools.atmo.notifs.grant", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Grant a sender permission to notify the authenticated user. User-authenticated.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["sender"], 13 + "properties": { 14 + "sender": { "type": "string", "format": "did" }, 15 + "requestId": { 16 + "type": "string", 17 + "description": "If provided, the matching pending request is consumed." 18 + } 19 + } 20 + } 21 + }, 22 + "output": { 23 + "encoding": "application/json", 24 + "schema": { 25 + "type": "object", 26 + "required": ["granted"], 27 + "properties": { 28 + "granted": { "type": "boolean" } 29 + } 30 + } 31 + } 32 + } 33 + } 34 + }
+31
packages/lexicons/lexicons/tools/atmo/notifs/linkChannel.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "tools.atmo.notifs.linkChannel", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Begin linking a delivery channel. Returns a short-lived token and a deep link the user follows to complete linking. User-authenticated.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["platform"], 13 + "properties": { 14 + "platform": { "type": "string", "enum": ["telegram"] } 15 + } 16 + } 17 + }, 18 + "output": { 19 + "encoding": "application/json", 20 + "schema": { 21 + "type": "object", 22 + "required": ["token", "deepLink"], 23 + "properties": { 24 + "token": { "type": "string" }, 25 + "deepLink": { "type": "string" } 26 + } 27 + } 28 + } 29 + } 30 + } 31 + }
+32
packages/lexicons/lexicons/tools/atmo/notifs/listChannels.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "tools.atmo.notifs.listChannels", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "List the delivery channels linked by the authenticated user. User-authenticated.", 8 + "output": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["channels"], 13 + "properties": { 14 + "channels": { 15 + "type": "array", 16 + "items": { "type": "ref", "ref": "#channelView" } 17 + } 18 + } 19 + } 20 + } 21 + }, 22 + "channelView": { 23 + "type": "object", 24 + "required": ["platform", "linkedAt"], 25 + "properties": { 26 + "platform": { "type": "string" }, 27 + "linkedAt": { "type": "string", "format": "datetime" }, 28 + "displayName": { "type": "string" } 29 + } 30 + } 31 + } 32 + }
+35
packages/lexicons/lexicons/tools/atmo/notifs/listGrants.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "tools.atmo.notifs.listGrants", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "List the senders the authenticated user has granted notification permission to. User-authenticated.", 8 + "output": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["grants"], 13 + "properties": { 14 + "grants": { 15 + "type": "array", 16 + "items": { "type": "ref", "ref": "#grantView" } 17 + } 18 + } 19 + } 20 + } 21 + }, 22 + "grantView": { 23 + "type": "object", 24 + "required": ["sender", "grantedAt", "muted"], 25 + "properties": { 26 + "sender": { "type": "string", "format": "did" }, 27 + "senderHandle": { "type": "string" }, 28 + "senderDisplayName": { "type": "string" }, 29 + "senderAvatar": { "type": "string" }, 30 + "grantedAt": { "type": "string", "format": "datetime" }, 31 + "muted": { "type": "boolean" } 32 + } 33 + } 34 + } 35 + }
+37
packages/lexicons/lexicons/tools/atmo/notifs/listPending.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "tools.atmo.notifs.listPending", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "List pending permission requests awaiting the authenticated user's decision. User-authenticated.", 8 + "output": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["pending"], 13 + "properties": { 14 + "pending": { 15 + "type": "array", 16 + "items": { "type": "ref", "ref": "#pendingView" } 17 + } 18 + } 19 + } 20 + } 21 + }, 22 + "pendingView": { 23 + "type": "object", 24 + "required": ["id", "sender", "createdAt", "expiresAt"], 25 + "properties": { 26 + "id": { "type": "string" }, 27 + "sender": { "type": "string", "format": "did" }, 28 + "senderHandle": { "type": "string" }, 29 + "senderDisplayName": { "type": "string" }, 30 + "senderAvatar": { "type": "string" }, 31 + "reason": { "type": "string" }, 32 + "createdAt": { "type": "string", "format": "datetime" }, 33 + "expiresAt": { "type": "string", "format": "datetime" } 34 + } 35 + } 36 + } 37 + }
+31
packages/lexicons/lexicons/tools/atmo/notifs/muteGrant.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "tools.atmo.notifs.muteGrant", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Mute or unmute an existing grant. Muted senders' notifications are accepted but not delivered. User-authenticated.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["sender", "muted"], 13 + "properties": { 14 + "sender": { "type": "string", "format": "did" }, 15 + "muted": { "type": "boolean" } 16 + } 17 + } 18 + }, 19 + "output": { 20 + "encoding": "application/json", 21 + "schema": { 22 + "type": "object", 23 + "required": ["muted"], 24 + "properties": { 25 + "muted": { "type": "boolean" } 26 + } 27 + } 28 + } 29 + } 30 + } 31 + }
+47
packages/lexicons/lexicons/tools/atmo/notifs/requestPermission.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "tools.atmo.notifs.requestPermission", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Ask a recipient for permission to send them notifications. Sender-authenticated.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["recipient"], 13 + "properties": { 14 + "recipient": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "DID of the user the sender wants to notify." 18 + }, 19 + "reason": { 20 + "type": "string", 21 + "maxLength": 200, 22 + "description": "Human-readable reason shown to the user when approving." 23 + } 24 + } 25 + } 26 + }, 27 + "output": { 28 + "encoding": "application/json", 29 + "schema": { 30 + "type": "object", 31 + "required": ["id", "status"], 32 + "properties": { 33 + "id": { "type": "string" }, 34 + "status": { 35 + "type": "string", 36 + "enum": ["pending", "alreadyGranted"] 37 + } 38 + } 39 + } 40 + }, 41 + "errors": [ 42 + { "name": "NotAuthorized" }, 43 + { "name": "RateLimitExceeded" } 44 + ] 45 + } 46 + } 47 + }
+30
packages/lexicons/lexicons/tools/atmo/notifs/revoke.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "tools.atmo.notifs.revoke", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Revoke a sender's permission to notify the authenticated user. User-authenticated.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["sender"], 13 + "properties": { 14 + "sender": { "type": "string", "format": "did" } 15 + } 16 + } 17 + }, 18 + "output": { 19 + "encoding": "application/json", 20 + "schema": { 21 + "type": "object", 22 + "required": ["revoked"], 23 + "properties": { 24 + "revoked": { "type": "boolean" } 25 + } 26 + } 27 + } 28 + } 29 + } 30 + }
+48
packages/lexicons/lexicons/tools/atmo/notifs/send.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "tools.atmo.notifs.send", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Send a notification to a recipient who has granted the sender permission. Sender-authenticated.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["recipient", "title", "body"], 13 + "properties": { 14 + "recipient": { "type": "string", "format": "did" }, 15 + "title": { "type": "string", "maxLength": 100 }, 16 + "body": { "type": "string", "maxLength": 500 }, 17 + "uri": { 18 + "type": "string", 19 + "description": "Optional link opened when the notification is tapped." 20 + }, 21 + "threadKey": { 22 + "type": "string", 23 + "description": "Optional opaque key for grouping related notifications." 24 + } 25 + } 26 + } 27 + }, 28 + "output": { 29 + "encoding": "application/json", 30 + "schema": { 31 + "type": "object", 32 + "required": ["id", "delivered"], 33 + "properties": { 34 + "id": { "type": "string" }, 35 + "delivered": { 36 + "type": "integer", 37 + "description": "Number of channels the notification was dispatched to." 38 + } 39 + } 40 + } 41 + }, 42 + "errors": [ 43 + { "name": "NotAuthorized" }, 44 + { "name": "RateLimitExceeded" } 45 + ] 46 + } 47 + } 48 + }
+30
packages/lexicons/lexicons/tools/atmo/notifs/unlinkChannel.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "tools.atmo.notifs.unlinkChannel", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Unlink a delivery channel from the authenticated user. User-authenticated.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["platform"], 13 + "properties": { 14 + "platform": { "type": "string", "enum": ["telegram"] } 15 + } 16 + } 17 + }, 18 + "output": { 19 + "encoding": "application/json", 20 + "schema": { 21 + "type": "object", 22 + "required": ["unlinked"], 23 + "properties": { 24 + "unlinked": { "type": "boolean" } 25 + } 26 + } 27 + } 28 + } 29 + } 30 + }
+29
packages/lexicons/lexicons/tools/atmo/notifs/updateSettings.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "tools.atmo.notifs.updateSettings", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Partially update the authenticated user's notification settings. Only fields present in the input are changed. User-authenticated.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "properties": { 13 + "notifyPendingViaTelegram": { "type": "boolean" } 14 + } 15 + } 16 + }, 17 + "output": { 18 + "encoding": "application/json", 19 + "schema": { 20 + "type": "object", 21 + "required": ["notifyPendingViaTelegram"], 22 + "properties": { 23 + "notifyPendingViaTelegram": { "type": "boolean" } 24 + } 25 + } 26 + } 27 + } 28 + } 29 + }
+25
packages/lexicons/package.json
··· 1 + { 2 + "name": "@atmo/notifs-lexicons", 3 + "version": "0.0.0", 4 + "private": true, 5 + "type": "module", 6 + "exports": { 7 + ".": { 8 + "types": "./src/index.ts", 9 + "default": "./src/index.ts" 10 + }, 11 + "./lexicons/*": "./lexicons/*" 12 + }, 13 + "scripts": { 14 + "generate": "lex-cli generate", 15 + "build": "tsc -b", 16 + "test": "echo \"(no tests in lexicons package)\"" 17 + }, 18 + "dependencies": { 19 + "@atcute/lexicons": "^2.0.0" 20 + }, 21 + "devDependencies": { 22 + "@atcute/lex-cli": "^3.1.0", 23 + "typescript": "^5.8.3" 24 + } 25 + }
+11
packages/lexicons/src/index.ts
··· 1 + // Public entrypoint for the shared lexicons package. 2 + // 3 + // Re-exports the code generated by `@atcute/lex-cli` (run `pnpm generate`). 4 + // Each lexicon is exposed as a namespace, e.g. `ToolsAtmoNotifsSend`, carrying: 5 + // - `mainSchema` — the XRPC metadata you pass to `XRPCRouter.add*` 6 + // - `$input` / `$output` / `$params` — inferred request/response types 7 + // - view types for `ref`'d defs (e.g. `GrantView`) 8 + // 9 + // Importing these modules also augments `@atcute/lexicons/ambient`'s 10 + // `XRPCQueries` / `XRPCProcedures` registries with our NSIDs. 11 + export * from './lexicons/index.js';
+8
packages/lexicons/tsconfig.json
··· 1 + { 2 + "extends": "../../tsconfig.base.json", 3 + "compilerOptions": { 4 + "outDir": "./dist", 5 + "rootDir": "./src" 6 + }, 7 + "include": ["src/**/*.ts"] 8 + }
+1988
pnpm-lock.yaml
··· 1 + lockfileVersion: '9.0' 2 + 3 + settings: 4 + autoInstallPeers: true 5 + excludeLinksFromLockfile: false 6 + 7 + importers: 8 + 9 + .: 10 + devDependencies: 11 + typescript: 12 + specifier: ^5.8.3 13 + version: 5.9.3 14 + 15 + apps/relay: 16 + dependencies: 17 + '@atcute/client': 18 + specifier: ^5.0.0 19 + version: 5.0.0(@atcute/lexicons@2.0.0)(typescript@5.9.3) 20 + '@atcute/identity-resolver': 21 + specifier: ^2.0.0 22 + version: 2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@5.9.3))(@atcute/lexicons@2.0.0)(typescript@5.9.3) 23 + '@atcute/lexicons': 24 + specifier: ^2.0.0 25 + version: 2.0.0 26 + '@atcute/xrpc-server': 27 + specifier: ^2.0.0 28 + version: 2.0.0(@atcute/cid@2.4.1)(@atcute/lexicons@2.0.0)(typescript@5.9.3) 29 + '@atmo/notifs-lexicons': 30 + specifier: workspace:* 31 + version: link:../../packages/lexicons 32 + nanoid: 33 + specifier: ^5.1.11 34 + version: 5.1.11 35 + devDependencies: 36 + '@atcute/crypto': 37 + specifier: ^2.4.1 38 + version: 2.4.1 39 + '@cloudflare/vitest-pool-workers': 40 + specifier: ^0.16.9 41 + version: 0.16.9(@cloudflare/workers-types@4.20260522.1)(@vitest/runner@4.1.7)(@vitest/snapshot@4.1.7)(vitest@4.1.7(vite@8.0.14(esbuild@0.27.3))) 42 + '@cloudflare/workers-types': 43 + specifier: ^4.20260522.1 44 + version: 4.20260522.1 45 + typescript: 46 + specifier: ^5.8.3 47 + version: 5.9.3 48 + vitest: 49 + specifier: ^4.1.7 50 + version: 4.1.7(vite@8.0.14(esbuild@0.27.3)) 51 + wrangler: 52 + specifier: ^4.94.0 53 + version: 4.94.0(@cloudflare/workers-types@4.20260522.1) 54 + 55 + packages/lexicons: 56 + dependencies: 57 + '@atcute/lexicons': 58 + specifier: ^2.0.0 59 + version: 2.0.0 60 + devDependencies: 61 + '@atcute/lex-cli': 62 + specifier: ^3.1.0 63 + version: 3.1.0(@atcute/cbor@2.3.3(@atcute/cid@2.4.1))(@atcute/cid@2.4.1)(typescript@5.9.3) 64 + typescript: 65 + specifier: ^5.8.3 66 + version: 5.9.3 67 + 68 + packages: 69 + 70 + '@atcute/car@6.0.0': 71 + resolution: {integrity: sha512-v3lhHfqxBi/wxq3q4N9f3/9ed44xqwKI0VAlgPge9NKjJ2xuJcEvj12v4m+P2FB758cioCsghKy58tz2s20eAA==} 72 + peerDependencies: 73 + '@atcute/cbor': ^2.0.0 74 + '@atcute/cid': ^2.0.0 75 + 76 + '@atcute/cbor@2.3.3': 77 + resolution: {integrity: sha512-zZ4nHOK837zTMWJtta35YD7pcukrTzDc8jkpIGlSgoDYzu3l4BX3WVgpPJtRn3K6h2v97uyiWfiVjSpM7JSFzQ==} 78 + peerDependencies: 79 + '@atcute/cid': ^2.0.0 80 + 81 + '@atcute/cid@2.4.1': 82 + resolution: {integrity: sha512-bwhna69RCv7yetXudtj+2qrMPYvhhIQqvJz6YUpUS98v7OdF3X2dnye9Nig2NDrklZcuyOsu7sQo7GOykJXRLQ==} 83 + 84 + '@atcute/client@5.0.0': 85 + resolution: {integrity: sha512-Dtrc1no1oAtpUTmkBxH0xKSgy8qcnk8orPf/PkEATW+MB3k8FPHtPH2fshiKd55rioZkb+xaN+7A29WtqPQHRA==} 86 + peerDependencies: 87 + '@atcute/lexicons': ^2.0.0 88 + 89 + '@atcute/crypto@2.4.1': 90 + resolution: {integrity: sha512-tJ3Pi/XYcAsABKtqSlSOTKfO5YiQ4XdqlTuPS8HiRZSezOPcXBFFzAFWpSIJPURbVPFQL3LLrrK0Ea24wl5qeQ==} 91 + 92 + '@atcute/identity-resolver@2.0.0': 93 + resolution: {integrity: sha512-IKg1BDQAF2bIdN10DL6KAXmTjK+3enTU2IRbuani9TsFahBwGZ7O5FiVmTiL6QlGfauGNW5S0xNCOxWXWMoR2Q==} 94 + peerDependencies: 95 + '@atcute/identity': ^2.0.0 96 + '@atcute/lexicons': ^2.0.0 97 + 98 + '@atcute/identity@2.0.0': 99 + resolution: {integrity: sha512-YXFsggO7eJYifqkN85+kUXJE2a1iI9AyuzPTDjtS/4WE1Zs1/XiPkWmwZlAgtp+pYhVtjm3mJqy/h/mZ0OnIVw==} 100 + peerDependencies: 101 + '@atcute/lexicons': ^2.0.0 102 + 103 + '@atcute/lex-cli@3.1.0': 104 + resolution: {integrity: sha512-Mba7y12ENIbdj+cr+gt5mJTalpuz/UbS4ykm/wT7t9ipoJtidXMSeuQFfeoyBvvidW77Vg4G6zxZkoDxtlr6Mg==} 105 + hasBin: true 106 + 107 + '@atcute/lexicon-doc@3.0.0': 108 + resolution: {integrity: sha512-hxBTEvO78X4j/HsGgcH8GKgMA9hP9FM6jtwQL8Yd6MIHtGXdB8QygvBmOAJEqm1oE9ckU1SSU3cwa2t4tbZZNw==} 109 + peerDependencies: 110 + '@atcute/lexicons': ^2.0.0 111 + 112 + '@atcute/lexicon-resolver@1.0.0': 113 + resolution: {integrity: sha512-4v3oSijgd3w9JS2BMsV1dfD+GD1ClQeuEJatM9b7rNffi1kMTSn/3nOQbJ1aNeXfqZbCkn3ATDRdvgki1xIAZw==} 114 + peerDependencies: 115 + '@atcute/identity': ^2.0.0 116 + '@atcute/identity-resolver': ^2.0.0 117 + '@atcute/lexicon-doc': ^3.0.0 118 + '@atcute/lexicons': ^2.0.0 119 + 120 + '@atcute/lexicons@2.0.0': 121 + resolution: {integrity: sha512-fIlwP+TPEAGoF5aU5s+f8N5sOjOu8Mww/sQL1B57Dp2hj3G/EWG9XwOHPokzycBCgXx+UxIIrzZCGy8whsVDZw==} 122 + 123 + '@atcute/mst@1.0.1': 124 + resolution: {integrity: sha512-F3PfHt/6RedpSCwPFVOK8YQ+9lohAqeqUfqWrXLEZUmSVjXPlhSY6re+fmAnNuYX1g124QprIea9G2K3IfymyQ==} 125 + peerDependencies: 126 + '@atcute/cbor': ^2.0.0 127 + '@atcute/cid': ^2.0.0 128 + 129 + '@atcute/multibase@1.2.0': 130 + resolution: {integrity: sha512-ZK2GRra+qIYq9nNuQB52m2ul0hOmCQEtPobGfTSUxm7pF0OGEkWGkWHugFhNEDVzHzTwPxHp6VGotdZFue4lYQ==} 131 + 132 + '@atcute/repo@1.0.0': 133 + resolution: {integrity: sha512-3s6VDKMimmYxVXn9OTlYQ7bJGPRcZRyFZ+oF/IFdfm3PsEUxwwSXajw1bG0HVXns1rNgNnDUT0PqMlUd2GnjqA==} 134 + peerDependencies: 135 + '@atcute/cbor': ^2.0.0 136 + '@atcute/cid': ^2.0.0 137 + '@atcute/lexicons': ^2.0.0 138 + 139 + '@atcute/uint8array@1.1.2': 140 + resolution: {integrity: sha512-n+lutnbN9mKzSjSVdfsYfzJ40u2971H+iLSL46D6d7zcrA4delxusf/ftGFvj5oGW03OioaFgQOy3Lqa3JmTeA==} 141 + 142 + '@atcute/util-fetch@2.0.0': 143 + resolution: {integrity: sha512-v+4aFQ/tuBqTV+URDJaFgm3mASWdglKXiPaGutJ1bs7QtQKmPZeesPY5MzW/a+MtI8GWCEJk8X9wOfalPOFSlg==} 144 + 145 + '@atcute/util-text@1.3.1': 146 + resolution: {integrity: sha512-MRgJXkx67znuBXuoAYCJkBZyd3OApL7zZlNf5kXhuoCXcdiu1nblRDycYTADSkym4epBSQWxh26kmI9sewaq6A==} 147 + 148 + '@atcute/varint@2.0.0': 149 + resolution: {integrity: sha512-CEY/oVK/nVpL4e5y3sdenLETDL6/Xu5xsE/0TupK+f0Yv8jcD60t2gD8SHROWSvUwYLdkjczLCSA7YrtnjCzWw==} 150 + 151 + '@atcute/xrpc-server@2.0.0': 152 + resolution: {integrity: sha512-HYfuxEqZ5XZIh0pGBlrRrnnqblIaP30OQf7/8lcwbzpZUEZy25BUmt6XrAqqs+bGQOpY+DagZyspFRn5F2joRA==} 153 + peerDependencies: 154 + '@atcute/lexicons': ^2.0.0 155 + 156 + '@cloudflare/kv-asset-handler@0.5.0': 157 + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} 158 + engines: {node: '>=22.0.0'} 159 + 160 + '@cloudflare/unenv-preset@2.16.1': 161 + resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} 162 + peerDependencies: 163 + unenv: 2.0.0-rc.24 164 + workerd: '>1.20260305.0 <2.0.0-0' 165 + peerDependenciesMeta: 166 + workerd: 167 + optional: true 168 + 169 + '@cloudflare/vitest-pool-workers@0.16.9': 170 + resolution: {integrity: sha512-qdohrJbLhuB3mq3j/Vg4nHQU+Gw0ZhQErlZ7xr9A2VpP1F4QzCewwwJmWZnXlwU2rMbvGVwwOD91Eb39EvfQmQ==} 171 + peerDependencies: 172 + '@vitest/runner': ^4.1.0 173 + '@vitest/snapshot': ^4.1.0 174 + vitest: ^4.1.0 175 + 176 + '@cloudflare/workerd-darwin-64@1.20260521.1': 177 + resolution: {integrity: sha512-aiNdXmxlhwGjTSajL3I7uQPpN4lAOcXjvg5ZOlJKIywnevr798n9XCS6lvuqgniM3KjurBNWRRypMJntg/eSLg==} 178 + engines: {node: '>=16'} 179 + cpu: [x64] 180 + os: [darwin] 181 + 182 + '@cloudflare/workerd-darwin-arm64@1.20260521.1': 183 + resolution: {integrity: sha512-ikN8aKSi4Ak28ndOkuSO5rq6lmV6wwDQu9F9Vu6J7EkwAOth74J/Hjn4j4EuFceW/npw2Ws0Y/muzA6WKHl4TA==} 184 + engines: {node: '>=16'} 185 + cpu: [arm64] 186 + os: [darwin] 187 + 188 + '@cloudflare/workerd-linux-64@1.20260521.1': 189 + resolution: {integrity: sha512-D/gUhvQcG0pJr5aJl6yUoi2JxbFpjVtDq9xUJHPjfkAjL28TUVgCR/e5r8YGirepv4I1DK7ihuii9LZ2GGMJbw==} 190 + engines: {node: '>=16'} 191 + cpu: [x64] 192 + os: [linux] 193 + 194 + '@cloudflare/workerd-linux-arm64@1.20260521.1': 195 + resolution: {integrity: sha512-vhjWPIHenczegTakhRPwEmTeaavCpNqsuo3RlLCkUdU47HrwLvy/4QersGggs4+kF4Do+IE/EznCGyT40xYcLA==} 196 + engines: {node: '>=16'} 197 + cpu: [arm64] 198 + os: [linux] 199 + 200 + '@cloudflare/workerd-windows-64@1.20260521.1': 201 + resolution: {integrity: sha512-wBolYC/+lnGIEbkkPdzFtjTOWip2uQH6maeAP1ZV0kyxi5SGpsa83+wD5rH5OOle+sHE5qJMdwCKjwRwj+FKJg==} 202 + engines: {node: '>=16'} 203 + cpu: [x64] 204 + os: [win32] 205 + 206 + '@cloudflare/workers-types@4.20260522.1': 207 + resolution: {integrity: sha512-UjKZprpYHAaBVipLfZA2GzTuWSTHyPXWRsaedwVEqyDe+VHwFi8RVtxGr1lceBDCwLDGRjlUwuwokX6O9GZY2A==} 208 + 209 + '@cspotcode/source-map-support@0.8.1': 210 + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} 211 + engines: {node: '>=12'} 212 + 213 + '@emnapi/core@1.10.0': 214 + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} 215 + 216 + '@emnapi/runtime@1.10.0': 217 + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} 218 + 219 + '@emnapi/wasi-threads@1.2.1': 220 + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} 221 + 222 + '@esbuild/aix-ppc64@0.27.3': 223 + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} 224 + engines: {node: '>=18'} 225 + cpu: [ppc64] 226 + os: [aix] 227 + 228 + '@esbuild/android-arm64@0.27.3': 229 + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} 230 + engines: {node: '>=18'} 231 + cpu: [arm64] 232 + os: [android] 233 + 234 + '@esbuild/android-arm@0.27.3': 235 + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} 236 + engines: {node: '>=18'} 237 + cpu: [arm] 238 + os: [android] 239 + 240 + '@esbuild/android-x64@0.27.3': 241 + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} 242 + engines: {node: '>=18'} 243 + cpu: [x64] 244 + os: [android] 245 + 246 + '@esbuild/darwin-arm64@0.27.3': 247 + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} 248 + engines: {node: '>=18'} 249 + cpu: [arm64] 250 + os: [darwin] 251 + 252 + '@esbuild/darwin-x64@0.27.3': 253 + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} 254 + engines: {node: '>=18'} 255 + cpu: [x64] 256 + os: [darwin] 257 + 258 + '@esbuild/freebsd-arm64@0.27.3': 259 + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} 260 + engines: {node: '>=18'} 261 + cpu: [arm64] 262 + os: [freebsd] 263 + 264 + '@esbuild/freebsd-x64@0.27.3': 265 + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} 266 + engines: {node: '>=18'} 267 + cpu: [x64] 268 + os: [freebsd] 269 + 270 + '@esbuild/linux-arm64@0.27.3': 271 + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} 272 + engines: {node: '>=18'} 273 + cpu: [arm64] 274 + os: [linux] 275 + 276 + '@esbuild/linux-arm@0.27.3': 277 + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} 278 + engines: {node: '>=18'} 279 + cpu: [arm] 280 + os: [linux] 281 + 282 + '@esbuild/linux-ia32@0.27.3': 283 + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} 284 + engines: {node: '>=18'} 285 + cpu: [ia32] 286 + os: [linux] 287 + 288 + '@esbuild/linux-loong64@0.27.3': 289 + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} 290 + engines: {node: '>=18'} 291 + cpu: [loong64] 292 + os: [linux] 293 + 294 + '@esbuild/linux-mips64el@0.27.3': 295 + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} 296 + engines: {node: '>=18'} 297 + cpu: [mips64el] 298 + os: [linux] 299 + 300 + '@esbuild/linux-ppc64@0.27.3': 301 + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} 302 + engines: {node: '>=18'} 303 + cpu: [ppc64] 304 + os: [linux] 305 + 306 + '@esbuild/linux-riscv64@0.27.3': 307 + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} 308 + engines: {node: '>=18'} 309 + cpu: [riscv64] 310 + os: [linux] 311 + 312 + '@esbuild/linux-s390x@0.27.3': 313 + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} 314 + engines: {node: '>=18'} 315 + cpu: [s390x] 316 + os: [linux] 317 + 318 + '@esbuild/linux-x64@0.27.3': 319 + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} 320 + engines: {node: '>=18'} 321 + cpu: [x64] 322 + os: [linux] 323 + 324 + '@esbuild/netbsd-arm64@0.27.3': 325 + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} 326 + engines: {node: '>=18'} 327 + cpu: [arm64] 328 + os: [netbsd] 329 + 330 + '@esbuild/netbsd-x64@0.27.3': 331 + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} 332 + engines: {node: '>=18'} 333 + cpu: [x64] 334 + os: [netbsd] 335 + 336 + '@esbuild/openbsd-arm64@0.27.3': 337 + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} 338 + engines: {node: '>=18'} 339 + cpu: [arm64] 340 + os: [openbsd] 341 + 342 + '@esbuild/openbsd-x64@0.27.3': 343 + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} 344 + engines: {node: '>=18'} 345 + cpu: [x64] 346 + os: [openbsd] 347 + 348 + '@esbuild/openharmony-arm64@0.27.3': 349 + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} 350 + engines: {node: '>=18'} 351 + cpu: [arm64] 352 + os: [openharmony] 353 + 354 + '@esbuild/sunos-x64@0.27.3': 355 + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} 356 + engines: {node: '>=18'} 357 + cpu: [x64] 358 + os: [sunos] 359 + 360 + '@esbuild/win32-arm64@0.27.3': 361 + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} 362 + engines: {node: '>=18'} 363 + cpu: [arm64] 364 + os: [win32] 365 + 366 + '@esbuild/win32-ia32@0.27.3': 367 + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} 368 + engines: {node: '>=18'} 369 + cpu: [ia32] 370 + os: [win32] 371 + 372 + '@esbuild/win32-x64@0.27.3': 373 + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} 374 + engines: {node: '>=18'} 375 + cpu: [x64] 376 + os: [win32] 377 + 378 + '@img/colour@1.1.0': 379 + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} 380 + engines: {node: '>=18'} 381 + 382 + '@img/sharp-darwin-arm64@0.34.5': 383 + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} 384 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 385 + cpu: [arm64] 386 + os: [darwin] 387 + 388 + '@img/sharp-darwin-x64@0.34.5': 389 + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} 390 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 391 + cpu: [x64] 392 + os: [darwin] 393 + 394 + '@img/sharp-libvips-darwin-arm64@1.2.4': 395 + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} 396 + cpu: [arm64] 397 + os: [darwin] 398 + 399 + '@img/sharp-libvips-darwin-x64@1.2.4': 400 + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} 401 + cpu: [x64] 402 + os: [darwin] 403 + 404 + '@img/sharp-libvips-linux-arm64@1.2.4': 405 + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} 406 + cpu: [arm64] 407 + os: [linux] 408 + libc: [glibc] 409 + 410 + '@img/sharp-libvips-linux-arm@1.2.4': 411 + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} 412 + cpu: [arm] 413 + os: [linux] 414 + libc: [glibc] 415 + 416 + '@img/sharp-libvips-linux-ppc64@1.2.4': 417 + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} 418 + cpu: [ppc64] 419 + os: [linux] 420 + libc: [glibc] 421 + 422 + '@img/sharp-libvips-linux-riscv64@1.2.4': 423 + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} 424 + cpu: [riscv64] 425 + os: [linux] 426 + libc: [glibc] 427 + 428 + '@img/sharp-libvips-linux-s390x@1.2.4': 429 + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} 430 + cpu: [s390x] 431 + os: [linux] 432 + libc: [glibc] 433 + 434 + '@img/sharp-libvips-linux-x64@1.2.4': 435 + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} 436 + cpu: [x64] 437 + os: [linux] 438 + libc: [glibc] 439 + 440 + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': 441 + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} 442 + cpu: [arm64] 443 + os: [linux] 444 + libc: [musl] 445 + 446 + '@img/sharp-libvips-linuxmusl-x64@1.2.4': 447 + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} 448 + cpu: [x64] 449 + os: [linux] 450 + libc: [musl] 451 + 452 + '@img/sharp-linux-arm64@0.34.5': 453 + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} 454 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 455 + cpu: [arm64] 456 + os: [linux] 457 + libc: [glibc] 458 + 459 + '@img/sharp-linux-arm@0.34.5': 460 + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} 461 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 462 + cpu: [arm] 463 + os: [linux] 464 + libc: [glibc] 465 + 466 + '@img/sharp-linux-ppc64@0.34.5': 467 + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} 468 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 469 + cpu: [ppc64] 470 + os: [linux] 471 + libc: [glibc] 472 + 473 + '@img/sharp-linux-riscv64@0.34.5': 474 + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} 475 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 476 + cpu: [riscv64] 477 + os: [linux] 478 + libc: [glibc] 479 + 480 + '@img/sharp-linux-s390x@0.34.5': 481 + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} 482 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 483 + cpu: [s390x] 484 + os: [linux] 485 + libc: [glibc] 486 + 487 + '@img/sharp-linux-x64@0.34.5': 488 + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} 489 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 490 + cpu: [x64] 491 + os: [linux] 492 + libc: [glibc] 493 + 494 + '@img/sharp-linuxmusl-arm64@0.34.5': 495 + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} 496 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 497 + cpu: [arm64] 498 + os: [linux] 499 + libc: [musl] 500 + 501 + '@img/sharp-linuxmusl-x64@0.34.5': 502 + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} 503 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 504 + cpu: [x64] 505 + os: [linux] 506 + libc: [musl] 507 + 508 + '@img/sharp-wasm32@0.34.5': 509 + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} 510 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 511 + cpu: [wasm32] 512 + 513 + '@img/sharp-win32-arm64@0.34.5': 514 + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} 515 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 516 + cpu: [arm64] 517 + os: [win32] 518 + 519 + '@img/sharp-win32-ia32@0.34.5': 520 + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} 521 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 522 + cpu: [ia32] 523 + os: [win32] 524 + 525 + '@img/sharp-win32-x64@0.34.5': 526 + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} 527 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 528 + cpu: [x64] 529 + os: [win32] 530 + 531 + '@jridgewell/resolve-uri@3.1.2': 532 + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} 533 + engines: {node: '>=6.0.0'} 534 + 535 + '@jridgewell/sourcemap-codec@1.5.5': 536 + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} 537 + 538 + '@jridgewell/trace-mapping@0.3.9': 539 + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} 540 + 541 + '@napi-rs/wasm-runtime@1.1.4': 542 + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} 543 + peerDependencies: 544 + '@emnapi/core': ^1.7.1 545 + '@emnapi/runtime': ^1.7.1 546 + 547 + '@noble/secp256k1@3.1.0': 548 + resolution: {integrity: sha512-+F7iS7tUMaNGXcc9X3PjmjvuQnXEuSjCRNzVVA2xAcKXgCaP0dHYz4SFyt4FKNHef7sOP//xihowcySSS7PK9g==} 549 + 550 + '@optique/core@1.0.2': 551 + resolution: {integrity: sha512-znsqMmjAdeOgSJzdJlpZpgAscojwQmeQYXzYnuEKllz5VCj6WyEkdzU4QuvJQtWQY3ve2taXwudEBRur0VHBOQ==} 552 + engines: {bun: '>=1.2.0', deno: '>=2.3.0', node: '>=20.0.0'} 553 + 554 + '@optique/run@1.0.2': 555 + resolution: {integrity: sha512-0Wc+zC8SLGV8zXQX+pk+o0c6wE/ddx/36CHZ0toTh5lApsjruUuGhqbxvljerAAG5un1xQbOLxzksBVC6UPgSg==} 556 + engines: {bun: '>=1.2.0', deno: '>=2.3.0', node: '>=20.0.0'} 557 + 558 + '@oxc-project/types@0.132.0': 559 + resolution: {integrity: sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==} 560 + 561 + '@poppinss/colors@4.1.6': 562 + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} 563 + 564 + '@poppinss/dumper@0.6.5': 565 + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} 566 + 567 + '@poppinss/exception@1.2.3': 568 + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} 569 + 570 + '@rolldown/binding-android-arm64@1.0.2': 571 + resolution: {integrity: sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==} 572 + engines: {node: ^20.19.0 || >=22.12.0} 573 + cpu: [arm64] 574 + os: [android] 575 + 576 + '@rolldown/binding-darwin-arm64@1.0.2': 577 + resolution: {integrity: sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==} 578 + engines: {node: ^20.19.0 || >=22.12.0} 579 + cpu: [arm64] 580 + os: [darwin] 581 + 582 + '@rolldown/binding-darwin-x64@1.0.2': 583 + resolution: {integrity: sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==} 584 + engines: {node: ^20.19.0 || >=22.12.0} 585 + cpu: [x64] 586 + os: [darwin] 587 + 588 + '@rolldown/binding-freebsd-x64@1.0.2': 589 + resolution: {integrity: sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==} 590 + engines: {node: ^20.19.0 || >=22.12.0} 591 + cpu: [x64] 592 + os: [freebsd] 593 + 594 + '@rolldown/binding-linux-arm-gnueabihf@1.0.2': 595 + resolution: {integrity: sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==} 596 + engines: {node: ^20.19.0 || >=22.12.0} 597 + cpu: [arm] 598 + os: [linux] 599 + 600 + '@rolldown/binding-linux-arm64-gnu@1.0.2': 601 + resolution: {integrity: sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==} 602 + engines: {node: ^20.19.0 || >=22.12.0} 603 + cpu: [arm64] 604 + os: [linux] 605 + libc: [glibc] 606 + 607 + '@rolldown/binding-linux-arm64-musl@1.0.2': 608 + resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==} 609 + engines: {node: ^20.19.0 || >=22.12.0} 610 + cpu: [arm64] 611 + os: [linux] 612 + libc: [musl] 613 + 614 + '@rolldown/binding-linux-ppc64-gnu@1.0.2': 615 + resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==} 616 + engines: {node: ^20.19.0 || >=22.12.0} 617 + cpu: [ppc64] 618 + os: [linux] 619 + libc: [glibc] 620 + 621 + '@rolldown/binding-linux-s390x-gnu@1.0.2': 622 + resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==} 623 + engines: {node: ^20.19.0 || >=22.12.0} 624 + cpu: [s390x] 625 + os: [linux] 626 + libc: [glibc] 627 + 628 + '@rolldown/binding-linux-x64-gnu@1.0.2': 629 + resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==} 630 + engines: {node: ^20.19.0 || >=22.12.0} 631 + cpu: [x64] 632 + os: [linux] 633 + libc: [glibc] 634 + 635 + '@rolldown/binding-linux-x64-musl@1.0.2': 636 + resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==} 637 + engines: {node: ^20.19.0 || >=22.12.0} 638 + cpu: [x64] 639 + os: [linux] 640 + libc: [musl] 641 + 642 + '@rolldown/binding-openharmony-arm64@1.0.2': 643 + resolution: {integrity: sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==} 644 + engines: {node: ^20.19.0 || >=22.12.0} 645 + cpu: [arm64] 646 + os: [openharmony] 647 + 648 + '@rolldown/binding-wasm32-wasi@1.0.2': 649 + resolution: {integrity: sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==} 650 + engines: {node: ^20.19.0 || >=22.12.0} 651 + cpu: [wasm32] 652 + 653 + '@rolldown/binding-win32-arm64-msvc@1.0.2': 654 + resolution: {integrity: sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==} 655 + engines: {node: ^20.19.0 || >=22.12.0} 656 + cpu: [arm64] 657 + os: [win32] 658 + 659 + '@rolldown/binding-win32-x64-msvc@1.0.2': 660 + resolution: {integrity: sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==} 661 + engines: {node: ^20.19.0 || >=22.12.0} 662 + cpu: [x64] 663 + os: [win32] 664 + 665 + '@rolldown/pluginutils@1.0.1': 666 + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} 667 + 668 + '@sindresorhus/is@7.2.0': 669 + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} 670 + engines: {node: '>=18'} 671 + 672 + '@speed-highlight/core@1.2.15': 673 + resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==} 674 + 675 + '@standard-schema/spec@1.1.0': 676 + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} 677 + 678 + '@tybys/wasm-util@0.10.2': 679 + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} 680 + 681 + '@types/chai@5.2.3': 682 + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} 683 + 684 + '@types/deep-eql@4.0.2': 685 + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} 686 + 687 + '@types/estree@1.0.9': 688 + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} 689 + 690 + '@vitest/expect@4.1.7': 691 + resolution: {integrity: sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==} 692 + 693 + '@vitest/mocker@4.1.7': 694 + resolution: {integrity: sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==} 695 + peerDependencies: 696 + msw: ^2.4.9 697 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 698 + peerDependenciesMeta: 699 + msw: 700 + optional: true 701 + vite: 702 + optional: true 703 + 704 + '@vitest/pretty-format@4.1.7': 705 + resolution: {integrity: sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==} 706 + 707 + '@vitest/runner@4.1.7': 708 + resolution: {integrity: sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==} 709 + 710 + '@vitest/snapshot@4.1.7': 711 + resolution: {integrity: sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==} 712 + 713 + '@vitest/spy@4.1.7': 714 + resolution: {integrity: sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==} 715 + 716 + '@vitest/utils@4.1.7': 717 + resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==} 718 + 719 + assertion-error@2.0.1: 720 + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} 721 + engines: {node: '>=12'} 722 + 723 + blake3-wasm@2.1.5: 724 + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} 725 + 726 + chai@6.2.2: 727 + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} 728 + engines: {node: '>=18'} 729 + 730 + cjs-module-lexer@1.4.3: 731 + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} 732 + 733 + convert-source-map@2.0.0: 734 + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} 735 + 736 + cookie@1.1.1: 737 + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} 738 + engines: {node: '>=18'} 739 + 740 + detect-libc@2.1.2: 741 + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} 742 + engines: {node: '>=8'} 743 + 744 + error-stack-parser-es@1.0.5: 745 + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} 746 + 747 + es-module-lexer@2.1.0: 748 + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} 749 + 750 + esbuild@0.27.3: 751 + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} 752 + engines: {node: '>=18'} 753 + hasBin: true 754 + 755 + esm-env@1.2.2: 756 + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} 757 + 758 + estree-walker@3.0.3: 759 + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} 760 + 761 + expect-type@1.3.0: 762 + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} 763 + engines: {node: '>=12.0.0'} 764 + 765 + fdir@6.5.0: 766 + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} 767 + engines: {node: '>=12.0.0'} 768 + peerDependencies: 769 + picomatch: ^3 || ^4 770 + peerDependenciesMeta: 771 + picomatch: 772 + optional: true 773 + 774 + fsevents@2.3.3: 775 + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} 776 + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} 777 + os: [darwin] 778 + 779 + kleur@4.1.5: 780 + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} 781 + engines: {node: '>=6'} 782 + 783 + lightningcss-android-arm64@1.32.0: 784 + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} 785 + engines: {node: '>= 12.0.0'} 786 + cpu: [arm64] 787 + os: [android] 788 + 789 + lightningcss-darwin-arm64@1.32.0: 790 + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} 791 + engines: {node: '>= 12.0.0'} 792 + cpu: [arm64] 793 + os: [darwin] 794 + 795 + lightningcss-darwin-x64@1.32.0: 796 + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} 797 + engines: {node: '>= 12.0.0'} 798 + cpu: [x64] 799 + os: [darwin] 800 + 801 + lightningcss-freebsd-x64@1.32.0: 802 + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} 803 + engines: {node: '>= 12.0.0'} 804 + cpu: [x64] 805 + os: [freebsd] 806 + 807 + lightningcss-linux-arm-gnueabihf@1.32.0: 808 + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} 809 + engines: {node: '>= 12.0.0'} 810 + cpu: [arm] 811 + os: [linux] 812 + 813 + lightningcss-linux-arm64-gnu@1.32.0: 814 + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} 815 + engines: {node: '>= 12.0.0'} 816 + cpu: [arm64] 817 + os: [linux] 818 + libc: [glibc] 819 + 820 + lightningcss-linux-arm64-musl@1.32.0: 821 + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} 822 + engines: {node: '>= 12.0.0'} 823 + cpu: [arm64] 824 + os: [linux] 825 + libc: [musl] 826 + 827 + lightningcss-linux-x64-gnu@1.32.0: 828 + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} 829 + engines: {node: '>= 12.0.0'} 830 + cpu: [x64] 831 + os: [linux] 832 + libc: [glibc] 833 + 834 + lightningcss-linux-x64-musl@1.32.0: 835 + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} 836 + engines: {node: '>= 12.0.0'} 837 + cpu: [x64] 838 + os: [linux] 839 + libc: [musl] 840 + 841 + lightningcss-win32-arm64-msvc@1.32.0: 842 + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} 843 + engines: {node: '>= 12.0.0'} 844 + cpu: [arm64] 845 + os: [win32] 846 + 847 + lightningcss-win32-x64-msvc@1.32.0: 848 + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} 849 + engines: {node: '>= 12.0.0'} 850 + cpu: [x64] 851 + os: [win32] 852 + 853 + lightningcss@1.32.0: 854 + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} 855 + engines: {node: '>= 12.0.0'} 856 + 857 + magic-string@0.30.21: 858 + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} 859 + 860 + miniflare@4.20260521.0: 861 + resolution: {integrity: sha512-roRfxPq49OkuSeQsc43hRjSB1+HdHtDNKRwDEVk2hCjCBuBWxb5Wvwq88b0ULj6QVEJLN/+ZqF19M+h4VYJ/zg==} 862 + engines: {node: '>=22.0.0'} 863 + hasBin: true 864 + 865 + nanoid@3.3.12: 866 + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} 867 + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} 868 + hasBin: true 869 + 870 + nanoid@5.1.11: 871 + resolution: {integrity: sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==} 872 + engines: {node: ^18 || >=20} 873 + hasBin: true 874 + 875 + obug@2.1.1: 876 + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} 877 + 878 + path-to-regexp@6.3.0: 879 + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} 880 + 881 + pathe@2.0.3: 882 + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} 883 + 884 + picocolors@1.1.1: 885 + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} 886 + 887 + picomatch@4.0.4: 888 + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} 889 + engines: {node: '>=12'} 890 + 891 + postcss@8.5.15: 892 + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} 893 + engines: {node: ^10 || ^12 || >=14} 894 + 895 + prettier@3.8.3: 896 + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} 897 + engines: {node: '>=14'} 898 + hasBin: true 899 + 900 + rolldown@1.0.2: 901 + resolution: {integrity: sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==} 902 + engines: {node: ^20.19.0 || >=22.12.0} 903 + hasBin: true 904 + 905 + rosie-skills-darwin-arm64@0.6.4: 906 + resolution: {integrity: sha512-rn1s5hqFKcxeiDEWWoFa1hdGPshR8TkwHLzy/cBavb9XJNAaUxbe3oQ78W9sQkRHAgRyzJYyk9tw68Qrdnizgg==} 907 + cpu: [arm64] 908 + os: [darwin] 909 + 910 + rosie-skills-freebsd-x64@0.6.4: 911 + resolution: {integrity: sha512-SxCRduPBMtfjkQ+q56Yw9OLA3PyaqoALzt7kER7IDKuUVfM2O/1w8sa5xhTDiCvWkZJixnH5d5Ya6KT+/Mwcng==} 912 + cpu: [x64] 913 + os: [freebsd] 914 + 915 + rosie-skills-linux-x64@0.6.4: 916 + resolution: {integrity: sha512-D9Y9mfu7goB0s0X59uU3hcFeUTef3VbpCIDwFMzyvJrAq3XhRACWBDMHQsHlyWdHxTXPX/ILyW65RXyrJlgqng==} 917 + cpu: [x64] 918 + os: [linux] 919 + 920 + rosie-skills@0.6.4: 921 + resolution: {integrity: sha512-ojfhSiQRdZ2QyWbmKAHOSAUbaLYrTc5zIH7mS1jKoP8KCFSQddwVhMyFqldckTeybTfW3zNcsZzyOTzGTN1SBA==} 922 + engines: {node: '>=18'} 923 + hasBin: true 924 + 925 + semver@7.8.1: 926 + resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} 927 + engines: {node: '>=10'} 928 + hasBin: true 929 + 930 + sharp@0.34.5: 931 + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} 932 + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} 933 + 934 + siginfo@2.0.0: 935 + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} 936 + 937 + source-map-js@1.2.1: 938 + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} 939 + engines: {node: '>=0.10.0'} 940 + 941 + stackback@0.0.2: 942 + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} 943 + 944 + std-env@4.1.0: 945 + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} 946 + 947 + supports-color@10.2.2: 948 + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} 949 + engines: {node: '>=18'} 950 + 951 + tinybench@2.9.0: 952 + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} 953 + 954 + tinyexec@1.1.2: 955 + resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} 956 + engines: {node: '>=18'} 957 + 958 + tinyglobby@0.2.16: 959 + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} 960 + engines: {node: '>=12.0.0'} 961 + 962 + tinyrainbow@3.1.0: 963 + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} 964 + engines: {node: '>=14.0.0'} 965 + 966 + tslib@2.8.1: 967 + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} 968 + 969 + typescript@5.9.3: 970 + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} 971 + engines: {node: '>=14.17'} 972 + hasBin: true 973 + 974 + undici@7.24.8: 975 + resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==} 976 + engines: {node: '>=20.18.1'} 977 + 978 + unenv@2.0.0-rc.24: 979 + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} 980 + 981 + unicode-segmenter@0.14.5: 982 + resolution: {integrity: sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==} 983 + 984 + valibot@1.4.0: 985 + resolution: {integrity: sha512-iC/x7fVcSyOwlm/VSt7RlHnzNGLGvR9GnxdifUeWoCJo0q4ZZvrVkIHC6faTlkxG47I2Y4UrFquPuVHCrOnrLg==} 986 + peerDependencies: 987 + typescript: '>=5' 988 + peerDependenciesMeta: 989 + typescript: 990 + optional: true 991 + 992 + vite@8.0.14: 993 + resolution: {integrity: sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==} 994 + engines: {node: ^20.19.0 || >=22.12.0} 995 + hasBin: true 996 + peerDependencies: 997 + '@types/node': ^20.19.0 || >=22.12.0 998 + '@vitejs/devtools': ^0.1.18 999 + esbuild: ^0.27.0 || ^0.28.0 1000 + jiti: '>=1.21.0' 1001 + less: ^4.0.0 1002 + sass: ^1.70.0 1003 + sass-embedded: ^1.70.0 1004 + stylus: '>=0.54.8' 1005 + sugarss: ^5.0.0 1006 + terser: ^5.16.0 1007 + tsx: ^4.8.1 1008 + yaml: ^2.4.2 1009 + peerDependenciesMeta: 1010 + '@types/node': 1011 + optional: true 1012 + '@vitejs/devtools': 1013 + optional: true 1014 + esbuild: 1015 + optional: true 1016 + jiti: 1017 + optional: true 1018 + less: 1019 + optional: true 1020 + sass: 1021 + optional: true 1022 + sass-embedded: 1023 + optional: true 1024 + stylus: 1025 + optional: true 1026 + sugarss: 1027 + optional: true 1028 + terser: 1029 + optional: true 1030 + tsx: 1031 + optional: true 1032 + yaml: 1033 + optional: true 1034 + 1035 + vitest@4.1.7: 1036 + resolution: {integrity: sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==} 1037 + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} 1038 + hasBin: true 1039 + peerDependencies: 1040 + '@edge-runtime/vm': '*' 1041 + '@opentelemetry/api': ^1.9.0 1042 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 1043 + '@vitest/browser-playwright': 4.1.7 1044 + '@vitest/browser-preview': 4.1.7 1045 + '@vitest/browser-webdriverio': 4.1.7 1046 + '@vitest/coverage-istanbul': 4.1.7 1047 + '@vitest/coverage-v8': 4.1.7 1048 + '@vitest/ui': 4.1.7 1049 + happy-dom: '*' 1050 + jsdom: '*' 1051 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 1052 + peerDependenciesMeta: 1053 + '@edge-runtime/vm': 1054 + optional: true 1055 + '@opentelemetry/api': 1056 + optional: true 1057 + '@types/node': 1058 + optional: true 1059 + '@vitest/browser-playwright': 1060 + optional: true 1061 + '@vitest/browser-preview': 1062 + optional: true 1063 + '@vitest/browser-webdriverio': 1064 + optional: true 1065 + '@vitest/coverage-istanbul': 1066 + optional: true 1067 + '@vitest/coverage-v8': 1068 + optional: true 1069 + '@vitest/ui': 1070 + optional: true 1071 + happy-dom: 1072 + optional: true 1073 + jsdom: 1074 + optional: true 1075 + 1076 + why-is-node-running@2.3.0: 1077 + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} 1078 + engines: {node: '>=8'} 1079 + hasBin: true 1080 + 1081 + workerd@1.20260521.1: 1082 + resolution: {integrity: sha512-HzIThcZ0ZVEuzVxpY2IYZ3yssSrTjtrWXAVfmOl5rVwyqcu7aeZXGMiwrEmi9MOcC3wjy+BNv+hFrMMY5OrjQQ==} 1083 + engines: {node: '>=16'} 1084 + hasBin: true 1085 + 1086 + wrangler@4.94.0: 1087 + resolution: {integrity: sha512-GsNw0DomGFfeXFtKVTwn2X69UKcCxcTB0CXykjsMineJIxOeyrw7LovlHQ/3JU8KJHH7repLB+kOHvfTBA/Eew==} 1088 + engines: {node: '>=22.0.0'} 1089 + hasBin: true 1090 + peerDependencies: 1091 + '@cloudflare/workers-types': ^4.20260521.1 1092 + peerDependenciesMeta: 1093 + '@cloudflare/workers-types': 1094 + optional: true 1095 + 1096 + ws@8.20.1: 1097 + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} 1098 + engines: {node: '>=10.0.0'} 1099 + peerDependencies: 1100 + bufferutil: ^4.0.1 1101 + utf-8-validate: '>=5.0.2' 1102 + peerDependenciesMeta: 1103 + bufferutil: 1104 + optional: true 1105 + utf-8-validate: 1106 + optional: true 1107 + 1108 + youch-core@0.3.3: 1109 + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} 1110 + 1111 + youch@4.1.0-beta.10: 1112 + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} 1113 + 1114 + zod@3.25.76: 1115 + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} 1116 + 1117 + snapshots: 1118 + 1119 + '@atcute/car@6.0.0(@atcute/cbor@2.3.3(@atcute/cid@2.4.1))(@atcute/cid@2.4.1)': 1120 + dependencies: 1121 + '@atcute/cbor': 2.3.3(@atcute/cid@2.4.1) 1122 + '@atcute/cid': 2.4.1 1123 + '@atcute/uint8array': 1.1.2 1124 + '@atcute/varint': 2.0.0 1125 + 1126 + '@atcute/cbor@2.3.3(@atcute/cid@2.4.1)': 1127 + dependencies: 1128 + '@atcute/cid': 2.4.1 1129 + '@atcute/multibase': 1.2.0 1130 + '@atcute/uint8array': 1.1.2 1131 + 1132 + '@atcute/cid@2.4.1': 1133 + dependencies: 1134 + '@atcute/multibase': 1.2.0 1135 + '@atcute/uint8array': 1.1.2 1136 + 1137 + '@atcute/client@5.0.0(@atcute/lexicons@2.0.0)(typescript@5.9.3)': 1138 + dependencies: 1139 + '@atcute/identity': 2.0.0(@atcute/lexicons@2.0.0)(typescript@5.9.3) 1140 + '@atcute/lexicons': 2.0.0 1141 + transitivePeerDependencies: 1142 + - typescript 1143 + 1144 + '@atcute/crypto@2.4.1': 1145 + dependencies: 1146 + '@atcute/multibase': 1.2.0 1147 + '@atcute/uint8array': 1.1.2 1148 + '@noble/secp256k1': 3.1.0 1149 + 1150 + '@atcute/identity-resolver@2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@5.9.3))(@atcute/lexicons@2.0.0)(typescript@5.9.3)': 1151 + dependencies: 1152 + '@atcute/identity': 2.0.0(@atcute/lexicons@2.0.0)(typescript@5.9.3) 1153 + '@atcute/lexicons': 2.0.0 1154 + '@atcute/util-fetch': 2.0.0(typescript@5.9.3) 1155 + valibot: 1.4.0(typescript@5.9.3) 1156 + transitivePeerDependencies: 1157 + - typescript 1158 + 1159 + '@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@5.9.3)': 1160 + dependencies: 1161 + '@atcute/lexicons': 2.0.0 1162 + valibot: 1.4.0(typescript@5.9.3) 1163 + transitivePeerDependencies: 1164 + - typescript 1165 + 1166 + '@atcute/lex-cli@3.1.0(@atcute/cbor@2.3.3(@atcute/cid@2.4.1))(@atcute/cid@2.4.1)(typescript@5.9.3)': 1167 + dependencies: 1168 + '@atcute/identity': 2.0.0(@atcute/lexicons@2.0.0)(typescript@5.9.3) 1169 + '@atcute/identity-resolver': 2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@5.9.3))(@atcute/lexicons@2.0.0)(typescript@5.9.3) 1170 + '@atcute/lexicon-doc': 3.0.0(@atcute/lexicons@2.0.0)(typescript@5.9.3) 1171 + '@atcute/lexicon-resolver': 1.0.0(@atcute/cbor@2.3.3(@atcute/cid@2.4.1))(@atcute/cid@2.4.1)(@atcute/identity-resolver@2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@5.9.3))(@atcute/lexicons@2.0.0)(typescript@5.9.3))(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@5.9.3))(@atcute/lexicon-doc@3.0.0(@atcute/lexicons@2.0.0)(typescript@5.9.3))(@atcute/lexicons@2.0.0)(typescript@5.9.3) 1172 + '@atcute/lexicons': 2.0.0 1173 + '@optique/core': 1.0.2 1174 + '@optique/run': 1.0.2 1175 + picocolors: 1.1.1 1176 + prettier: 3.8.3 1177 + valibot: 1.4.0(typescript@5.9.3) 1178 + transitivePeerDependencies: 1179 + - '@atcute/cbor' 1180 + - '@atcute/cid' 1181 + - typescript 1182 + 1183 + '@atcute/lexicon-doc@3.0.0(@atcute/lexicons@2.0.0)(typescript@5.9.3)': 1184 + dependencies: 1185 + '@atcute/identity': 2.0.0(@atcute/lexicons@2.0.0)(typescript@5.9.3) 1186 + '@atcute/lexicons': 2.0.0 1187 + '@atcute/uint8array': 1.1.2 1188 + '@atcute/util-text': 1.3.1 1189 + valibot: 1.4.0(typescript@5.9.3) 1190 + transitivePeerDependencies: 1191 + - typescript 1192 + 1193 + '@atcute/lexicon-resolver@1.0.0(@atcute/cbor@2.3.3(@atcute/cid@2.4.1))(@atcute/cid@2.4.1)(@atcute/identity-resolver@2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@5.9.3))(@atcute/lexicons@2.0.0)(typescript@5.9.3))(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@5.9.3))(@atcute/lexicon-doc@3.0.0(@atcute/lexicons@2.0.0)(typescript@5.9.3))(@atcute/lexicons@2.0.0)(typescript@5.9.3)': 1194 + dependencies: 1195 + '@atcute/crypto': 2.4.1 1196 + '@atcute/identity': 2.0.0(@atcute/lexicons@2.0.0)(typescript@5.9.3) 1197 + '@atcute/identity-resolver': 2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@5.9.3))(@atcute/lexicons@2.0.0)(typescript@5.9.3) 1198 + '@atcute/lexicon-doc': 3.0.0(@atcute/lexicons@2.0.0)(typescript@5.9.3) 1199 + '@atcute/lexicons': 2.0.0 1200 + '@atcute/repo': 1.0.0(@atcute/cbor@2.3.3(@atcute/cid@2.4.1))(@atcute/cid@2.4.1)(@atcute/lexicons@2.0.0) 1201 + '@atcute/util-fetch': 2.0.0(typescript@5.9.3) 1202 + valibot: 1.4.0(typescript@5.9.3) 1203 + transitivePeerDependencies: 1204 + - '@atcute/cbor' 1205 + - '@atcute/cid' 1206 + - typescript 1207 + 1208 + '@atcute/lexicons@2.0.0': 1209 + dependencies: 1210 + '@atcute/uint8array': 1.1.2 1211 + '@atcute/util-text': 1.3.1 1212 + '@standard-schema/spec': 1.1.0 1213 + esm-env: 1.2.2 1214 + 1215 + '@atcute/mst@1.0.1(@atcute/cbor@2.3.3(@atcute/cid@2.4.1))(@atcute/cid@2.4.1)': 1216 + dependencies: 1217 + '@atcute/cbor': 2.3.3(@atcute/cid@2.4.1) 1218 + '@atcute/cid': 2.4.1 1219 + '@atcute/uint8array': 1.1.2 1220 + 1221 + '@atcute/multibase@1.2.0': 1222 + dependencies: 1223 + '@atcute/uint8array': 1.1.2 1224 + 1225 + '@atcute/repo@1.0.0(@atcute/cbor@2.3.3(@atcute/cid@2.4.1))(@atcute/cid@2.4.1)(@atcute/lexicons@2.0.0)': 1226 + dependencies: 1227 + '@atcute/car': 6.0.0(@atcute/cbor@2.3.3(@atcute/cid@2.4.1))(@atcute/cid@2.4.1) 1228 + '@atcute/cbor': 2.3.3(@atcute/cid@2.4.1) 1229 + '@atcute/cid': 2.4.1 1230 + '@atcute/crypto': 2.4.1 1231 + '@atcute/lexicons': 2.0.0 1232 + '@atcute/mst': 1.0.1(@atcute/cbor@2.3.3(@atcute/cid@2.4.1))(@atcute/cid@2.4.1) 1233 + '@atcute/uint8array': 1.1.2 1234 + 1235 + '@atcute/uint8array@1.1.2': {} 1236 + 1237 + '@atcute/util-fetch@2.0.0(typescript@5.9.3)': 1238 + dependencies: 1239 + valibot: 1.4.0(typescript@5.9.3) 1240 + transitivePeerDependencies: 1241 + - typescript 1242 + 1243 + '@atcute/util-text@1.3.1': 1244 + dependencies: 1245 + unicode-segmenter: 0.14.5 1246 + 1247 + '@atcute/varint@2.0.0': {} 1248 + 1249 + '@atcute/xrpc-server@2.0.0(@atcute/cid@2.4.1)(@atcute/lexicons@2.0.0)(typescript@5.9.3)': 1250 + dependencies: 1251 + '@atcute/cbor': 2.3.3(@atcute/cid@2.4.1) 1252 + '@atcute/crypto': 2.4.1 1253 + '@atcute/identity': 2.0.0(@atcute/lexicons@2.0.0)(typescript@5.9.3) 1254 + '@atcute/identity-resolver': 2.0.0(@atcute/identity@2.0.0(@atcute/lexicons@2.0.0)(typescript@5.9.3))(@atcute/lexicons@2.0.0)(typescript@5.9.3) 1255 + '@atcute/lexicons': 2.0.0 1256 + '@atcute/multibase': 1.2.0 1257 + '@atcute/uint8array': 1.1.2 1258 + nanoid: 5.1.11 1259 + valibot: 1.4.0(typescript@5.9.3) 1260 + transitivePeerDependencies: 1261 + - '@atcute/cid' 1262 + - typescript 1263 + 1264 + '@cloudflare/kv-asset-handler@0.5.0': {} 1265 + 1266 + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260521.1)': 1267 + dependencies: 1268 + unenv: 2.0.0-rc.24 1269 + optionalDependencies: 1270 + workerd: 1.20260521.1 1271 + 1272 + '@cloudflare/vitest-pool-workers@0.16.9(@cloudflare/workers-types@4.20260522.1)(@vitest/runner@4.1.7)(@vitest/snapshot@4.1.7)(vitest@4.1.7(vite@8.0.14(esbuild@0.27.3)))': 1273 + dependencies: 1274 + '@vitest/runner': 4.1.7 1275 + '@vitest/snapshot': 4.1.7 1276 + cjs-module-lexer: 1.4.3 1277 + esbuild: 0.27.3 1278 + miniflare: 4.20260521.0 1279 + vitest: 4.1.7(vite@8.0.14(esbuild@0.27.3)) 1280 + wrangler: 4.94.0(@cloudflare/workers-types@4.20260522.1) 1281 + zod: 3.25.76 1282 + transitivePeerDependencies: 1283 + - '@cloudflare/workers-types' 1284 + - bufferutil 1285 + - utf-8-validate 1286 + 1287 + '@cloudflare/workerd-darwin-64@1.20260521.1': 1288 + optional: true 1289 + 1290 + '@cloudflare/workerd-darwin-arm64@1.20260521.1': 1291 + optional: true 1292 + 1293 + '@cloudflare/workerd-linux-64@1.20260521.1': 1294 + optional: true 1295 + 1296 + '@cloudflare/workerd-linux-arm64@1.20260521.1': 1297 + optional: true 1298 + 1299 + '@cloudflare/workerd-windows-64@1.20260521.1': 1300 + optional: true 1301 + 1302 + '@cloudflare/workers-types@4.20260522.1': {} 1303 + 1304 + '@cspotcode/source-map-support@0.8.1': 1305 + dependencies: 1306 + '@jridgewell/trace-mapping': 0.3.9 1307 + 1308 + '@emnapi/core@1.10.0': 1309 + dependencies: 1310 + '@emnapi/wasi-threads': 1.2.1 1311 + tslib: 2.8.1 1312 + optional: true 1313 + 1314 + '@emnapi/runtime@1.10.0': 1315 + dependencies: 1316 + tslib: 2.8.1 1317 + optional: true 1318 + 1319 + '@emnapi/wasi-threads@1.2.1': 1320 + dependencies: 1321 + tslib: 2.8.1 1322 + optional: true 1323 + 1324 + '@esbuild/aix-ppc64@0.27.3': 1325 + optional: true 1326 + 1327 + '@esbuild/android-arm64@0.27.3': 1328 + optional: true 1329 + 1330 + '@esbuild/android-arm@0.27.3': 1331 + optional: true 1332 + 1333 + '@esbuild/android-x64@0.27.3': 1334 + optional: true 1335 + 1336 + '@esbuild/darwin-arm64@0.27.3': 1337 + optional: true 1338 + 1339 + '@esbuild/darwin-x64@0.27.3': 1340 + optional: true 1341 + 1342 + '@esbuild/freebsd-arm64@0.27.3': 1343 + optional: true 1344 + 1345 + '@esbuild/freebsd-x64@0.27.3': 1346 + optional: true 1347 + 1348 + '@esbuild/linux-arm64@0.27.3': 1349 + optional: true 1350 + 1351 + '@esbuild/linux-arm@0.27.3': 1352 + optional: true 1353 + 1354 + '@esbuild/linux-ia32@0.27.3': 1355 + optional: true 1356 + 1357 + '@esbuild/linux-loong64@0.27.3': 1358 + optional: true 1359 + 1360 + '@esbuild/linux-mips64el@0.27.3': 1361 + optional: true 1362 + 1363 + '@esbuild/linux-ppc64@0.27.3': 1364 + optional: true 1365 + 1366 + '@esbuild/linux-riscv64@0.27.3': 1367 + optional: true 1368 + 1369 + '@esbuild/linux-s390x@0.27.3': 1370 + optional: true 1371 + 1372 + '@esbuild/linux-x64@0.27.3': 1373 + optional: true 1374 + 1375 + '@esbuild/netbsd-arm64@0.27.3': 1376 + optional: true 1377 + 1378 + '@esbuild/netbsd-x64@0.27.3': 1379 + optional: true 1380 + 1381 + '@esbuild/openbsd-arm64@0.27.3': 1382 + optional: true 1383 + 1384 + '@esbuild/openbsd-x64@0.27.3': 1385 + optional: true 1386 + 1387 + '@esbuild/openharmony-arm64@0.27.3': 1388 + optional: true 1389 + 1390 + '@esbuild/sunos-x64@0.27.3': 1391 + optional: true 1392 + 1393 + '@esbuild/win32-arm64@0.27.3': 1394 + optional: true 1395 + 1396 + '@esbuild/win32-ia32@0.27.3': 1397 + optional: true 1398 + 1399 + '@esbuild/win32-x64@0.27.3': 1400 + optional: true 1401 + 1402 + '@img/colour@1.1.0': {} 1403 + 1404 + '@img/sharp-darwin-arm64@0.34.5': 1405 + optionalDependencies: 1406 + '@img/sharp-libvips-darwin-arm64': 1.2.4 1407 + optional: true 1408 + 1409 + '@img/sharp-darwin-x64@0.34.5': 1410 + optionalDependencies: 1411 + '@img/sharp-libvips-darwin-x64': 1.2.4 1412 + optional: true 1413 + 1414 + '@img/sharp-libvips-darwin-arm64@1.2.4': 1415 + optional: true 1416 + 1417 + '@img/sharp-libvips-darwin-x64@1.2.4': 1418 + optional: true 1419 + 1420 + '@img/sharp-libvips-linux-arm64@1.2.4': 1421 + optional: true 1422 + 1423 + '@img/sharp-libvips-linux-arm@1.2.4': 1424 + optional: true 1425 + 1426 + '@img/sharp-libvips-linux-ppc64@1.2.4': 1427 + optional: true 1428 + 1429 + '@img/sharp-libvips-linux-riscv64@1.2.4': 1430 + optional: true 1431 + 1432 + '@img/sharp-libvips-linux-s390x@1.2.4': 1433 + optional: true 1434 + 1435 + '@img/sharp-libvips-linux-x64@1.2.4': 1436 + optional: true 1437 + 1438 + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': 1439 + optional: true 1440 + 1441 + '@img/sharp-libvips-linuxmusl-x64@1.2.4': 1442 + optional: true 1443 + 1444 + '@img/sharp-linux-arm64@0.34.5': 1445 + optionalDependencies: 1446 + '@img/sharp-libvips-linux-arm64': 1.2.4 1447 + optional: true 1448 + 1449 + '@img/sharp-linux-arm@0.34.5': 1450 + optionalDependencies: 1451 + '@img/sharp-libvips-linux-arm': 1.2.4 1452 + optional: true 1453 + 1454 + '@img/sharp-linux-ppc64@0.34.5': 1455 + optionalDependencies: 1456 + '@img/sharp-libvips-linux-ppc64': 1.2.4 1457 + optional: true 1458 + 1459 + '@img/sharp-linux-riscv64@0.34.5': 1460 + optionalDependencies: 1461 + '@img/sharp-libvips-linux-riscv64': 1.2.4 1462 + optional: true 1463 + 1464 + '@img/sharp-linux-s390x@0.34.5': 1465 + optionalDependencies: 1466 + '@img/sharp-libvips-linux-s390x': 1.2.4 1467 + optional: true 1468 + 1469 + '@img/sharp-linux-x64@0.34.5': 1470 + optionalDependencies: 1471 + '@img/sharp-libvips-linux-x64': 1.2.4 1472 + optional: true 1473 + 1474 + '@img/sharp-linuxmusl-arm64@0.34.5': 1475 + optionalDependencies: 1476 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 1477 + optional: true 1478 + 1479 + '@img/sharp-linuxmusl-x64@0.34.5': 1480 + optionalDependencies: 1481 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 1482 + optional: true 1483 + 1484 + '@img/sharp-wasm32@0.34.5': 1485 + dependencies: 1486 + '@emnapi/runtime': 1.10.0 1487 + optional: true 1488 + 1489 + '@img/sharp-win32-arm64@0.34.5': 1490 + optional: true 1491 + 1492 + '@img/sharp-win32-ia32@0.34.5': 1493 + optional: true 1494 + 1495 + '@img/sharp-win32-x64@0.34.5': 1496 + optional: true 1497 + 1498 + '@jridgewell/resolve-uri@3.1.2': {} 1499 + 1500 + '@jridgewell/sourcemap-codec@1.5.5': {} 1501 + 1502 + '@jridgewell/trace-mapping@0.3.9': 1503 + dependencies: 1504 + '@jridgewell/resolve-uri': 3.1.2 1505 + '@jridgewell/sourcemap-codec': 1.5.5 1506 + 1507 + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': 1508 + dependencies: 1509 + '@emnapi/core': 1.10.0 1510 + '@emnapi/runtime': 1.10.0 1511 + '@tybys/wasm-util': 0.10.2 1512 + optional: true 1513 + 1514 + '@noble/secp256k1@3.1.0': {} 1515 + 1516 + '@optique/core@1.0.2': {} 1517 + 1518 + '@optique/run@1.0.2': 1519 + dependencies: 1520 + '@optique/core': 1.0.2 1521 + 1522 + '@oxc-project/types@0.132.0': {} 1523 + 1524 + '@poppinss/colors@4.1.6': 1525 + dependencies: 1526 + kleur: 4.1.5 1527 + 1528 + '@poppinss/dumper@0.6.5': 1529 + dependencies: 1530 + '@poppinss/colors': 4.1.6 1531 + '@sindresorhus/is': 7.2.0 1532 + supports-color: 10.2.2 1533 + 1534 + '@poppinss/exception@1.2.3': {} 1535 + 1536 + '@rolldown/binding-android-arm64@1.0.2': 1537 + optional: true 1538 + 1539 + '@rolldown/binding-darwin-arm64@1.0.2': 1540 + optional: true 1541 + 1542 + '@rolldown/binding-darwin-x64@1.0.2': 1543 + optional: true 1544 + 1545 + '@rolldown/binding-freebsd-x64@1.0.2': 1546 + optional: true 1547 + 1548 + '@rolldown/binding-linux-arm-gnueabihf@1.0.2': 1549 + optional: true 1550 + 1551 + '@rolldown/binding-linux-arm64-gnu@1.0.2': 1552 + optional: true 1553 + 1554 + '@rolldown/binding-linux-arm64-musl@1.0.2': 1555 + optional: true 1556 + 1557 + '@rolldown/binding-linux-ppc64-gnu@1.0.2': 1558 + optional: true 1559 + 1560 + '@rolldown/binding-linux-s390x-gnu@1.0.2': 1561 + optional: true 1562 + 1563 + '@rolldown/binding-linux-x64-gnu@1.0.2': 1564 + optional: true 1565 + 1566 + '@rolldown/binding-linux-x64-musl@1.0.2': 1567 + optional: true 1568 + 1569 + '@rolldown/binding-openharmony-arm64@1.0.2': 1570 + optional: true 1571 + 1572 + '@rolldown/binding-wasm32-wasi@1.0.2': 1573 + dependencies: 1574 + '@emnapi/core': 1.10.0 1575 + '@emnapi/runtime': 1.10.0 1576 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) 1577 + optional: true 1578 + 1579 + '@rolldown/binding-win32-arm64-msvc@1.0.2': 1580 + optional: true 1581 + 1582 + '@rolldown/binding-win32-x64-msvc@1.0.2': 1583 + optional: true 1584 + 1585 + '@rolldown/pluginutils@1.0.1': {} 1586 + 1587 + '@sindresorhus/is@7.2.0': {} 1588 + 1589 + '@speed-highlight/core@1.2.15': {} 1590 + 1591 + '@standard-schema/spec@1.1.0': {} 1592 + 1593 + '@tybys/wasm-util@0.10.2': 1594 + dependencies: 1595 + tslib: 2.8.1 1596 + optional: true 1597 + 1598 + '@types/chai@5.2.3': 1599 + dependencies: 1600 + '@types/deep-eql': 4.0.2 1601 + assertion-error: 2.0.1 1602 + 1603 + '@types/deep-eql@4.0.2': {} 1604 + 1605 + '@types/estree@1.0.9': {} 1606 + 1607 + '@vitest/expect@4.1.7': 1608 + dependencies: 1609 + '@standard-schema/spec': 1.1.0 1610 + '@types/chai': 5.2.3 1611 + '@vitest/spy': 4.1.7 1612 + '@vitest/utils': 4.1.7 1613 + chai: 6.2.2 1614 + tinyrainbow: 3.1.0 1615 + 1616 + '@vitest/mocker@4.1.7(vite@8.0.14(esbuild@0.27.3))': 1617 + dependencies: 1618 + '@vitest/spy': 4.1.7 1619 + estree-walker: 3.0.3 1620 + magic-string: 0.30.21 1621 + optionalDependencies: 1622 + vite: 8.0.14(esbuild@0.27.3) 1623 + 1624 + '@vitest/pretty-format@4.1.7': 1625 + dependencies: 1626 + tinyrainbow: 3.1.0 1627 + 1628 + '@vitest/runner@4.1.7': 1629 + dependencies: 1630 + '@vitest/utils': 4.1.7 1631 + pathe: 2.0.3 1632 + 1633 + '@vitest/snapshot@4.1.7': 1634 + dependencies: 1635 + '@vitest/pretty-format': 4.1.7 1636 + '@vitest/utils': 4.1.7 1637 + magic-string: 0.30.21 1638 + pathe: 2.0.3 1639 + 1640 + '@vitest/spy@4.1.7': {} 1641 + 1642 + '@vitest/utils@4.1.7': 1643 + dependencies: 1644 + '@vitest/pretty-format': 4.1.7 1645 + convert-source-map: 2.0.0 1646 + tinyrainbow: 3.1.0 1647 + 1648 + assertion-error@2.0.1: {} 1649 + 1650 + blake3-wasm@2.1.5: {} 1651 + 1652 + chai@6.2.2: {} 1653 + 1654 + cjs-module-lexer@1.4.3: {} 1655 + 1656 + convert-source-map@2.0.0: {} 1657 + 1658 + cookie@1.1.1: {} 1659 + 1660 + detect-libc@2.1.2: {} 1661 + 1662 + error-stack-parser-es@1.0.5: {} 1663 + 1664 + es-module-lexer@2.1.0: {} 1665 + 1666 + esbuild@0.27.3: 1667 + optionalDependencies: 1668 + '@esbuild/aix-ppc64': 0.27.3 1669 + '@esbuild/android-arm': 0.27.3 1670 + '@esbuild/android-arm64': 0.27.3 1671 + '@esbuild/android-x64': 0.27.3 1672 + '@esbuild/darwin-arm64': 0.27.3 1673 + '@esbuild/darwin-x64': 0.27.3 1674 + '@esbuild/freebsd-arm64': 0.27.3 1675 + '@esbuild/freebsd-x64': 0.27.3 1676 + '@esbuild/linux-arm': 0.27.3 1677 + '@esbuild/linux-arm64': 0.27.3 1678 + '@esbuild/linux-ia32': 0.27.3 1679 + '@esbuild/linux-loong64': 0.27.3 1680 + '@esbuild/linux-mips64el': 0.27.3 1681 + '@esbuild/linux-ppc64': 0.27.3 1682 + '@esbuild/linux-riscv64': 0.27.3 1683 + '@esbuild/linux-s390x': 0.27.3 1684 + '@esbuild/linux-x64': 0.27.3 1685 + '@esbuild/netbsd-arm64': 0.27.3 1686 + '@esbuild/netbsd-x64': 0.27.3 1687 + '@esbuild/openbsd-arm64': 0.27.3 1688 + '@esbuild/openbsd-x64': 0.27.3 1689 + '@esbuild/openharmony-arm64': 0.27.3 1690 + '@esbuild/sunos-x64': 0.27.3 1691 + '@esbuild/win32-arm64': 0.27.3 1692 + '@esbuild/win32-ia32': 0.27.3 1693 + '@esbuild/win32-x64': 0.27.3 1694 + 1695 + esm-env@1.2.2: {} 1696 + 1697 + estree-walker@3.0.3: 1698 + dependencies: 1699 + '@types/estree': 1.0.9 1700 + 1701 + expect-type@1.3.0: {} 1702 + 1703 + fdir@6.5.0(picomatch@4.0.4): 1704 + optionalDependencies: 1705 + picomatch: 4.0.4 1706 + 1707 + fsevents@2.3.3: 1708 + optional: true 1709 + 1710 + kleur@4.1.5: {} 1711 + 1712 + lightningcss-android-arm64@1.32.0: 1713 + optional: true 1714 + 1715 + lightningcss-darwin-arm64@1.32.0: 1716 + optional: true 1717 + 1718 + lightningcss-darwin-x64@1.32.0: 1719 + optional: true 1720 + 1721 + lightningcss-freebsd-x64@1.32.0: 1722 + optional: true 1723 + 1724 + lightningcss-linux-arm-gnueabihf@1.32.0: 1725 + optional: true 1726 + 1727 + lightningcss-linux-arm64-gnu@1.32.0: 1728 + optional: true 1729 + 1730 + lightningcss-linux-arm64-musl@1.32.0: 1731 + optional: true 1732 + 1733 + lightningcss-linux-x64-gnu@1.32.0: 1734 + optional: true 1735 + 1736 + lightningcss-linux-x64-musl@1.32.0: 1737 + optional: true 1738 + 1739 + lightningcss-win32-arm64-msvc@1.32.0: 1740 + optional: true 1741 + 1742 + lightningcss-win32-x64-msvc@1.32.0: 1743 + optional: true 1744 + 1745 + lightningcss@1.32.0: 1746 + dependencies: 1747 + detect-libc: 2.1.2 1748 + optionalDependencies: 1749 + lightningcss-android-arm64: 1.32.0 1750 + lightningcss-darwin-arm64: 1.32.0 1751 + lightningcss-darwin-x64: 1.32.0 1752 + lightningcss-freebsd-x64: 1.32.0 1753 + lightningcss-linux-arm-gnueabihf: 1.32.0 1754 + lightningcss-linux-arm64-gnu: 1.32.0 1755 + lightningcss-linux-arm64-musl: 1.32.0 1756 + lightningcss-linux-x64-gnu: 1.32.0 1757 + lightningcss-linux-x64-musl: 1.32.0 1758 + lightningcss-win32-arm64-msvc: 1.32.0 1759 + lightningcss-win32-x64-msvc: 1.32.0 1760 + 1761 + magic-string@0.30.21: 1762 + dependencies: 1763 + '@jridgewell/sourcemap-codec': 1.5.5 1764 + 1765 + miniflare@4.20260521.0: 1766 + dependencies: 1767 + '@cspotcode/source-map-support': 0.8.1 1768 + sharp: 0.34.5 1769 + undici: 7.24.8 1770 + workerd: 1.20260521.1 1771 + ws: 8.20.1 1772 + youch: 4.1.0-beta.10 1773 + transitivePeerDependencies: 1774 + - bufferutil 1775 + - utf-8-validate 1776 + 1777 + nanoid@3.3.12: {} 1778 + 1779 + nanoid@5.1.11: {} 1780 + 1781 + obug@2.1.1: {} 1782 + 1783 + path-to-regexp@6.3.0: {} 1784 + 1785 + pathe@2.0.3: {} 1786 + 1787 + picocolors@1.1.1: {} 1788 + 1789 + picomatch@4.0.4: {} 1790 + 1791 + postcss@8.5.15: 1792 + dependencies: 1793 + nanoid: 3.3.12 1794 + picocolors: 1.1.1 1795 + source-map-js: 1.2.1 1796 + 1797 + prettier@3.8.3: {} 1798 + 1799 + rolldown@1.0.2: 1800 + dependencies: 1801 + '@oxc-project/types': 0.132.0 1802 + '@rolldown/pluginutils': 1.0.1 1803 + optionalDependencies: 1804 + '@rolldown/binding-android-arm64': 1.0.2 1805 + '@rolldown/binding-darwin-arm64': 1.0.2 1806 + '@rolldown/binding-darwin-x64': 1.0.2 1807 + '@rolldown/binding-freebsd-x64': 1.0.2 1808 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.2 1809 + '@rolldown/binding-linux-arm64-gnu': 1.0.2 1810 + '@rolldown/binding-linux-arm64-musl': 1.0.2 1811 + '@rolldown/binding-linux-ppc64-gnu': 1.0.2 1812 + '@rolldown/binding-linux-s390x-gnu': 1.0.2 1813 + '@rolldown/binding-linux-x64-gnu': 1.0.2 1814 + '@rolldown/binding-linux-x64-musl': 1.0.2 1815 + '@rolldown/binding-openharmony-arm64': 1.0.2 1816 + '@rolldown/binding-wasm32-wasi': 1.0.2 1817 + '@rolldown/binding-win32-arm64-msvc': 1.0.2 1818 + '@rolldown/binding-win32-x64-msvc': 1.0.2 1819 + 1820 + rosie-skills-darwin-arm64@0.6.4: 1821 + optional: true 1822 + 1823 + rosie-skills-freebsd-x64@0.6.4: 1824 + optional: true 1825 + 1826 + rosie-skills-linux-x64@0.6.4: 1827 + optional: true 1828 + 1829 + rosie-skills@0.6.4: 1830 + optionalDependencies: 1831 + rosie-skills-darwin-arm64: 0.6.4 1832 + rosie-skills-freebsd-x64: 0.6.4 1833 + rosie-skills-linux-x64: 0.6.4 1834 + 1835 + semver@7.8.1: {} 1836 + 1837 + sharp@0.34.5: 1838 + dependencies: 1839 + '@img/colour': 1.1.0 1840 + detect-libc: 2.1.2 1841 + semver: 7.8.1 1842 + optionalDependencies: 1843 + '@img/sharp-darwin-arm64': 0.34.5 1844 + '@img/sharp-darwin-x64': 0.34.5 1845 + '@img/sharp-libvips-darwin-arm64': 1.2.4 1846 + '@img/sharp-libvips-darwin-x64': 1.2.4 1847 + '@img/sharp-libvips-linux-arm': 1.2.4 1848 + '@img/sharp-libvips-linux-arm64': 1.2.4 1849 + '@img/sharp-libvips-linux-ppc64': 1.2.4 1850 + '@img/sharp-libvips-linux-riscv64': 1.2.4 1851 + '@img/sharp-libvips-linux-s390x': 1.2.4 1852 + '@img/sharp-libvips-linux-x64': 1.2.4 1853 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 1854 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 1855 + '@img/sharp-linux-arm': 0.34.5 1856 + '@img/sharp-linux-arm64': 0.34.5 1857 + '@img/sharp-linux-ppc64': 0.34.5 1858 + '@img/sharp-linux-riscv64': 0.34.5 1859 + '@img/sharp-linux-s390x': 0.34.5 1860 + '@img/sharp-linux-x64': 0.34.5 1861 + '@img/sharp-linuxmusl-arm64': 0.34.5 1862 + '@img/sharp-linuxmusl-x64': 0.34.5 1863 + '@img/sharp-wasm32': 0.34.5 1864 + '@img/sharp-win32-arm64': 0.34.5 1865 + '@img/sharp-win32-ia32': 0.34.5 1866 + '@img/sharp-win32-x64': 0.34.5 1867 + 1868 + siginfo@2.0.0: {} 1869 + 1870 + source-map-js@1.2.1: {} 1871 + 1872 + stackback@0.0.2: {} 1873 + 1874 + std-env@4.1.0: {} 1875 + 1876 + supports-color@10.2.2: {} 1877 + 1878 + tinybench@2.9.0: {} 1879 + 1880 + tinyexec@1.1.2: {} 1881 + 1882 + tinyglobby@0.2.16: 1883 + dependencies: 1884 + fdir: 6.5.0(picomatch@4.0.4) 1885 + picomatch: 4.0.4 1886 + 1887 + tinyrainbow@3.1.0: {} 1888 + 1889 + tslib@2.8.1: 1890 + optional: true 1891 + 1892 + typescript@5.9.3: {} 1893 + 1894 + undici@7.24.8: {} 1895 + 1896 + unenv@2.0.0-rc.24: 1897 + dependencies: 1898 + pathe: 2.0.3 1899 + 1900 + unicode-segmenter@0.14.5: {} 1901 + 1902 + valibot@1.4.0(typescript@5.9.3): 1903 + optionalDependencies: 1904 + typescript: 5.9.3 1905 + 1906 + vite@8.0.14(esbuild@0.27.3): 1907 + dependencies: 1908 + lightningcss: 1.32.0 1909 + picomatch: 4.0.4 1910 + postcss: 8.5.15 1911 + rolldown: 1.0.2 1912 + tinyglobby: 0.2.16 1913 + optionalDependencies: 1914 + esbuild: 0.27.3 1915 + fsevents: 2.3.3 1916 + 1917 + vitest@4.1.7(vite@8.0.14(esbuild@0.27.3)): 1918 + dependencies: 1919 + '@vitest/expect': 4.1.7 1920 + '@vitest/mocker': 4.1.7(vite@8.0.14(esbuild@0.27.3)) 1921 + '@vitest/pretty-format': 4.1.7 1922 + '@vitest/runner': 4.1.7 1923 + '@vitest/snapshot': 4.1.7 1924 + '@vitest/spy': 4.1.7 1925 + '@vitest/utils': 4.1.7 1926 + es-module-lexer: 2.1.0 1927 + expect-type: 1.3.0 1928 + magic-string: 0.30.21 1929 + obug: 2.1.1 1930 + pathe: 2.0.3 1931 + picomatch: 4.0.4 1932 + std-env: 4.1.0 1933 + tinybench: 2.9.0 1934 + tinyexec: 1.1.2 1935 + tinyglobby: 0.2.16 1936 + tinyrainbow: 3.1.0 1937 + vite: 8.0.14(esbuild@0.27.3) 1938 + why-is-node-running: 2.3.0 1939 + transitivePeerDependencies: 1940 + - msw 1941 + 1942 + why-is-node-running@2.3.0: 1943 + dependencies: 1944 + siginfo: 2.0.0 1945 + stackback: 0.0.2 1946 + 1947 + workerd@1.20260521.1: 1948 + optionalDependencies: 1949 + '@cloudflare/workerd-darwin-64': 1.20260521.1 1950 + '@cloudflare/workerd-darwin-arm64': 1.20260521.1 1951 + '@cloudflare/workerd-linux-64': 1.20260521.1 1952 + '@cloudflare/workerd-linux-arm64': 1.20260521.1 1953 + '@cloudflare/workerd-windows-64': 1.20260521.1 1954 + 1955 + wrangler@4.94.0(@cloudflare/workers-types@4.20260522.1): 1956 + dependencies: 1957 + '@cloudflare/kv-asset-handler': 0.5.0 1958 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260521.1) 1959 + blake3-wasm: 2.1.5 1960 + esbuild: 0.27.3 1961 + miniflare: 4.20260521.0 1962 + path-to-regexp: 6.3.0 1963 + rosie-skills: 0.6.4 1964 + unenv: 2.0.0-rc.24 1965 + workerd: 1.20260521.1 1966 + optionalDependencies: 1967 + '@cloudflare/workers-types': 4.20260522.1 1968 + fsevents: 2.3.3 1969 + transitivePeerDependencies: 1970 + - bufferutil 1971 + - utf-8-validate 1972 + 1973 + ws@8.20.1: {} 1974 + 1975 + youch-core@0.3.3: 1976 + dependencies: 1977 + '@poppinss/exception': 1.2.3 1978 + error-stack-parser-es: 1.0.5 1979 + 1980 + youch@4.1.0-beta.10: 1981 + dependencies: 1982 + '@poppinss/colors': 4.1.6 1983 + '@poppinss/dumper': 0.6.5 1984 + '@speed-highlight/core': 1.2.15 1985 + cookie: 1.1.1 1986 + youch-core: 0.3.3 1987 + 1988 + zod@3.25.76: {}
+12
pnpm-workspace.yaml
··· 1 + packages: 2 + - 'apps/*' 3 + - 'packages/*' 4 + allowBuilds: 5 + esbuild: true 6 + sharp: false 7 + workerd: true 8 + minimumReleaseAgeExclude: 9 + - '@cloudflare/vitest-pool-workers@0.16.9' 10 + - '@cloudflare/workers-types@4.20260522.1' 11 + - miniflare@4.20260521.0 12 + - wrangler@4.94.0
+28
tsconfig.base.json
··· 1 + { 2 + "$schema": "https://json.schemastore.org/tsconfig", 3 + "compilerOptions": { 4 + "target": "ES2022", 5 + "lib": ["ES2022"], 6 + "module": "ESNext", 7 + "moduleResolution": "Bundler", 8 + "moduleDetection": "force", 9 + "verbatimModuleSyntax": true, 10 + "resolveJsonModule": true, 11 + "isolatedModules": true, 12 + 13 + "strict": true, 14 + "noUncheckedIndexedAccess": true, 15 + "noImplicitOverride": true, 16 + "noFallthroughCasesInSwitch": true, 17 + "forceConsistentCasingInFileNames": true, 18 + 19 + "declaration": true, 20 + "declarationMap": true, 21 + "sourceMap": true, 22 + "composite": true, 23 + 24 + "skipLibCheck": true, 25 + "esModuleInterop": true, 26 + "allowSyntheticDefaultImports": true 27 + } 28 + }
+7
tsconfig.json
··· 1 + { 2 + "files": [], 3 + "references": [ 4 + { "path": "./packages/lexicons" }, 5 + { "path": "./apps/relay" } 6 + ] 7 + }