···11+-- Initial schema for the atproto notification relay.
22+-- All timestamps are unix milliseconds (Date.now()).
33+44+CREATE TABLE users (
55+ did TEXT PRIMARY KEY,
66+ created_at INTEGER NOT NULL,
77+ notify_pending_via_telegram INTEGER NOT NULL DEFAULT 0
88+);
99+1010+CREATE TABLE channels (
1111+ did TEXT NOT NULL,
1212+ platform TEXT NOT NULL,
1313+ platform_user_id TEXT NOT NULL,
1414+ display_name TEXT,
1515+ linked_at INTEGER NOT NULL,
1616+ PRIMARY KEY (did, platform)
1717+);
1818+CREATE UNIQUE INDEX channels_by_platform_user ON channels (platform, platform_user_id);
1919+2020+CREATE TABLE link_tokens (
2121+ token TEXT PRIMARY KEY,
2222+ did TEXT NOT NULL,
2323+ platform TEXT NOT NULL,
2424+ expires_at INTEGER NOT NULL
2525+);
2626+CREATE INDEX link_tokens_by_did ON link_tokens (did);
2727+CREATE INDEX link_tokens_by_expires ON link_tokens (expires_at);
2828+2929+CREATE TABLE senders (
3030+ did TEXT PRIMARY KEY,
3131+ handle TEXT,
3232+ display_name TEXT,
3333+ avatar_url TEXT,
3434+ profile_fetched_at INTEGER
3535+);
3636+3737+CREATE TABLE pending_requests (
3838+ id TEXT PRIMARY KEY,
3939+ recipient_did TEXT NOT NULL,
4040+ sender_did TEXT NOT NULL,
4141+ reason TEXT,
4242+ created_at INTEGER NOT NULL,
4343+ expires_at INTEGER NOT NULL,
4444+ UNIQUE (recipient_did, sender_did)
4545+);
4646+CREATE INDEX pending_by_recipient ON pending_requests (recipient_did);
4747+CREATE INDEX pending_by_expires ON pending_requests (expires_at);
4848+4949+CREATE TABLE grants (
5050+ recipient_did TEXT NOT NULL,
5151+ sender_did TEXT NOT NULL,
5252+ granted_at INTEGER NOT NULL,
5353+ muted INTEGER NOT NULL DEFAULT 0,
5454+ PRIMARY KEY (recipient_did, sender_did)
5555+);
5656+CREATE INDEX grants_by_recipient ON grants (recipient_did);
5757+5858+CREATE TABLE delivery_log (
5959+ id TEXT PRIMARY KEY,
6060+ recipient_did TEXT NOT NULL,
6161+ sender_did TEXT NOT NULL,
6262+ title TEXT,
6363+ delivered_count INTEGER NOT NULL,
6464+ created_at INTEGER NOT NULL
6565+);
6666+CREATE INDEX delivery_by_recipient ON delivery_log (recipient_did, created_at DESC);
···11+import type { Did, Nsid } from '@atcute/lexicons';
22+import type { ServiceJwtVerifier } from '@atcute/xrpc-server/auth';
33+44+/**
55+ * Sender path (used by `requestPermission`, `send`).
66+ *
77+ * The bearer JWT is issued by the *sender's* DID and signed with the sender's
88+ * atproto signing key (resolved from their DID document). On any failure the
99+ * verifier throws `AuthRequiredError` with a populated `WWW-Authenticate`.
1010+ */
1111+export async function verifySenderRequest(
1212+ verifier: ServiceJwtVerifier,
1313+ request: Request,
1414+ lxm: Nsid,
1515+): Promise<{ senderDid: Did }> {
1616+ const { issuer } = await verifier.verifyRequest(request, { lxm });
1717+ return { senderDid: issuer };
1818+}
···11+import type { Did, Nsid } from '@atcute/lexicons';
22+import type { ServiceJwtVerifier } from '@atcute/xrpc-server/auth';
33+44+/**
55+ * User path (used by every procedure/query except `requestPermission`/`send`).
66+ *
77+ * The bearer JWT is issued by the *end user's* DID, minted by their PDS via
88+ * `com.atproto.server.getServiceAuth` (the website obtains these on the user's
99+ * behalf). On any failure the verifier throws `AuthRequiredError` with a
1010+ * populated `WWW-Authenticate`.
1111+ */
1212+export async function verifyUserRequest(
1313+ verifier: ServiceJwtVerifier,
1414+ request: Request,
1515+ lxm: Nsid,
1616+): Promise<{ userDid: Did }> {
1717+ const { issuer } = await verifier.verifyRequest(request, { lxm });
1818+ return { userDid: issuer };
1919+}
···11+import type { Did } from '@atcute/lexicons';
22+import type { AtprotoAudience } from '@atcute/lexicons/syntax';
33+import { ServiceJwtVerifier } from '@atcute/xrpc-server/auth';
44+55+import type { Env } from '../env';
66+import { makeResolver } from '../identity/resolve';
77+88+// One verifier per Env (constructing the resolver is cheap but pointless to
99+// repeat each request; the underlying DID-doc cache lives in KV anyway).
1010+const verifiers = new WeakMap<Env, ServiceJwtVerifier>();
1111+1212+export function getVerifier(env: Env): ServiceJwtVerifier {
1313+ let verifier = verifiers.get(env);
1414+ if (verifier === undefined) {
1515+ verifier = makeVerifier(env);
1616+ verifiers.set(env, verifier);
1717+ }
1818+ return verifier;
1919+}
2020+2121+export function makeVerifier(env: Env): ServiceJwtVerifier {
2222+ const relayDid = env.RELAY_DID as Did;
2323+ return new ServiceJwtVerifier({
2424+ // Accept tokens addressed to the bare relay DID or the service-ref form.
2525+ acceptAudiences: [relayDid, `${relayDid}#notif_relay` as AtprotoAudience],
2626+ resolver: makeResolver(env.CACHE),
2727+ maxAge: 300,
2828+ clockLeeway: 5,
2929+ });
3030+}
···11+-- Canonical schema reference for the relay's D1 database.
22+--
33+-- This file is documentation: the source of truth applied to D1 is the numbered
44+-- migrations in `apps/relay/migrations/`. Keep this in sync with them (currently
55+-- it mirrors migrations/0001_init.sql).
66+--
77+-- All timestamps are unix milliseconds (Date.now()).
88+99+CREATE TABLE users (
1010+ did TEXT PRIMARY KEY,
1111+ created_at INTEGER NOT NULL,
1212+ notify_pending_via_telegram INTEGER NOT NULL DEFAULT 0
1313+);
1414+1515+CREATE TABLE channels (
1616+ did TEXT NOT NULL,
1717+ platform TEXT NOT NULL,
1818+ platform_user_id TEXT NOT NULL,
1919+ display_name TEXT,
2020+ linked_at INTEGER NOT NULL,
2121+ PRIMARY KEY (did, platform)
2222+);
2323+CREATE UNIQUE INDEX channels_by_platform_user ON channels (platform, platform_user_id);
2424+2525+CREATE TABLE link_tokens (
2626+ token TEXT PRIMARY KEY,
2727+ did TEXT NOT NULL,
2828+ platform TEXT NOT NULL,
2929+ expires_at INTEGER NOT NULL
3030+);
3131+CREATE INDEX link_tokens_by_did ON link_tokens (did);
3232+CREATE INDEX link_tokens_by_expires ON link_tokens (expires_at);
3333+3434+CREATE TABLE senders (
3535+ did TEXT PRIMARY KEY,
3636+ handle TEXT,
3737+ display_name TEXT,
3838+ avatar_url TEXT,
3939+ profile_fetched_at INTEGER
4040+);
4141+4242+CREATE TABLE pending_requests (
4343+ id TEXT PRIMARY KEY,
4444+ recipient_did TEXT NOT NULL,
4545+ sender_did TEXT NOT NULL,
4646+ reason TEXT,
4747+ created_at INTEGER NOT NULL,
4848+ expires_at INTEGER NOT NULL,
4949+ UNIQUE (recipient_did, sender_did)
5050+);
5151+CREATE INDEX pending_by_recipient ON pending_requests (recipient_did);
5252+CREATE INDEX pending_by_expires ON pending_requests (expires_at);
5353+5454+CREATE TABLE grants (
5555+ recipient_did TEXT NOT NULL,
5656+ sender_did TEXT NOT NULL,
5757+ granted_at INTEGER NOT NULL,
5858+ muted INTEGER NOT NULL DEFAULT 0,
5959+ PRIMARY KEY (recipient_did, sender_did)
6060+);
6161+CREATE INDEX grants_by_recipient ON grants (recipient_did);
6262+6363+CREATE TABLE delivery_log (
6464+ id TEXT PRIMARY KEY,
6565+ recipient_did TEXT NOT NULL,
6666+ sender_did TEXT NOT NULL,
6767+ title TEXT,
6868+ delivered_count INTEGER NOT NULL,
6969+ created_at INTEGER NOT NULL
7070+);
7171+CREATE INDEX delivery_by_recipient ON delivery_log (recipient_did, created_at DESC);
···11+import { RateLimitExceededError, XRPCError } from '@atcute/xrpc-server';
22+33+// Re-export the error classes handlers throw, so call sites import from one place.
44+// The router's default exception handler turns any thrown `XRPCError` into the
55+// correct HTTP response, so handlers should always `throw` these (never return
66+// ad-hoc error JSON).
77+export {
88+ AuthRequiredError,
99+ ForbiddenError,
1010+ RateLimitExceededError,
1111+ XRPCError,
1212+} from '@atcute/xrpc-server';
1313+1414+/**
1515+ * 403 with the lexicon-declared `NotAuthorized` error name (used by `send` and
1616+ * `requestPermission`). We use a raw `XRPCError` rather than `ForbiddenError`
1717+ * because the latter's error name is `Forbidden`.
1818+ */
1919+export function notAuthorized(message = 'Not authorized to notify this recipient'): XRPCError {
2020+ return new XRPCError({ status: 403, error: 'NotAuthorized', message });
2121+}
2222+2323+/**
2424+ * 429 `RateLimitExceeded` with a `Retry-After` header (whole seconds, min 1).
2525+ */
2626+export function rateLimited(
2727+ retryAfterSeconds: number,
2828+ message = 'Rate limit exceeded',
2929+): RateLimitExceededError {
3030+ const seconds = Math.max(1, Math.ceil(retryAfterSeconds));
3131+ return new RateLimitExceededError({
3232+ message,
3333+ headers: { 'Retry-After': String(seconds) },
3434+ });
3535+}
···11+import { applyD1Migrations, env } from 'cloudflare:test';
22+33+// Runs once per test file (before the per-test storage isolation snapshot), so
44+// every test sees a fully-migrated, empty database.
55+await applyD1Migrations(env.DB, env.TEST_MIGRATIONS);
···11+// @atcute/lex-cli configuration.
22+//
33+// NOTE: lex-cli loads its config via dynamic `import()`, which only resolves
44+// `lex.config.js` / `lex.config.ts` (not `.json`). We use `.js` so `pnpm
55+// generate` works on the project's Node version without TypeScript loader flags.
66+import { defineLexiconConfig } from '@atcute/lex-cli';
77+88+export default defineLexiconConfig({
99+ generate: {
1010+ files: ['lexicons/**/*.json'],
1111+ outdir: 'src/lexicons/',
1212+ },
1313+});
···11+// Public entrypoint for the shared lexicons package.
22+//
33+// Re-exports the code generated by `@atcute/lex-cli` (run `pnpm generate`).
44+// Each lexicon is exposed as a namespace, e.g. `ToolsAtmoNotifsSend`, carrying:
55+// - `mainSchema` — the XRPC metadata you pass to `XRPCRouter.add*`
66+// - `$input` / `$output` / `$params` — inferred request/response types
77+// - view types for `ref`'d defs (e.g. `GrantView`)
88+//
99+// Importing these modules also augments `@atcute/lexicons/ambient`'s
1010+// `XRPCQueries` / `XRPCProcedures` registries with our NSIDs.
1111+export * from './lexicons/index.js';