diff --git a/.env.example b/.env.example index d87a7be..e71ddcf 100644 --- a/.env.example +++ b/.env.example @@ -5,15 +5,13 @@ COOKIE_SECRET= # Set to your tunnel URL to use a confidential client in dev # OAUTH_PUBLIC_URL=https://your-tunnel.trycloudflare.com -# --- Reddit-on-Bluesky --- -# Main bot account (used to own the curate list and as the atmo identity). -BSKY_HANDLE= -BSKY_PASSWORD= - -# Random string used to AES-GCM encrypt stored community app passwords. -# Generate with: openssl rand -base64 32 +# Random string used to AES-GCM encrypt each community's Rookery signing key +# in D1. Generate with: openssl rand -base64 32 COMMUNITY_ENCRYPTION_KEY= +# Shared secret required by the Rookery PDS when creating new community accounts. +ROOKERY_SIGNUP_SECRET= + # Optional: if set, /_cron/check-dms requires ?secret=... when called manually. # The scheduled handler bypasses this via an internal header. # CRON_SECRET= diff --git a/.gitignore b/.gitignore index c2044c0..c6d11eb 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ Thumbs.db !.env.example !.env.test .dev.vars +.rookery-accounts/ # Vite vite.config.js.timestamp-* diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql index dd1259b..c0dfc46 100644 --- a/migrations/0001_init.sql +++ b/migrations/0001_init.sql @@ -1,11 +1,19 @@ --- Community accounts we manage. --- password_ciphertext + password_iv: AES-GCM encrypted app password. +-- Community accounts we manage. Each row represents a rookery-hosted +-- account with a secp256k1 WelcomeMat keypair. +-- +-- Key storage: +-- secret_key_ciphertext + secret_key_iv — AES-GCM encrypted hex-encoded +-- secp256k1 secret key (32 bytes → 64 hex chars → ~90 bytes encrypted) +-- public_jwk_json — serialized EC public JWK +-- thumbprint — RFC 7638 JWK thumbprint (the account's DPoP identity) CREATE TABLE communities ( did TEXT PRIMARY KEY, handle TEXT NOT NULL, pds TEXT NOT NULL, - password_ciphertext TEXT NOT NULL, - password_iv TEXT NOT NULL, + secret_key_ciphertext TEXT NOT NULL, + secret_key_iv TEXT NOT NULL, + public_jwk_json TEXT NOT NULL, + thumbprint TEXT NOT NULL, display_name TEXT, avatar TEXT, description TEXT, @@ -37,10 +45,3 @@ CREATE INDEX idx_posts_refresh ON posts(last_refreshed_at); -- Dedup: never create two quote posts for the same (community, quoted post). CREATE UNIQUE INDEX idx_posts_community_quoted ON posts(community_did, quoted_post_uri); - --- Simple key/value meta store (for curate list URI, etc). -CREATE TABLE meta ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); diff --git a/package.json b/package.json index b624701..8380887 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "atmo-social", + "name": "atmo-garden", "private": true, "version": "0.1.0", "type": "module", @@ -59,6 +59,8 @@ "@foxui/text": "^0.8.2", "@foxui/time": "^0.8.2", "@lucide/svelte": "^1.7.0", + "@noble/curves": "^2.0.1", + "@noble/hashes": "^2.0.1", "@tailwindcss/typography": "^0.5.19", "@use-gesture/vanilla": "^10.3.1", "emoji-picker-element": "^1.29.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5ca8f6..aed44e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,12 @@ importers: '@lucide/svelte': specifier: ^1.7.0 version: 1.7.0(svelte@5.55.0) + '@noble/curves': + specifier: ^2.0.1 + version: 2.0.1 + '@noble/hashes': + specifier: ^2.0.1 + version: 2.0.1 '@tailwindcss/typography': specifier: ^0.5.19 version: 0.5.19(tailwindcss@4.2.2) @@ -857,6 +863,14 @@ packages: '@napi-rs/wasm-runtime@1.1.1': resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + '@noble/curves@2.0.1': + resolution: {integrity: sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.0.1': + resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} + engines: {node: '>= 20.19.0'} + '@number-flow/svelte@0.4.0': resolution: {integrity: sha512-9tnowrlZlBV3IVe3Gm1V7yXSf4Ugag2k7iW45xqb04HXSa1ApEImopvGWAjJpHDvS849o+UCb0YH461Mtde9lA==} peerDependencies: @@ -3102,6 +3116,12 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@noble/curves@2.0.1': + dependencies: + '@noble/hashes': 2.0.1 + + '@noble/hashes@2.0.1': {} + '@number-flow/svelte@0.4.0(svelte@5.55.0)': dependencies: esm-env: 1.2.2 diff --git a/scripts/create-rookery-account.ts b/scripts/create-rookery-account.ts new file mode 100644 index 0000000..c5fcc21 --- /dev/null +++ b/scripts/create-rookery-account.ts @@ -0,0 +1,226 @@ +// Create a rookery account via the full WelcomeMat signup flow (ES256K): +// 1. Generate a secp256k1 keypair +// 2. Fetch ToS, sign it (raw 64-byte compact ES256K signature) +// 3. Build a self-signed wm+jwt access token +// 4. Build a DPoP proof +// 5. POST /api/signup with X-Rookery-Signup-Secret header +// +// Usage: +// pnpm tsx scripts/create-rookery-account.ts +// +// The private key (hex-encoded 32 bytes) is written to +// .rookery-accounts/.json so we can use it to auth as that account +// later. DO NOT commit that directory. + +import { mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { createHash, randomUUID } from 'crypto'; +import { secp256k1 } from '@noble/curves/secp256k1.js'; + +function loadEnv(path: string) { + try { + const text = readFileSync(path, 'utf8'); + for (const line of text.split('\n')) { + const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)\s*$/i); + if (!m) continue; + let val = m[2]; + if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1); + if (!process.env[m[1]]) process.env[m[1]] = val; + } + } catch { + /* ignore */ + } +} +loadEnv('.env'); +loadEnv('.dev.vars'); + +const PDS_HOSTNAME = process.env.ROOKERY_HOSTNAME ?? 'pds.atmo.garden'; +// HANDLE_DOMAIN is enforced server-side; informational here. +void process.env.ROOKERY_HANDLE_DOMAIN; +const SIGNUP_SECRET = process.env.ROOKERY_SIGNUP_SECRET; + +if (!SIGNUP_SECRET) { + console.error('Missing ROOKERY_SIGNUP_SECRET in .env / .dev.vars'); + process.exit(1); +} + +const shortHandle = process.argv[2]; +if (!shortHandle) { + console.error('Usage: pnpm tsx scripts/create-rookery-account.ts '); + process.exit(1); +} +if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(shortHandle)) { + console.error('Handle must be [a-z0-9-], single label'); + process.exit(1); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function b64url(input: Uint8Array): string { + return Buffer.from(input).toString('base64url'); +} + +function b64urlStr(s: string): string { + return Buffer.from(s).toString('base64url'); +} + +function sha256b64url(input: string | Uint8Array): string { + return Buffer.from(createHash('sha256').update(input).digest()).toString('base64url'); +} + +type EcPublicJwk = { kty: 'EC'; crv: 'secp256k1'; x: string; y: string }; + +function buildPublicJwk(secretKey: Uint8Array): EcPublicJwk { + const pubUncompressed = secp256k1.getPublicKey(secretKey, false); // 65 bytes + const x = pubUncompressed.slice(1, 33); + const y = pubUncompressed.slice(33, 65); + return { + kty: 'EC', + crv: 'secp256k1', + x: b64url(x), + y: b64url(y) + }; +} + +function computeThumbprint(jwk: EcPublicJwk): string { + // RFC 7638 canonical form for EC keys: {crv, kty, x, y} in order. + return sha256b64url( + JSON.stringify({ crv: jwk.crv, kty: 'EC', x: jwk.x, y: jwk.y }) + ); +} + +function signEs256k(secretKey: Uint8Array, message: Uint8Array | string): Uint8Array { + const msgBytes = + typeof message === 'string' ? new TextEncoder().encode(message) : message; + // noble 2.x prehashes with sha256 by default → signature is over sha256(msg), + // matching rookery's validateDpopProof / verifyEs256kMessageSignature. + const sig = secp256k1.sign(msgBytes, secretKey); + // noble 2.x returns Uint8Array of 64 bytes (compact r||s) by default. + if (sig.length !== 64) { + throw new Error(`unexpected signature length: ${sig.length}`); + } + return sig; +} + +function signJwt( + header: Record, + payload: Record, + secretKey: Uint8Array +): string { + const signingInput = `${b64urlStr(JSON.stringify(header))}.${b64urlStr(JSON.stringify(payload))}`; + const sig = signEs256k(secretKey, signingInput); + return `${signingInput}.${b64url(sig)}`; +} + +// --------------------------------------------------------------------------- +// 1. Keypair +// --------------------------------------------------------------------------- + +console.log(`[1/5] Generating secp256k1 keypair…`); +const secretKey = secp256k1.utils.randomSecretKey(); +const publicJwk = buildPublicJwk(secretKey); +const thumbprint = computeThumbprint(publicJwk); +console.log(` thumbprint = ${thumbprint}`); + +// --------------------------------------------------------------------------- +// 2 + 3. ToS + access token +// --------------------------------------------------------------------------- + +console.log(`[2/5] Fetching and signing ToS…`); +const tosText = await fetch(`https://${PDS_HOSTNAME}/tos`).then((r) => r.text()); +const tosHash = sha256b64url(tosText); +const tosSignature = b64url(signEs256k(secretKey, tosText)); + +const accessToken = signJwt( + { typ: 'wm+jwt', alg: 'ES256K' }, + { + tos_hash: tosHash, + aud: `https://${PDS_HOSTNAME}`, + cnf: { jkt: thumbprint }, + iat: Math.floor(Date.now() / 1000) + }, + secretKey +); + +// --------------------------------------------------------------------------- +// 4. DPoP proof for signup +// --------------------------------------------------------------------------- + +console.log(`[3/5] Building DPoP proof…`); +const signupUrl = `https://${PDS_HOSTNAME}/api/signup`; +const signupHtu = (() => { + const u = new URL(signupUrl); + return u.origin + u.pathname; +})(); +const dpopProof = signJwt( + { typ: 'dpop+jwt', alg: 'ES256K', jwk: publicJwk }, + { + jti: randomUUID(), + htm: 'POST', + htu: signupHtu, + iat: Math.floor(Date.now() / 1000) + }, + secretKey +); + +// --------------------------------------------------------------------------- +// 5. POST /api/signup +// --------------------------------------------------------------------------- + +console.log(`[4/5] POST ${signupUrl} (handle=${shortHandle})…`); +const res = await fetch(signupUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + DPoP: dpopProof, + 'X-Rookery-Signup-Secret': SIGNUP_SECRET + }, + body: JSON.stringify({ + handle: shortHandle, + tos_signature: tosSignature, + access_token: accessToken + }) +}); + +const body = (await res.json()) as { + did?: string; + handle?: string; + access_token?: string; + token_type?: string; + error?: string; + message?: string; +}; + +if (!res.ok || !body.did) { + console.error(` FAILED ${res.status}:`, body); + process.exit(1); +} + +console.log(` did = ${body.did}`); +console.log(` handle = ${body.handle}`); + +// --------------------------------------------------------------------------- +// 6. Save the key material +// --------------------------------------------------------------------------- + +console.log(`[5/5] Saving key material…`); +try { + mkdirSync('.rookery-accounts', { recursive: true }); +} catch { + /* exists */ +} + +const record = { + did: body.did, + handle: body.handle, + pds: `https://${PDS_HOSTNAME}`, + thumbprint, + publicJwk, + secretKeyHex: Buffer.from(secretKey).toString('hex'), + createdAt: new Date().toISOString() +}; +writeFileSync(`.rookery-accounts/${shortHandle}.json`, JSON.stringify(record, null, 2)); +console.log(` wrote .rookery-accounts/${shortHandle}.json`); + +console.log(`\n✅ Account ready: @${body.handle} (${body.did})`); diff --git a/scripts/debug-dms.ts b/scripts/debug-dms.ts deleted file mode 100644 index d58b9fb..0000000 --- a/scripts/debug-dms.ts +++ /dev/null @@ -1,138 +0,0 @@ -// Standalone diagnostic: logs in as the BSKY_HANDLE account and dumps -// all conversations + their latest messages so we can see what DMs exist -// and why they're not being picked up by the bot. -// -// Run: pnpm tsx scripts/debug-dms.ts - -import { readFileSync } from 'fs'; -import { Client, CredentialManager } from '@atcute/client'; -import type { Did } from '@atcute/lexicons'; - -// Minimal .env loader (no external dep). -function loadEnv(path: string) { - try { - const text = readFileSync(path, 'utf8'); - for (const line of text.split('\n')) { - const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)\s*$/i); - if (!m) continue; - let val = m[2]; - if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1); - if (!process.env[m[1]]) process.env[m[1]] = val; - } - } catch { - // ignore - } -} - -loadEnv('.env'); -loadEnv('.dev.vars'); - -const HANDLE = process.env.BSKY_HANDLE; -const PASSWORD = process.env.BSKY_PASSWORD; - -if (!HANDLE || !PASSWORD) { - console.error('Missing BSKY_HANDLE / BSKY_PASSWORD in env'); - process.exit(1); -} - -async function main() { - console.log(`Logging in as ${HANDLE}…`); - - // Resolve the PDS via the public handle resolver. - const didRes = await fetch( - `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${HANDLE}` - ); - if (!didRes.ok) { - console.error('Failed to resolve handle:', didRes.status); - process.exit(1); - } - const { did } = (await didRes.json()) as { did: string }; - console.log(` did = ${did}`); - - const plcRes = await fetch(`https://plc.directory/${did}`); - if (!plcRes.ok) { - console.error('Failed to resolve DID doc:', plcRes.status); - process.exit(1); - } - const doc = (await plcRes.json()) as { - service?: { id: string; serviceEndpoint: string }[]; - }; - const pds = doc.service?.find((s) => s.id === '#atproto_pds')?.serviceEndpoint; - if (!pds) { - console.error('No PDS in DID doc'); - process.exit(1); - } - console.log(` pds = ${pds}`); - - const manager = new CredentialManager({ service: pds }); - await manager.login({ identifier: HANDLE!, password: PASSWORD! }); - console.log(` login OK, session.did = ${manager.session?.did}`); - - const chatClient = new Client({ - handler: manager, - proxy: { did: 'did:web:api.bsky.chat' as Did, serviceId: '#bsky_chat' } - }); - - for (const status of ['accepted', 'request']) { - console.log(`\n=== listConvos (status=${status}) ===`); - const res = await chatClient.get('chat.bsky.convo.listConvos', { - params: { limit: 50, status } - }); - if (!res.ok) { - console.log(` FAILED status=${res.status}`, res.data); - continue; - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const convos = res.data.convos as any[]; - console.log(` ${convos.length} convos`); - for (const c of convos) { - console.log( - ` - convoId=${c.id} rev=${c.rev} unread=${c.unreadCount} members=${c.members - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .map((m: any) => m.handle ?? m.did) - .join(', ')}` - ); - if (c.lastMessage) { - console.log( - ` lastMessage: ${JSON.stringify({ - $type: c.lastMessage.$type, - text: c.lastMessage.text, - sender: c.lastMessage.sender?.did, - rev: c.lastMessage.rev - })}` - ); - } - - // Pull ALL messages via pagination. - let cursor: string | undefined; - let total = 0; - for (let page = 0; page < 10; page++) { - const msgRes = await chatClient.get('chat.bsky.convo.getMessages', { - params: { convoId: c.id, limit: 100, ...(cursor ? { cursor } : {}) } - }); - if (!msgRes.ok) { - console.log(` getMessages FAILED ${msgRes.status}`, msgRes.data); - break; - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const msgs = msgRes.data.messages as any[]; - if (page === 0) console.log(` page ${page}: ${msgs.length} messages`); - else console.log(` page ${page}: +${msgs.length} more`); - for (const m of msgs) { - console.log( - ` [${m.rev}] ${m.$type ?? 'msg'} sender=${m.sender?.did ?? '?'} sentAt=${m.sentAt} text=${JSON.stringify(m.text ?? null)} embed=${JSON.stringify(m.embed ?? null)} facets=${JSON.stringify(m.facets ?? null)}` - ); - } - total += msgs.length; - cursor = msgRes.data.cursor; - if (!cursor) break; - } - console.log(` total messages seen: ${total}`); - } - } -} - -main().catch((e) => { - console.error(e); - process.exit(1); -}); diff --git a/scripts/delete-post.ts b/scripts/delete-post.ts deleted file mode 100644 index 3adc50c..0000000 --- a/scripts/delete-post.ts +++ /dev/null @@ -1,53 +0,0 @@ -// One-off: delete a post by rkey from the main bot account. -// Usage: pnpm tsx scripts/delete-post.ts - -import { readFileSync } from 'fs'; -import { CredentialManager, Client } from '@atcute/client'; - -function loadEnv(path: string) { - try { - const text = readFileSync(path, 'utf8'); - for (const line of text.split('\n')) { - const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)\s*$/i); - if (!m) continue; - let val = m[2]; - if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1); - if (!process.env[m[1]]) process.env[m[1]] = val; - } - } catch { - /* ignore */ - } -} - -loadEnv('.env'); -loadEnv('.dev.vars'); - -const rkey = process.argv[2]; -if (!rkey) { - console.error('Usage: pnpm tsx scripts/delete-post.ts '); - process.exit(1); -} - -const didRes = await fetch( - `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${process.env.BSKY_HANDLE}` -); -const { did } = (await didRes.json()) as { did: string }; - -const plc = await fetch(`https://plc.directory/${did}`); -const doc = (await plc.json()) as { service?: { id: string; serviceEndpoint: string }[] }; -const pds = doc.service?.find((s) => s.id === '#atproto_pds')?.serviceEndpoint; -if (!pds) throw new Error('No PDS'); - -const manager = new CredentialManager({ service: pds }); -await manager.login({ identifier: process.env.BSKY_HANDLE!, password: process.env.BSKY_PASSWORD! }); -const client = new Client({ handler: manager }); - -const res = await client.post('com.atproto.repo.deleteRecord', { - input: { - repo: did as `did:${string}:${string}`, - collection: 'app.bsky.feed.post', - rkey - } -}); - -console.log(res.ok ? `deleted ${rkey}` : `failed: ${JSON.stringify(res.data)}`); diff --git a/scripts/delete-rookery-account.ts b/scripts/delete-rookery-account.ts new file mode 100644 index 0000000..3fa3fbb --- /dev/null +++ b/scripts/delete-rookery-account.ts @@ -0,0 +1,81 @@ +// Tombstone a rookery account on the PLC directory + deactivate it on +// rookery. IRREVERSIBLE. +// +// Usage: pnpm tsx scripts/delete-rookery-account.ts +// +// pnpm tsx scripts/delete-rookery-account.ts alice.atmo.garden +// pnpm tsx scripts/delete-rookery-account.ts did:plc:xxx + +import { readFileSync, unlinkSync } from 'fs'; + +function loadEnv(path: string) { + try { + const text = readFileSync(path, 'utf8'); + for (const line of text.split('\n')) { + const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)\s*$/i); + if (!m) continue; + let val = m[2]; + if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1); + if (!process.env[m[1]]) process.env[m[1]] = val; + } + } catch { + /* ignore */ + } +} +loadEnv('.env'); +loadEnv('.dev.vars'); + +const PDS_HOSTNAME = process.env.ROOKERY_HOSTNAME ?? 'pds.atmo.garden'; +const SIGNUP_SECRET = process.env.ROOKERY_SIGNUP_SECRET; + +if (!SIGNUP_SECRET) { + console.error('Missing ROOKERY_SIGNUP_SECRET in .env / .dev.vars'); + process.exit(1); +} + +const target = process.argv[2]; +if (!target) { + console.error('Usage: pnpm tsx scripts/delete-rookery-account.ts '); + process.exit(1); +} + +const url = `https://${PDS_HOSTNAME}/api/admin/delete-account`; +console.log(`POST ${url}`); +console.log(` handleOrDid: ${target}`); + +const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Rookery-Signup-Secret': SIGNUP_SECRET + }, + body: JSON.stringify({ handleOrDid: target }) +}); + +const body = await res.text(); +console.log(`\nstatus: ${res.status}`); +console.log(`body: ${body}`); + +if (!res.ok) { + process.exit(1); +} + +// If we have a local keyfile for this handle (short label), remove it. +const shortLabel = target.includes('.') ? target.split('.')[0] : null; +if (shortLabel) { + const keyfile = `.rookery-accounts/${shortLabel}.json`; + try { + unlinkSync(keyfile); + console.log(`\nremoved local keyfile: ${keyfile}`); + } catch { + /* didn't exist */ + } +} + +console.log('\n✅ tombstone submitted'); +console.log(' The PLC operation may take a few seconds to propagate.'); +console.log( + ' Verify: https://plc.directory/' + + (target.startsWith('did:') ? target : '(check via resolveHandle)') + + '/log/audit' +); diff --git a/scripts/post-as-alice.ts b/scripts/post-as-alice.ts new file mode 100644 index 0000000..8c68942 --- /dev/null +++ b/scripts/post-as-alice.ts @@ -0,0 +1,110 @@ +// Write an app.bsky.feed.post record to alice's rookery repo using ES256K. +// +// Usage: pnpm tsx scripts/post-as-alice.ts "some post text" + +import { readFileSync } from 'fs'; +import { createHash, randomUUID } from 'crypto'; +import { secp256k1 } from '@noble/curves/secp256k1.js'; +import * as TID from '@atcute/tid'; + +type EcPublicJwk = { kty: 'EC'; crv: 'secp256k1'; x: string; y: string }; +type AccountFile = { + did: string; + handle: string; + pds: string; + thumbprint: string; + publicJwk: EcPublicJwk; + secretKeyHex: string; +}; + +const text = process.argv.slice(2).join(' ') || 'hello from alice via rookery'; +const account = JSON.parse( + readFileSync('.rookery-accounts/alice.json', 'utf8') +) as AccountFile; +const secretKey = new Uint8Array(Buffer.from(account.secretKeyHex, 'hex')); +console.log(`posting as ${account.handle} (${account.did})`); +console.log(`text: ${JSON.stringify(text)}`); + +// --- helpers --- +function b64url(input: Uint8Array): string { + return Buffer.from(input).toString('base64url'); +} +function sha256b64url(input: string): string { + return Buffer.from(createHash('sha256').update(input).digest()).toString('base64url'); +} +function signEs256k(secretKey: Uint8Array, message: string | Uint8Array): Uint8Array { + const msgBytes = + typeof message === 'string' ? new TextEncoder().encode(message) : message; + const sig = secp256k1.sign(msgBytes, secretKey); + if (sig.length !== 64) throw new Error(`bad sig length ${sig.length}`); + return sig; +} +function signJwt( + header: Record, + payload: Record, + secretKey: Uint8Array +): string { + const signingInput = `${Buffer.from(JSON.stringify(header)).toString('base64url')}.${Buffer.from(JSON.stringify(payload)).toString('base64url')}`; + const sig = signEs256k(secretKey, signingInput); + return `${signingInput}.${b64url(sig)}`; +} + +// --- access token --- +const tosText = await fetch(`${account.pds}/tos`).then((r) => r.text()); +const accessToken = signJwt( + { typ: 'wm+jwt', alg: 'ES256K' }, + { + tos_hash: sha256b64url(tosText), + aud: account.pds, + cnf: { jkt: account.thumbprint }, + iat: Math.floor(Date.now() / 1000) + }, + secretKey +); + +function buildDpop(method: string, url: string): string { + const u = new URL(url); + return signJwt( + { typ: 'dpop+jwt', alg: 'ES256K', jwk: account.publicJwk }, + { + jti: randomUUID(), + htm: method, + htu: u.origin + u.pathname, + iat: Math.floor(Date.now() / 1000), + ath: sha256b64url(accessToken) + }, + secretKey + ); +} + +// --- createRecord --- +const url = `${account.pds}/xrpc/com.atproto.repo.createRecord`; +const res = await fetch(url, { + method: 'POST', + headers: { + Authorization: `DPoP ${accessToken}`, + DPoP: buildDpop('POST', url), + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + repo: account.did, + collection: 'app.bsky.feed.post', + rkey: TID.now(), + record: { + $type: 'app.bsky.feed.post', + text, + createdAt: new Date().toISOString() + } + }) +}); + +const body = await res.text(); +console.log('\nstatus:', res.status); +console.log('body:', body); +if (res.ok) { + const data = JSON.parse(body) as { uri: string; cid: string }; + const rkey = data.uri.split('/').pop(); + console.log(`\n✅ posted`); + console.log(` ${data.uri}`); + console.log(` https://bsky.app/profile/${account.handle}/post/${rkey}`); +} diff --git a/scripts/read-alice-dms.ts b/scripts/read-alice-dms.ts new file mode 100644 index 0000000..f2508d7 --- /dev/null +++ b/scripts/read-alice-dms.ts @@ -0,0 +1,182 @@ +// Read alice's DMs via rookery's getServiceAuth endpoint. +// Uses the ES256K WelcomeMat flow. +// +// Usage: pnpm tsx scripts/read-alice-dms.ts [handle] + +import { readFileSync } from 'fs'; +import { createHash, randomUUID } from 'crypto'; +import { secp256k1 } from '@noble/curves/secp256k1.js'; + +type EcPublicJwk = { kty: 'EC'; crv: 'secp256k1'; x: string; y: string }; + +type AccountFile = { + did: string; + handle: string; + pds: string; + thumbprint: string; + publicJwk: EcPublicJwk; + secretKeyHex: string; +}; + +const HANDLE = process.argv[2] ?? 'alice'; +const account = JSON.parse( + readFileSync(`.rookery-accounts/${HANDLE}.json`, 'utf8') +) as AccountFile; +const secretKey = new Uint8Array(Buffer.from(account.secretKeyHex, 'hex')); +console.log(`logged-in identity: ${account.handle} (${account.did})`); + +// --- helpers --- +function b64url(input: Uint8Array): string { + return Buffer.from(input).toString('base64url'); +} +function sha256b64url(input: string): string { + return Buffer.from(createHash('sha256').update(input).digest()).toString('base64url'); +} +function signEs256k(secretKey: Uint8Array, message: string | Uint8Array): Uint8Array { + const msgBytes = + typeof message === 'string' ? new TextEncoder().encode(message) : message; + const sig = secp256k1.sign(msgBytes, secretKey); + if (sig.length !== 64) throw new Error(`bad sig length ${sig.length}`); + return sig; +} +function signJwt( + header: Record, + payload: Record, + secretKey: Uint8Array +): string { + const signingInput = `${Buffer.from(JSON.stringify(header)).toString('base64url')}.${Buffer.from(JSON.stringify(payload)).toString('base64url')}`; + const sig = signEs256k(secretKey, signingInput); + return `${signingInput}.${b64url(sig)}`; +} + +// --- access token (re-usable across this script run) --- +const tosText = await fetch(`${account.pds}/tos`).then((r) => r.text()); +const accessToken = signJwt( + { typ: 'wm+jwt', alg: 'ES256K' }, + { + tos_hash: sha256b64url(tosText), + aud: account.pds, + cnf: { jkt: account.thumbprint }, + iat: Math.floor(Date.now() / 1000) + }, + secretKey +); + +function buildDpop(method: string, url: string): string { + const u = new URL(url); + return signJwt( + { typ: 'dpop+jwt', alg: 'ES256K', jwk: account.publicJwk }, + { + jti: randomUUID(), + htm: method, + htu: u.origin + u.pathname, + iat: Math.floor(Date.now() / 1000), + ath: sha256b64url(accessToken) + }, + secretKey + ); +} + +async function getServiceAuth(aud: string, lxm: string): Promise { + const url = `${account.pds}/xrpc/com.atproto.server.getServiceAuth?aud=${encodeURIComponent(aud)}&lxm=${encodeURIComponent(lxm)}`; + const res = await fetch(url, { + headers: { + Authorization: `DPoP ${accessToken}`, + DPoP: buildDpop('GET', url) + } + }); + const body = await res.text(); + if (!res.ok) throw new Error(`getServiceAuth ${res.status}: ${body}`); + return (JSON.parse(body) as { token: string }).token; +} + +function decodeJwt(jwt: string): unknown { + const [h, p] = jwt.split('.'); + const decode = (s: string) => JSON.parse(Buffer.from(s, 'base64url').toString()); + return { header: decode(h), payload: decode(p) }; +} + +// --- listConvos --- +console.log('\n[1/3] getServiceAuth(aud=did:web:api.bsky.chat, lxm=chat.bsky.convo.listConvos)'); +const listToken = await getServiceAuth('did:web:api.bsky.chat', 'chat.bsky.convo.listConvos'); +console.log(' token:', JSON.stringify(decodeJwt(listToken), null, 2)); + +console.log('\n[2/3] api.bsky.chat/listConvos …'); +const convosRes = await fetch( + 'https://api.bsky.chat/xrpc/chat.bsky.convo.listConvos?limit=20', + { headers: { Authorization: `Bearer ${listToken}` } } +); +console.log(' status:', convosRes.status); +const convosBody = await convosRes.text(); +if (!convosRes.ok) { + console.log(' body:', convosBody); + process.exit(1); +} +type Convo = { + id: string; + rev: string; + unreadCount: number; + status?: string; + members: Array<{ did: string; handle?: string }>; +}; +const convosData = JSON.parse(convosBody) as { convos: Convo[]; cursor?: string }; +console.log(` ${convosData.convos.length} convos`); +for (const c of convosData.convos) { + console.log( + ` - ${c.id} status=${c.status ?? '?'} unread=${c.unreadCount} members=${c.members + .map((m) => m.handle ?? m.did) + .join(', ')}` + ); +} + +// Also check "request" status. +const reqRes = await fetch( + 'https://api.bsky.chat/xrpc/chat.bsky.convo.listConvos?limit=20&status=request', + { headers: { Authorization: `Bearer ${listToken}` } } +); +if (reqRes.ok) { + const reqData = JSON.parse(await reqRes.text()) as { convos: Convo[] }; + if (reqData.convos.length > 0) { + console.log(` (requests) ${reqData.convos.length} convos:`); + for (const c of reqData.convos) { + console.log( + ` - ${c.id} members=${c.members.map((m) => m.handle ?? m.did).join(', ')}` + ); + } + convosData.convos.push(...reqData.convos); + } +} + +// --- getMessages per convo --- +console.log('\n[3/3] getMessages for each convo'); +for (const convo of convosData.convos) { + console.log(`\nconvo ${convo.id} (${convo.members.map((m) => m.handle ?? m.did).join(', ')})`); + const mToken = await getServiceAuth('did:web:api.bsky.chat', 'chat.bsky.convo.getMessages'); + const msgRes = await fetch( + `https://api.bsky.chat/xrpc/chat.bsky.convo.getMessages?convoId=${convo.id}&limit=20`, + { headers: { Authorization: `Bearer ${mToken}` } } + ); + if (!msgRes.ok) { + console.log(' FAILED', msgRes.status, (await msgRes.text()).slice(0, 200)); + continue; + } + type Msg = { + $type?: string; + id: string; + rev: string; + sender?: { did: string }; + text?: string; + sentAt: string; + embed?: { record?: { uri?: string } }; + }; + const { messages } = (await msgRes.json()) as { messages: Msg[] }; + console.log(` ${messages.length} messages (newest first):`); + for (const m of messages) { + const embedUri = m.embed?.record?.uri; + console.log( + ` - [${m.rev}] sender=${m.sender?.did ?? '?'} sentAt=${m.sentAt}` + ); + console.log(` text: ${JSON.stringify(m.text ?? null)}`); + if (embedUri) console.log(` embed: ${embedUri}`); + } +} diff --git a/src/app.d.ts b/src/app.d.ts index b7126f7..9c8189c 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -23,9 +23,9 @@ declare global { OAUTH_PUBLIC_URL: string; PROFILE_CACHE?: KVNamespace; DB: D1Database; - BSKY_HANDLE: string; - BSKY_PASSWORD: string; COMMUNITY_ENCRYPTION_KEY: string; + ROOKERY_HOSTNAME: string; + ROOKERY_SIGNUP_SECRET: string; CRON_SECRET?: string; }; } diff --git a/src/lib/atproto/server/chat.remote.ts b/src/lib/atproto/server/chat.remote.ts deleted file mode 100644 index 8bb30be..0000000 --- a/src/lib/atproto/server/chat.remote.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { error } from '@sveltejs/kit'; -import { command, getRequestEvent } from '$app/server'; -import { Client } from '@atcute/client'; -import type { Did } from '@atcute/lexicons'; -import * as v from 'valibot'; - -function getChatClient() { - const { locals } = getRequestEvent(); - if (!locals.session || !locals.did) error(401, 'Not authenticated'); - - return new Client({ - handler: locals.session, - proxy: { did: 'did:web:api.bsky.chat' as Did, serviceId: '#bsky_chat' } - }); -} - -export const listConvos = command( - v.object({ - status: v.optional(v.string()), - cursor: v.optional(v.string()) - }), - async (input) => { - const client = getChatClient(); - - const res = await client.get('chat.bsky.convo.listConvos', { - params: { - limit: 50, - ...(input.status ? { status: input.status } : {}), - ...(input.cursor ? { cursor: input.cursor } : {}) - } - }); - - if (!res.ok) error(res.status, 'Failed to list conversations'); - return { convos: res.data.convos, cursor: res.data.cursor ?? null }; - } -); - -export const getConvo = command( - v.object({ - convoId: v.string() - }), - async (input) => { - const client = getChatClient(); - - const res = await client.get('chat.bsky.convo.getConvo', { - params: { convoId: input.convoId } - }); - - if (!res.ok) error(res.status, 'Failed to load conversation'); - return res.data.convo; - } -); - -export const getMessages = command( - v.object({ - convoId: v.string(), - cursor: v.optional(v.string()) - }), - async (input) => { - const client = getChatClient(); - - const res = await client.get('chat.bsky.convo.getMessages', { - params: { - convoId: input.convoId, - limit: 50, - ...(input.cursor ? { cursor: input.cursor } : {}) - } - }); - - if (!res.ok) error(res.status, 'Failed to load messages'); - return { messages: res.data.messages, cursor: res.data.cursor ?? null }; - } -); - -export const sendMessage = command( - v.object({ - convoId: v.string(), - text: v.string() - }), - async (input) => { - const client = getChatClient(); - - const res = await client.post('chat.bsky.convo.sendMessage', { - input: { - convoId: input.convoId, - message: { text: input.text } - } - }); - - if (!res.ok) error(res.status, 'Failed to send message'); - return res.data; - } -); - -export const acceptConvo = command( - v.object({ - convoId: v.string() - }), - async (input) => { - const client = getChatClient(); - - const res = await client.post('chat.bsky.convo.acceptConvo', { - input: { convoId: input.convoId } - }); - - if (!res.ok) error(res.status, 'Failed to accept conversation'); - return { ok: true }; - } -); - -export const updateRead = command( - v.object({ - convoId: v.string() - }), - async (input) => { - const client = getChatClient(); - - await client.post('chat.bsky.convo.updateRead', { - input: { convoId: input.convoId } - }).catch(() => {}); - - return { ok: true }; - } -); - -export const addReaction = command( - v.object({ - convoId: v.string(), - messageId: v.string(), - value: v.string() - }), - async (input) => { - const client = getChatClient(); - - const res = await client.post('chat.bsky.convo.addReaction', { - input: { - convoId: input.convoId, - messageId: input.messageId, - value: input.value - } - }); - - if (!res.ok) error(res.status, 'Failed to add reaction'); - return res.data; - } -); - -export const removeReaction = command( - v.object({ - convoId: v.string(), - messageId: v.string(), - value: v.string() - }), - async (input) => { - const client = getChatClient(); - - const res = await client.post('chat.bsky.convo.removeReaction', { - input: { - convoId: input.convoId, - messageId: input.messageId, - value: input.value - } - }); - - if (!res.ok) error(res.status, 'Failed to remove reaction'); - return res.data; - } -); - -export const deleteMessage = command( - v.object({ - convoId: v.string(), - messageId: v.string() - }), - async (input) => { - const client = getChatClient(); - - const res = await client.post('chat.bsky.convo.deleteMessageForSelf', { - input: { - convoId: input.convoId, - messageId: input.messageId - } - }); - - if (!res.ok) error(res.status, 'Failed to delete message'); - return res.data; - } -); diff --git a/src/lib/atproto/server/notifications.remote.ts b/src/lib/atproto/server/notifications.remote.ts deleted file mode 100644 index 04315b3..0000000 --- a/src/lib/atproto/server/notifications.remote.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { error } from '@sveltejs/kit'; -import { command, getRequestEvent } from '$app/server'; -import * as v from 'valibot'; - -export const listNotifications = command( - v.object({ - cursor: v.optional(v.string()) - }), - async (input) => { - const { locals } = getRequestEvent(); - if (!locals.client || !locals.did) error(401, 'Not authenticated'); - - const res = await locals.client.get('app.bsky.notification.listNotifications', { - params: { - limit: 30, - ...(input.cursor ? { cursor: input.cursor } : {}) - } - }); - - if (!res.ok) error(res.status, 'Failed to list notifications'); - return { - notifications: res.data.notifications, - cursor: res.data.cursor ?? null, - seenAt: res.data.seenAt ?? null - }; - } -); - -export const getUnreadCount = command( - v.object({}), - async () => { - const { locals } = getRequestEvent(); - if (!locals.client || !locals.did) error(401, 'Not authenticated'); - - const res = await locals.client.get('app.bsky.notification.getUnreadCount', { - params: {} - }); - - if (!res.ok) error(res.status, 'Failed to get unread count'); - return { count: res.data.count }; - } -); - -export const updateSeen = command( - v.object({}), - async () => { - const { locals } = getRequestEvent(); - if (!locals.client || !locals.did) error(401, 'Not authenticated'); - - const res = await locals.client.post('app.bsky.notification.updateSeen', { - as: null, - input: { - seenAt: new Date().toISOString() - } - }); - - if (!res.ok) error(res.status, 'Failed to update seen'); - return { ok: true }; - } -); diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte deleted file mode 100644 index e69de29..0000000 diff --git a/src/lib/components/chat/ChatMessage.svelte b/src/lib/components/chat/ChatMessage.svelte deleted file mode 100644 index e69de29..0000000 diff --git a/src/lib/components/chat/index.ts b/src/lib/components/chat/index.ts deleted file mode 100644 index 8509214..0000000 --- a/src/lib/components/chat/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as Chat } from './Chat.svelte'; diff --git a/src/lib/components/index.ts b/src/lib/components/index.ts index d8b785a..8e665b8 100644 --- a/src/lib/components/index.ts +++ b/src/lib/components/index.ts @@ -7,7 +7,6 @@ export * from './social-icons'; export * from './swiper-cards'; export * from './user-profile'; export * from './github-corner'; -export * from './chat'; export * from './emoji-picker'; export * from './bluesky-post'; export * from './nested-comments'; diff --git a/src/lib/reddit/RedditPostCard.svelte b/src/lib/reddit/RedditPostCard.svelte index a24b79f..74ff078 100644 --- a/src/lib/reddit/RedditPostCard.svelte +++ b/src/lib/reddit/RedditPostCard.svelte @@ -27,11 +27,7 @@ const communityHandle = $derived( 'community_handle' in row ? (row as PostWithCommunity).community_handle : undefined ); - const communityDisplayName = $derived( - 'community_display_name' in row - ? ((row as PostWithCommunity).community_display_name ?? undefined) - : undefined - ); + const communityShort = $derived(communityHandle ? communityHandle.split('.')[0] : undefined); const communityAvatar = $derived( 'community_avatar' in row ? ((row as PostWithCommunity).community_avatar ?? undefined) : undefined ); @@ -63,46 +59,42 @@ } -
- {#if showCommunity && communityHandle} +
+ {#if showCommunity && communityShort} - {communityDisplayName ?? communityHandle} - · - @{communityHandle} + c/{communityShort} · {fmtRelative(row.indexed_at)} {:else} -
+
{fmtRelative(row.indexed_at)}
{/if} -

{row.title}

+

{row.title}

{#if quoted && quotedPostData} -
-
- `/profile/${handle}`} - onclickhandle={(handle) => goto(`/profile/${handle}`)} - /> -
+
+ `/profile/${handle}`} + onclickhandle={(handle) => goto(`/profile/${handle}`)} + />
{:else if quoted === null} -
+
(quoted post unavailable)
{:else} -
+
diff --git a/src/lib/reddit/bot.ts b/src/lib/reddit/bot.ts index dd0e449..6cd3bd6 100644 --- a/src/lib/reddit/bot.ts +++ b/src/lib/reddit/bot.ts @@ -1,187 +1,62 @@ -// Reddit-on-Bluesky bot: server-side logic for logging in as community -// accounts, managing the central curate list, reading DMs, and creating -// quote posts from DM submissions. - -import { Client, CredentialManager, simpleFetchHandler } from '@atcute/client'; +// Reddit-on-Bluesky bot: server-side logic for creating rookery-backed +// community accounts, reading their DMs via Bluesky's chat service, and +// turning submission DMs into quote posts on Bluesky. +// +// All auth goes through rookery's WelcomeMat DPoP flow (secp256k1). Chat +// operations use service-auth JWTs that rookery issues via +// getServiceAuth, which api.bsky.chat accepts because the token is signed +// by the account's secp256k1 repo signing key (published in its DID doc). + +import { Client, simpleFetchHandler } from '@atcute/client'; import type { Did, ResourceUri } from '@atcute/lexicons'; -import * as TID from '@atcute/tid'; -import { getPDS, actorToDid } from '$lib/atproto/methods'; import { decryptPassword, encryptPassword } from './crypto'; import { getCombinedFeed, - getMeta, getPostsDueForRefresh, hasSubmission, insertCommunity, insertPost, listCommunities, - setMeta, updateCommunityProfile, updatePostMetrics, type CommunityRow } from './db'; - -const PROCESSED_REACTION = '✅'; import { getPostCid, parseSubmission } from './submission'; +import { + WelcomeMatClient, + createRookeryAccount, + type EcPublicJwk, + type RookeryAccount +} from './welcomemat'; const PUBLIC_APPVIEW = 'https://public.api.bsky.app'; -const CURATE_LIST_META_KEY = 'curate_list_uri'; -const CURATE_LIST_RKEY_META_KEY = 'curate_list_rkey'; - -export type AuthedAccount = { - did: Did; - handle: string; - pds: string; - manager: CredentialManager; - pdsClient: Client; // repo ops - chatClient: Client; // chat proxied to api.bsky.chat -}; +const CHAT_SERVICE_DID = 'did:web:api.bsky.chat'; +const CHAT_SERVICE_BASE = 'https://api.bsky.chat/xrpc'; +const PROCESSED_REACTION = '✅'; // ------------------------------------------------------------------------- -// Auth helpers +// Loading a client for a stored community // ------------------------------------------------------------------------- -/** - * Log in to a PDS using an app password and return a ready-to-use pair of - * clients — one for the PDS (for createRecord etc) and one proxied through - * the bsky chat service (for chat.bsky.convo.*). - */ -export async function loginWithAppPassword( - identifier: string, - password: string, - pdsUrl?: string -): Promise { - // Resolve identifier → PDS if we don't already know the PDS. We rely on - // the login response for the authoritative did/handle. - let pds = pdsUrl; - - if (!pds) { - const resolvedDid = await actorToDid(identifier); - const resolvedPds = await getPDS(resolvedDid); - if (!resolvedPds) throw new Error(`No PDS found for ${identifier}`); - pds = resolvedPds; - } - - const manager = new CredentialManager({ service: pds }); - const session = await manager.login({ identifier, password }); - const handle = session.handle; - const did: Did = session.did; - - const pdsClient = new Client({ handler: manager }); - const chatClient = new Client({ - handler: manager, - proxy: { did: 'did:web:api.bsky.chat' as Did, serviceId: '#bsky_chat' } - }); - - return { did, handle, pds, manager, pdsClient, chatClient }; -} - -export async function loginMainAccount(env: App.Platform['env']): Promise { - if (!env.BSKY_HANDLE || !env.BSKY_PASSWORD) { - throw new Error('BSKY_HANDLE / BSKY_PASSWORD not configured'); - } - return loginWithAppPassword(env.BSKY_HANDLE, env.BSKY_PASSWORD); -} - -export async function loginCommunity( - row: CommunityRow, - env: App.Platform['env'] -): Promise { - const password = await decryptPassword( - row.password_ciphertext, - row.password_iv, +async function loadClient( + env: App.Platform['env'], + row: CommunityRow +): Promise { + const secretKeyHex = await decryptPassword( + row.secret_key_ciphertext, + row.secret_key_iv, env.COMMUNITY_ENCRYPTION_KEY ); - return loginWithAppPassword(row.handle, password, row.pds); -} - -// ------------------------------------------------------------------------- -// Curate list management -// ------------------------------------------------------------------------- - -/** - * Ensure that the main account has a curate list for tracking community - * accounts. Returns the list's AT URI. Creates the list if missing. - */ -export async function ensureCurateList( - main: AuthedAccount, - db: D1Database -): Promise { - const cached = await getMeta(db, CURATE_LIST_META_KEY); - if (cached) return cached as ResourceUri; - - // Check whether the main account already has a reddit communities list. - const existing = await main.pdsClient.get('com.atproto.repo.listRecords', { - params: { - repo: main.did, - collection: 'app.bsky.graph.list', - limit: 100 - } - }); - - if (existing.ok) { - for (const rec of existing.data.records) { - const value = rec.value as { name?: string; purpose?: string }; - if ( - value?.purpose === 'app.bsky.graph.defs#curatelist' && - value?.name === 'atmo communities' - ) { - const uri = rec.uri as ResourceUri; - await setMeta(db, CURATE_LIST_META_KEY, uri); - const rkey = uri.split('/').pop() ?? ''; - await setMeta(db, CURATE_LIST_RKEY_META_KEY, rkey); - return uri; - } - } - } - - // Create the list. - const rkey = TID.now(); - const create = await main.pdsClient.post('com.atproto.repo.createRecord', { - input: { - repo: main.did, - collection: 'app.bsky.graph.list', - rkey, - record: { - $type: 'app.bsky.graph.list', - purpose: 'app.bsky.graph.defs#curatelist', - name: 'atmo communities', - description: 'Communities registered on atmo.social (reddit-on-bluesky)', - createdAt: new Date().toISOString() - } - } - }); - - if (!create.ok) throw new Error('Failed to create curate list'); - const uri = create.data.uri as ResourceUri; - await setMeta(db, CURATE_LIST_META_KEY, uri); - await setMeta(db, CURATE_LIST_RKEY_META_KEY, rkey); - return uri; -} - -/** - * Add a community DID to the curate list. - */ -export async function addCommunityToList( - main: AuthedAccount, - listUri: ResourceUri, - communityDid: Did -): Promise { - const rkey = TID.now(); - const res = await main.pdsClient.post('com.atproto.repo.createRecord', { - input: { - repo: main.did, - collection: 'app.bsky.graph.listitem', - rkey, - record: { - $type: 'app.bsky.graph.listitem', - list: listUri, - subject: communityDid, - createdAt: new Date().toISOString() - } - } - }); - if (!res.ok) throw new Error('Failed to add listitem'); + const publicJwk = JSON.parse(row.public_jwk_json) as EcPublicJwk; + const account: RookeryAccount = { + did: row.did, + handle: row.handle, + pds: row.pds, + thumbprint: row.thumbprint, + publicJwk, + secretKeyHex + }; + return WelcomeMatClient.forAccount(account); } // ------------------------------------------------------------------------- @@ -193,47 +68,76 @@ export type RegisterResult = { handle: string; }; +/** + * Create a brand-new rookery community account. + * + * Flow: + * 1. Generate a secp256k1 keypair + * 2. Sign up on rookery (which auto-seeds chat.bsky.actor.declaration + * and a bot-labeled app.bsky.actor.profile) + * 3. Encrypt the secret key and store everything in D1 + */ export async function registerCommunity( env: App.Platform['env'], db: D1Database, - identifier: string, - password: string + shortHandle: string ): Promise { - // Log in as the community to validate credentials. - const community = await loginWithAppPassword(identifier, password); + if (!env.ROOKERY_HOSTNAME) throw new Error('ROOKERY_HOSTNAME not configured'); + if (!env.ROOKERY_SIGNUP_SECRET) + throw new Error('ROOKERY_SIGNUP_SECRET not configured'); + + const { account } = await createRookeryAccount({ + hostname: env.ROOKERY_HOSTNAME, + handle: shortHandle, + signupSecret: env.ROOKERY_SIGNUP_SECRET + }); - // Store encrypted credentials in D1. - const { ciphertext, iv } = await encryptPassword(password, env.COMMUNITY_ENCRYPTION_KEY); + // Encrypt the hex-encoded secret key. + const { ciphertext, iv } = await encryptPassword( + account.secretKeyHex, + env.COMMUNITY_ENCRYPTION_KEY + ); - // Fetch profile metadata for the registration preview. + // Fetch the profile the signup seeded so we can cache display name / + // description for the UI. Rookery writes these immediately but the relay + // may not have propagated them yet — fall back to the short handle. const appview = new Client({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW }) }); - const profile = await appview.get('app.bsky.actor.getProfile', { - params: { actor: community.did } - }); + let displayName: string | null = shortHandle; + let avatar: string | null = null; + let description: string | null = `https://${account.handle}`; + try { + const profile = await appview.get('app.bsky.actor.getProfile', { + params: { actor: account.did as Did } + }); + if (profile.ok) { + displayName = profile.data.displayName ?? shortHandle; + avatar = profile.data.avatar ?? null; + description = profile.data.description ?? description; + } + } catch { + // non-fatal — relay hasn't indexed yet + } await insertCommunity(db, { - did: community.did, - handle: community.handle, - pds: community.pds, - password_ciphertext: ciphertext, - password_iv: iv, - display_name: profile.ok ? profile.data.displayName ?? null : null, - avatar: profile.ok ? profile.data.avatar ?? null : null, - description: profile.ok ? profile.data.description ?? null : null + did: account.did as Did, + handle: account.handle, + pds: account.pds, + secret_key_ciphertext: ciphertext, + secret_key_iv: iv, + public_jwk_json: JSON.stringify(account.publicJwk), + thumbprint: account.thumbprint, + display_name: displayName, + avatar, + description }); - // Add the DID to the main curate list. - const main = await loginMainAccount(env); - const listUri = await ensureCurateList(main, db); - await addCommunityToList(main, listUri, community.did); - - return { did: community.did, handle: community.handle }; + return { did: account.did as Did, handle: account.handle }; } // ------------------------------------------------------------------------- -// DM processing + quote posting +// Chat types // ------------------------------------------------------------------------- type ChatConvo = { @@ -263,107 +167,164 @@ type ChatMessage = { reactions?: ChatReaction[]; }; +// ------------------------------------------------------------------------- +// api.bsky.chat calls via service-auth tokens +// ------------------------------------------------------------------------- + +/** + * Call an api.bsky.chat XRPC method as the given community. Automatically + * fetches a service-auth token from rookery scoped to the method. + */ +async function chatCall( + client: WelcomeMatClient, + method: string, + init: { method?: 'GET' | 'POST'; query?: Record; body?: unknown } +): Promise<{ ok: boolean; status: number; data: T | { error: string; message?: string } }> { + const httpMethod = init.method ?? 'GET'; + const token = await client.getServiceAuth(CHAT_SERVICE_DID, method); + + const url = new URL(`${CHAT_SERVICE_BASE}/${method}`); + if (init.query) { + for (const [k, v] of Object.entries(init.query)) { + if (v !== undefined) url.searchParams.set(k, v); + } + } + + const req: RequestInit = { + method: httpMethod, + headers: { + Authorization: `Bearer ${token}`, + ...(init.body !== undefined ? { 'Content-Type': 'application/json' } : {}) + }, + ...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}) + }; + + const res = await fetch(url.toString(), req); + const text = await res.text(); + let data: unknown; + try { + data = text ? JSON.parse(text) : {}; + } catch { + data = { error: 'ParseError', message: text }; + } + return { ok: res.ok, status: res.status, data: data as never }; +} + +async function listConvos( + client: WelcomeMatClient, + status: 'accepted' | 'request' +): Promise { + const res = await chatCall<{ convos: ChatConvo[] }>( + client, + 'chat.bsky.convo.listConvos', + { method: 'GET', query: { limit: '50', status } } + ); + if (!res.ok) return []; + return (res.data as { convos: ChatConvo[] }).convos ?? []; +} + +async function getMessages( + client: WelcomeMatClient, + convoId: string +): Promise { + const res = await chatCall<{ messages: ChatMessage[] }>( + client, + 'chat.bsky.convo.getMessages', + { method: 'GET', query: { convoId, limit: '100' } } + ); + if (!res.ok) return []; + return (res.data as { messages: ChatMessage[] }).messages ?? []; +} + +async function acceptConvo(client: WelcomeMatClient, convoId: string): Promise { + await chatCall(client, 'chat.bsky.convo.acceptConvo', { + method: 'POST', + body: { convoId } + }); +} + +async function addReaction( + client: WelcomeMatClient, + convoId: string, + messageId: string, + value: string +): Promise { + await chatCall(client, 'chat.bsky.convo.addReaction', { + method: 'POST', + body: { convoId, messageId, value } + }).catch(() => {}); +} + +// ------------------------------------------------------------------------- +// DM processing +// ------------------------------------------------------------------------- + /** - * Poll all convos for a community and turn fresh submission DMs into quote - * posts. Returns the number of quote posts created. + * Poll both accepted + request convos for a community and turn fresh + * submission DMs into quote posts. Returns the number of quote posts + * created this call. */ export async function processCommunityDms( env: App.Platform['env'], db: D1Database, row: CommunityRow ): Promise { - const community = await loginCommunity(row, env); + const client = await loadClient(env, row); let created = 0; - // Fetch all convos (both accepted and requested). We have to do two calls - // because listConvos filters by status. - const convoLists = await Promise.all([ - community.chatClient.get('chat.bsky.convo.listConvos', { - params: { limit: 50, status: 'accepted' } - }), - community.chatClient.get('chat.bsky.convo.listConvos', { - params: { limit: 50, status: 'request' } - }) + const [accepted, requested] = await Promise.all([ + listConvos(client, 'accepted'), + listConvos(client, 'request') ]); - - const convos: ChatConvo[] = []; - for (const res of convoLists) { - if (res.ok) { - for (const c of res.data.convos as unknown as ChatConvo[]) convos.push(c); - } - } + const convos = [...accepted, ...requested]; for (const convo of convos) { - // Skip empty convos / convos where we are the only participant. - const otherMembers = convo.members.filter((m) => m.did !== community.did); + const otherMembers = convo.members.filter((m) => m.did !== row.did); if (otherMembers.length === 0) continue; - // Accept request convos so we can process them. if (convo.status === 'request') { - try { - await community.chatClient.post('chat.bsky.convo.acceptConvo', { - input: { convoId: convo.id } - }); - } catch { - // non-fatal - } + await acceptConvo(client, convo.id); } - // Pull the latest page of messages. Processed messages are marked - // with a ✅ reaction from the community itself, so we don't need any - // DB cursor — we just walk the page and skip anything already reacted. - const msgRes = await community.chatClient.get('chat.bsky.convo.getMessages', { - params: { convoId: convo.id, limit: 100 } - }); - if (!msgRes.ok) continue; - const messages = msgRes.data.messages as unknown as ChatMessage[]; - - // Process oldest → newest so reactions appear in order in the chat. + const messages = await getMessages(client, convo.id); + // Process oldest → newest so reactions appear in order. for (const msg of [...messages].reverse()) { if (msg.$type === 'chat.bsky.convo.defs#deletedMessageView') continue; if (!msg.sender) continue; - // Skip messages the community sent. - if (msg.sender.did === community.did) continue; + if (msg.sender.did === row.did) continue; // Already processed? Look for our own ✅ reaction on this message. const alreadyProcessed = msg.reactions?.some( - (r) => r.value === PROCESSED_REACTION && r.sender?.did === community.did + (r) => r.value === PROCESSED_REACTION && r.sender?.did === row.did ); if (alreadyProcessed) continue; - const submission = await parseSubmission({ text: msg.text, embed: msg.embed }); + const submission = await parseSubmission({ + text: msg.text, + embed: msg.embed ?? undefined + }); if (!submission) { - // Not a valid submission — still mark it handled so we don't - // re-parse on every cron run. - await community.chatClient - .post('chat.bsky.convo.addReaction', { - input: { convoId: convo.id, messageId: msg.id, value: PROCESSED_REACTION } - }) - .catch(() => {}); + // Mark as handled so we don't re-parse on every cron run. + await addReaction(client, convo.id, msg.id, PROCESSED_REACTION); continue; } - // Extra safety: even if the reaction didn't stick last time, the - // posts table has a unique index on (community_did, quoted_post_uri) - // and is authoritative. Check before hitting createRecord so we - // never double-post to Bluesky. - if (await hasSubmission(db, community.did, submission.postUri)) { - await community.chatClient - .post('chat.bsky.convo.addReaction', { - input: { convoId: convo.id, messageId: msg.id, value: PROCESSED_REACTION } - }) - .catch(() => {}); + // Check dedup before hitting createRecord so we never double-post. + if (await hasSubmission(db, row.did, submission.postUri)) { + await addReaction(client, convo.id, msg.id, PROCESSED_REACTION); continue; } - const ok = await createSubmissionPost(db, community, submission, msg.sender.did as Did); + const ok = await createSubmissionPost( + db, + client, + row, + submission, + msg.sender.did as Did + ); if (ok) { created++; - await community.chatClient - .post('chat.bsky.convo.addReaction', { - input: { convoId: convo.id, messageId: msg.id, value: PROCESSED_REACTION } - }) - .catch(() => {}); + await addReaction(client, convo.id, msg.id, PROCESSED_REACTION); } } } @@ -373,47 +334,42 @@ export async function processCommunityDms( async function createSubmissionPost( db: D1Database, - community: AuthedAccount, + client: WelcomeMatClient, + row: CommunityRow, submission: { title: string; postUri: ResourceUri }, senderDid: Did ): Promise { - // Resolve the CID of the post being quoted. + // Resolve the CID of the post being quoted via the public appview. const appview = new Client({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW }) }); - const quotedCid = await getPostCid(appview, submission.postUri); if (!quotedCid) return false; - const rkey = TID.now(); const createdAt = new Date().toISOString(); - - const res = await community.pdsClient.post('com.atproto.repo.createRecord', { - input: { - repo: community.did, - collection: 'app.bsky.feed.post', - rkey, - record: { - $type: 'app.bsky.feed.post', - text: submission.title, - createdAt, - embed: { - $type: 'app.bsky.embed.record', - record: { - uri: submission.postUri, - cid: quotedCid - } + let result: { uri: string; cid: string }; + try { + result = await client.createRecord('app.bsky.feed.post', { + $type: 'app.bsky.feed.post', + text: submission.title, + createdAt, + embed: { + $type: 'app.bsky.embed.record', + record: { + uri: submission.postUri, + cid: quotedCid } } - } - }); - - if (!res.ok) return false; + }); + } catch (e) { + console.error('[createSubmissionPost] rookery createRecord failed', e); + return false; + } const inserted = await insertPost(db, { - uri: res.data.uri, - cid: res.data.cid, - community_did: community.did, + uri: result.uri, + cid: result.cid, + community_did: row.did, title: submission.title, quoted_post_uri: submission.postUri, quoted_post_cid: quotedCid, @@ -423,18 +379,13 @@ async function createSubmissionPost( repost_count: 0, indexed_at: createdAt }); - return inserted; } // ------------------------------------------------------------------------- -// Post metric refresh +// Metric refresh (unchanged — uses public appview) // ------------------------------------------------------------------------- -/** - * Refresh cached like/reply/repost counts for stale posts per the decay - * schedule. Uses the public appview (unauthenticated) for efficiency. - */ export async function refreshPostMetrics(db: D1Database): Promise { const due = await getPostsDueForRefresh(db, 100); if (due.length === 0) return 0; @@ -444,7 +395,6 @@ export async function refreshPostMetrics(db: D1Database): Promise { }); let refreshed = 0; - // getPosts caps at 25 URIs per request. for (let i = 0; i < due.length; i += 25) { const batch = due.slice(i, i + 25).map((p) => p.uri as ResourceUri); try { @@ -484,7 +434,6 @@ export async function runCronTick(env: App.Platform['env']): Promise<{ const errors: string[] = []; const communities = await listCommunities(db); - // Keep profile cache refreshed (cheap — one appview call per community). const appview = new Client({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW }) }); @@ -498,7 +447,7 @@ export async function runCronTick(env: App.Platform['env']): Promise<{ errors.push(`${row.handle}: ${String(e)}`); } - // Best-effort: refresh profile metadata (avatar/display name/description). + // Best-effort: refresh cached profile metadata (avatar, display name, desc). try { const profile = await appview.get('app.bsky.actor.getProfile', { params: { actor: row.did } @@ -528,8 +477,5 @@ export async function runCronTick(env: App.Platform['env']): Promise<{ }; } -// ------------------------------------------------------------------------- -// Convenience: combined feed wrapper so routes don't import db.ts directly. -// ------------------------------------------------------------------------- - +// Convenience re-export so routes don't need to import db.ts directly. export { getCombinedFeed }; diff --git a/src/lib/reddit/db.ts b/src/lib/reddit/db.ts index d0f966f..d7b9a7c 100644 --- a/src/lib/reddit/db.ts +++ b/src/lib/reddit/db.ts @@ -6,8 +6,10 @@ export type CommunityRow = { did: Did; handle: string; pds: string; - password_ciphertext: string; - password_iv: string; + secret_key_ciphertext: string; + secret_key_iv: string; + public_jwk_json: string; + thumbprint: string; display_name: string | null; avatar: string | null; description: string | null; @@ -38,7 +40,7 @@ export type PostWithCommunity = PostRow & { export async function listCommunities(db: D1Database): Promise { const res = await db .prepare( - 'SELECT did, handle, pds, password_ciphertext, password_iv, display_name, avatar, description, created_at FROM communities ORDER BY created_at DESC' + 'SELECT did, handle, pds, secret_key_ciphertext, secret_key_iv, public_jwk_json, thumbprint, display_name, avatar, description, created_at FROM communities ORDER BY created_at DESC' ) .all(); return res.results ?? []; @@ -50,7 +52,7 @@ export async function getCommunityByHandle( ): Promise { const res = await db .prepare( - 'SELECT did, handle, pds, password_ciphertext, password_iv, display_name, avatar, description, created_at FROM communities WHERE handle = ?' + 'SELECT did, handle, pds, secret_key_ciphertext, secret_key_iv, public_jwk_json, thumbprint, display_name, avatar, description, created_at FROM communities WHERE handle = ?' ) .bind(handle) .first(); @@ -63,7 +65,7 @@ export async function getCommunityByDid( ): Promise { const res = await db .prepare( - 'SELECT did, handle, pds, password_ciphertext, password_iv, display_name, avatar, description, created_at FROM communities WHERE did = ?' + 'SELECT did, handle, pds, secret_key_ciphertext, secret_key_iv, public_jwk_json, thumbprint, display_name, avatar, description, created_at FROM communities WHERE did = ?' ) .bind(did) .first(); @@ -76,14 +78,16 @@ export async function insertCommunity( ): Promise { await db .prepare( - 'INSERT INTO communities (did, handle, pds, password_ciphertext, password_iv, display_name, avatar, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + 'INSERT INTO communities (did, handle, pds, secret_key_ciphertext, secret_key_iv, public_jwk_json, thumbprint, display_name, avatar, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' ) .bind( row.did, row.handle, row.pds, - row.password_ciphertext, - row.password_iv, + row.secret_key_ciphertext, + row.secret_key_iv, + row.public_jwk_json, + row.thumbprint, row.display_name, row.avatar, row.description @@ -193,23 +197,6 @@ export async function getCombinedFeed( return res.results ?? []; } -export async function getMeta(db: D1Database, key: string): Promise { - const res = await db - .prepare('SELECT value FROM meta WHERE key = ?') - .bind(key) - .first<{ value: string }>(); - return res?.value ?? null; -} - -export async function setMeta(db: D1Database, key: string, value: string): Promise { - await db - .prepare( - 'INSERT INTO meta (key, value, updated_at) VALUES (?, ?, datetime("now")) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at' - ) - .bind(key, value) - .run(); -} - // Posts due for metric refresh based on age-decayed cadence. // Age buckets: // <1h → every 60s (every run) diff --git a/src/lib/reddit/server/communities.remote.ts b/src/lib/reddit/server/communities.remote.ts index 5e69cb5..ec03cc7 100644 --- a/src/lib/reddit/server/communities.remote.ts +++ b/src/lib/reddit/server/communities.remote.ts @@ -12,19 +12,40 @@ import { } from '../db'; import { registerCommunity } from '../bot'; -// Never ship password_ciphertext/iv to the client. -type PublicCommunity = Omit; +const HANDLE_DOMAIN = '.atmo.garden'; + +/** Normalize "bob" → "bob.atmo.garden". Full handles pass through unchanged. */ +function fullHandle(input: string): string { + return input.includes('.') ? input : input + HANDLE_DOMAIN; +} + +// Never ship encrypted key material to the client. +type PublicCommunity = Omit< + CommunityRow, + 'secret_key_ciphertext' | 'secret_key_iv' | 'public_jwk_json' | 'thumbprint' +>; function sanitize(row: CommunityRow): PublicCommunity { /* eslint-disable @typescript-eslint/no-unused-vars */ - const { password_ciphertext, password_iv, ...rest } = row; + const { + secret_key_ciphertext, + secret_key_iv, + public_jwk_json, + thumbprint, + ...rest + } = row; /* eslint-enable @typescript-eslint/no-unused-vars */ return rest; } export const register = command( v.object({ - identifier: v.pipe(v.string(), v.minLength(3)), - password: v.pipe(v.string(), v.minLength(1)) + // Short handle (single label). Rookery appends the configured domain. + shortHandle: v.pipe( + v.string(), + v.regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, 'Must be [a-z0-9-], single label'), + v.minLength(3), + v.maxLength(32) + ) }), async (input) => { const { platform } = getRequestEvent(); @@ -32,7 +53,7 @@ export const register = command( if (!env || !env.DB) error(500, 'DB binding unavailable'); try { - const result = await registerCommunity(env, env.DB, input.identifier, input.password); + const result = await registerCommunity(env, env.DB, input.shortHandle); return { ok: true, did: result.did, handle: result.handle }; } catch (e) { const msg = e instanceof Error ? e.message : String(e); @@ -61,7 +82,7 @@ export const getCommunity = command( const env = platform?.env; if (!env || !env.DB) return null; - const row = await getCommunityByHandle(env.DB, input.handle); + const row = await getCommunityByHandle(env.DB, fullHandle(input.handle)); return row ? sanitize(row) : null; } ); @@ -73,7 +94,7 @@ export const getCommunityPosts = command( const env = platform?.env; if (!env || !env.DB) return []; - const row = await getCommunityByHandle(env.DB, input.handle); + const row = await getCommunityByHandle(env.DB, fullHandle(input.handle)); if (!row) return []; return getRecentPostsForCommunity(env.DB, row.did, input.limit ?? 50); diff --git a/src/lib/reddit/welcomemat.ts b/src/lib/reddit/welcomemat.ts new file mode 100644 index 0000000..ce4f902 --- /dev/null +++ b/src/lib/reddit/welcomemat.ts @@ -0,0 +1,366 @@ +// WelcomeMat client for rookery — Workers-compatible. +// +// Uses @noble/curves for ES256K and Web APIs (crypto.subtle / crypto.randomUUID +// / TextEncoder) so it runs in both the Cloudflare Workers runtime and Node. +// +// What this module provides: +// +// - createRookeryAccount(): signs up a new rookery account via /api/signup +// - WelcomeMatClient: holds a signing key + thumbprint for an existing +// account and exposes high-level operations: +// - signDpop(method, url, accessToken) → DPoP proof JWT +// - buildAccessToken(tosText) → wm+jwt access token (cached) +// - getServiceAuth(aud, lxm) → service-auth JWT from rookery +// - createRecord(collection, record, rkey?) → via rookery DPoP +// - deleteRecord(collection, rkey) → via rookery DPoP +// +// The client does NOT know how to refresh the ToS / access token — if the ToS +// changes, construct a new client. + +import { secp256k1 } from '@noble/curves/secp256k1.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type EcPublicJwk = { + kty: 'EC'; + crv: 'secp256k1'; + x: string; + y: string; +}; + +export type RookeryAccount = { + did: string; + handle: string; + pds: string; + thumbprint: string; + publicJwk: EcPublicJwk; + /** 32-byte secp256k1 secret key as a hex string */ + secretKeyHex: string; +}; + +// --------------------------------------------------------------------------- +// Low-level helpers +// --------------------------------------------------------------------------- + +function b64url(input: Uint8Array | ArrayBuffer): string { + const bytes = input instanceof Uint8Array ? input : new Uint8Array(input); + let bin = ''; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function b64urlString(s: string): string { + return b64url(new TextEncoder().encode(s)); +} + +async function sha256b64url(input: string | Uint8Array): Promise { + const data = typeof input === 'string' ? new TextEncoder().encode(input) : input; + // Copy into a fresh ArrayBuffer so lib.dom BufferSource type checks. + const buf = new ArrayBuffer(data.byteLength); + new Uint8Array(buf).set(data); + const hash = await crypto.subtle.digest('SHA-256', buf); + return b64url(hash); +} + +function hexToBytes(hex: string): Uint8Array { + if (hex.length % 2 !== 0) throw new Error('hex string has odd length'); + const out = new Uint8Array(hex.length / 2); + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return out; +} + +function bytesToHex(bytes: Uint8Array): string { + let s = ''; + for (let i = 0; i < bytes.length; i++) { + s += bytes[i].toString(16).padStart(2, '0'); + } + return s; +} + +// noble 2.x sign() prehashes with sha256 by default; that matches what rookery +// verifies against (sha256 of the signing input). +function signEs256k(secretKey: Uint8Array, message: Uint8Array | string): Uint8Array { + const msgBytes = + typeof message === 'string' ? new TextEncoder().encode(message) : message; + const sig = secp256k1.sign(msgBytes, secretKey); + if (sig.length !== 64) { + throw new Error(`unexpected signature length: ${sig.length}`); + } + return sig; +} + +function signJwt( + header: Record, + payload: Record, + secretKey: Uint8Array +): string { + const signingInput = `${b64urlString(JSON.stringify(header))}.${b64urlString(JSON.stringify(payload))}`; + const sig = signEs256k(secretKey, signingInput); + return `${signingInput}.${b64url(sig)}`; +} + +function buildPublicJwk(secretKey: Uint8Array): EcPublicJwk { + const pub = secp256k1.getPublicKey(secretKey, false); // uncompressed (0x04 || x || y) + return { + kty: 'EC', + crv: 'secp256k1', + x: b64url(pub.slice(1, 33)), + y: b64url(pub.slice(33, 65)) + }; +} + +async function computeThumbprint(jwk: EcPublicJwk): Promise { + // RFC 7638 canonical form for EC keys. + return sha256b64url( + JSON.stringify({ crv: jwk.crv, kty: 'EC', x: jwk.x, y: jwk.y }) + ); +} + +function normalizeHtu(url: string): string { + const u = new URL(url); + return u.origin + u.pathname; +} + +// --------------------------------------------------------------------------- +// Signup +// --------------------------------------------------------------------------- + +export type CreateAccountOptions = { + /** The rookery host, e.g. "pds.atmo.garden" */ + hostname: string; + /** Single-label handle — rookery appends the configured handle domain. */ + handle: string; + /** Shared admin secret gated on rookery's /api/signup handler. */ + signupSecret: string; +}; + +export type CreateAccountResult = { + account: RookeryAccount; +}; + +/** + * Generate a fresh secp256k1 keypair and enroll it as a new rookery account + * via the WelcomeMat signup flow. Returns the full account object including + * the secret key hex (to be encrypted and stored by the caller). + */ +export async function createRookeryAccount( + opts: CreateAccountOptions +): Promise { + const secretKey = secp256k1.utils.randomSecretKey(); + const publicJwk = buildPublicJwk(secretKey); + const thumbprint = await computeThumbprint(publicJwk); + + const pds = `https://${opts.hostname}`; + + const tosText = await fetch(`${pds}/tos`).then((r) => r.text()); + const tosHash = await sha256b64url(tosText); + const tosSignature = b64url(signEs256k(secretKey, tosText)); + + const accessToken = signJwt( + { typ: 'wm+jwt', alg: 'ES256K' }, + { + tos_hash: tosHash, + aud: pds, + cnf: { jkt: thumbprint }, + iat: Math.floor(Date.now() / 1000) + }, + secretKey + ); + + const signupUrl = `${pds}/api/signup`; + const dpopProof = signJwt( + { typ: 'dpop+jwt', alg: 'ES256K', jwk: publicJwk }, + { + jti: crypto.randomUUID(), + htm: 'POST', + htu: normalizeHtu(signupUrl), + iat: Math.floor(Date.now() / 1000) + }, + secretKey + ); + + const res = await fetch(signupUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + DPoP: dpopProof, + 'X-Rookery-Signup-Secret': opts.signupSecret + }, + body: JSON.stringify({ + handle: opts.handle, + tos_signature: tosSignature, + access_token: accessToken + }) + }); + + const body = (await res.json()) as { + did?: string; + handle?: string; + error?: string; + message?: string; + }; + if (!res.ok || !body.did || !body.handle) { + const reason = body.error || body.message || `status ${res.status}`; + throw new Error(`rookery signup failed: ${reason}`); + } + + return { + account: { + did: body.did, + handle: body.handle, + pds, + thumbprint, + publicJwk, + secretKeyHex: bytesToHex(secretKey) + } + }; +} + +// --------------------------------------------------------------------------- +// Client +// --------------------------------------------------------------------------- + +/** + * High-level client for operating as an already-enrolled rookery account. + * + * Construct from a stored RookeryAccount via `WelcomeMatClient.forAccount()`. + * The client lazily fetches the ToS once and caches the resulting access + * token for its lifetime — since access tokens have no explicit expiry and + * rookery only rejects them on ToS change, one access token per cron tick + * is typically enough. + */ +export class WelcomeMatClient { + readonly account: RookeryAccount; + private secretKey: Uint8Array; + private accessTokenCache: { token: string; tosHash: string } | null = null; + + private constructor(account: RookeryAccount) { + this.account = account; + this.secretKey = hexToBytes(account.secretKeyHex); + } + + static forAccount(account: RookeryAccount): WelcomeMatClient { + return new WelcomeMatClient(account); + } + + /** + * Fetch the current ToS from the PDS and build (or reuse) a wm+jwt access + * token. The token is cached in memory until the ToS hash changes. + */ + private async ensureAccessToken(): Promise { + const tosText = await fetch(`${this.account.pds}/tos`).then((r) => r.text()); + const tosHash = await sha256b64url(tosText); + if (this.accessTokenCache && this.accessTokenCache.tosHash === tosHash) { + return this.accessTokenCache.token; + } + const token = signJwt( + { typ: 'wm+jwt', alg: 'ES256K' }, + { + tos_hash: tosHash, + aud: this.account.pds, + cnf: { jkt: this.account.thumbprint }, + iat: Math.floor(Date.now() / 1000) + }, + this.secretKey + ); + this.accessTokenCache = { token, tosHash }; + return token; + } + + /** + * Build a DPoP proof JWT bound to the current access token. This is the + * correct one to use for any authenticated rookery call. + */ + async buildDpopProof(method: string, url: string): Promise<{ + accessToken: string; + dpopProof: string; + }> { + const accessToken = await this.ensureAccessToken(); + const ath = await sha256b64url(accessToken); + const dpopProof = signJwt( + { typ: 'dpop+jwt', alg: 'ES256K', jwk: this.account.publicJwk }, + { + jti: crypto.randomUUID(), + htm: method, + htu: normalizeHtu(url), + iat: Math.floor(Date.now() / 1000), + ath + }, + this.secretKey + ); + return { accessToken, dpopProof }; + } + + private async dpopRequest( + method: 'GET' | 'POST', + url: string, + body?: unknown + ): Promise { + const { accessToken, dpopProof } = await this.buildDpopProof(method, url); + const init: RequestInit = { + method, + headers: { + Authorization: `DPoP ${accessToken}`, + DPoP: dpopProof, + ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}) + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}) + }; + return fetch(url, init); + } + + /** + * Ask rookery to issue a service-auth JWT scoped to the given aud / lxm. + * This is what gets sent to third-party atproto services like api.bsky.chat. + */ + async getServiceAuth(aud: string, lxm: string, expSeconds = 60): Promise { + const url = `${this.account.pds}/xrpc/com.atproto.server.getServiceAuth?aud=${encodeURIComponent(aud)}&lxm=${encodeURIComponent(lxm)}&exp=${expSeconds}`; + const res = await this.dpopRequest('GET', url); + if (!res.ok) { + throw new Error( + `getServiceAuth failed (${res.status}): ${await res.text()}` + ); + } + const body = (await res.json()) as { token: string }; + return body.token; + } + + /** + * createRecord via rookery's DPoP-auth'd endpoint. The rkey is optional — + * rookery generates a TID if omitted, which is compatible with Bluesky. + */ + async createRecord( + collection: string, + record: Record, + rkey?: string + ): Promise<{ uri: string; cid: string }> { + const url = `${this.account.pds}/xrpc/com.atproto.repo.createRecord`; + const res = await this.dpopRequest('POST', url, { + repo: this.account.did, + collection, + ...(rkey ? { rkey } : {}), + record + }); + if (!res.ok) { + throw new Error(`createRecord failed (${res.status}): ${await res.text()}`); + } + return (await res.json()) as { uri: string; cid: string }; + } + + async deleteRecord(collection: string, rkey: string): Promise { + const url = `${this.account.pds}/xrpc/com.atproto.repo.deleteRecord`; + const res = await this.dpopRequest('POST', url, { + repo: this.account.did, + collection, + rkey + }); + if (!res.ok) { + throw new Error(`deleteRecord failed (${res.status}): ${await res.text()}`); + } + } +} + diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index cdfaafd..eb5156d 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,13 +1,11 @@ @@ -49,22 +30,6 @@ - - {#if user.did} -
-
-{:else} -
- -
-
-

Messages

- -
- - -
- - -
- - {#if loading} -
- -
- {:else if displayedConvos.length === 0} -
- -

- {tab === 'accepted' ? 'No conversations yet' : 'No message requests'} -

-
- {:else} -
- {/if} -
- - -
-
- {@render children()} -
-
-
-{/if} diff --git a/src/routes/chat/+page.svelte b/src/routes/chat/+page.svelte deleted file mode 100644 index 25d3458..0000000 --- a/src/routes/chat/+page.svelte +++ /dev/null @@ -1,10 +0,0 @@ - - -
-
- -

Select a conversation

-
-
diff --git a/src/routes/chat/[convoId]/+page.svelte b/src/routes/chat/[convoId]/+page.svelte deleted file mode 100644 index faaf0d7..0000000 --- a/src/routes/chat/[convoId]/+page.svelte +++ /dev/null @@ -1,539 +0,0 @@ - - -
-{#if loading} -
- -
-{:else if !member} -
-

Conversation not found

-
-{:else} - -
- - - - - - - -
- - -
- {#if loadingOlder} -
- -
- {/if} - {#if allMessages.length === 0} -
-

No messages yet. Say hello!

-
- {:else} - {#each allMessages as msg, i (msg.id)} - {@const isOwn = msg.sender.did === user.did} - {@const showHeader = shouldShowHeader(i)} - {@const deleted = isDeleted(msg.id)} - {#if deleted || !isMessageView(msg)} -
- {#if showHeader} -
- {:else} -
- {/if} -

Message deleted

-
- {:else} - {@const html = renderMessageHtml(msg)} - {@const embeds = getMessageEmbeds(msg)} - {@const reactions = getReactions(msg)} - -
hoveredMessageId = msg.id} - onmouseleave={() => { if (emojiPickerMessageId !== msg.id) hoveredMessageId = null; }} - > - {#if showHeader} - - {:else} -
- {/if} -
- {#if showHeader} -
- - - {formatMessageTime(msg.sentAt)} - -
- {/if} - {#if html} -
- {@html sanitize(html, { ADD_ATTR: ['target', 'rel'] })} -
- {/if} - {#if embeds.length > 0} - {#each embeds as embed} - - {/each} - {/if} - - - {#if reactions.length > 0} -
- {#each reactions as reaction} - - {/each} -
- {/if} -
- - - {#if hoveredMessageId === msg.id || emojiPickerMessageId === msg.id} -
- {#each QUICK_EMOJIS as emoji} - {@const alreadyMine = reactions.some((r) => r.value === emoji && r.mine)} - - {/each} - { emojiPickerMessageId = v ? msg.id : null; if (!v) hoveredMessageId = null; }} - onpicked={(emoji) => handleReaction(msg.id, emoji.unicode, false)} - triggerClasses="!p-1 !min-w-0 !h-auto !rounded text-base-400 hover:text-base-600 dark:hover:text-base-200" - triggerVariant="ghost" - triggerSize="icon" - side="top" - class="z-50" - > - {#snippet child({ props })} - - {/snippet} - - {#if isOwn} - - {/if} -
- {/if} -
- {/if} - {/each} - - - {#each pendingMessages as msg (msg.id)} - {@const showHeader = allMessages.length === 0 && pendingMessages[0]?.id === msg.id - ? true - : (() => { - const lastReal = allMessages[allMessages.length - 1]; - const prevPendingIdx = pendingMessages.indexOf(msg) - 1; - const prev = prevPendingIdx >= 0 ? pendingMessages[prevPendingIdx] : lastReal; - if (!prev) return true; - const prevDid = 'sender' in prev ? prev.sender.did : user.did; - if (prevDid !== user.did) return true; - const prevTime = 'sentAt' in prev ? prev.sentAt : ''; - return new Date(msg.sentAt).getTime() - new Date(prevTime).getTime() > 5 * 60 * 1000; - })()} -
- {#if showHeader} - - {:else} -
- {/if} -
- {#if showHeader} -
- - {user.profile?.displayName ?? 'You'} - - - {formatMessageTime(msg.sentAt)} - -
- {/if} -

{msg.text}

-
-
- {/each} - {/if} -
- - - {#if isRequest} -
-
-

- {member.displayName ?? member.handle} wants to message you -

-
- - - Ignore - -
-
-
- {:else} -
-
- - -
-
- {/if} -{/if} -
- - diff --git a/src/routes/notifications/+page.svelte b/src/routes/notifications/+page.svelte deleted file mode 100644 index 58afa2c..0000000 --- a/src/routes/notifications/+page.svelte +++ /dev/null @@ -1,412 +0,0 @@ - - -{#if !user.isLoggedIn} -
-
- -

Log in to view your notifications

- -
-
-{:else} -
-
- -
-

Notifications

- -
- - {#if loading} -
- -
- {:else if grouped.length === 0} -
- -

No notifications yet

-
- {:else} -
- {#each grouped as group, i (group.notifications[0].uri + '-' + i)} - {@const Icon = reasonIcon(group.reason)} - {@const postText = getSubjectPostText(group)} - {@const notif = group.notifications[0]} - -
{ - if (e.button !== 0) return; - if ((e.target as HTMLElement).closest('a, button')) return; - navigateToGroup(group); - }} - > - -
-
- -
-
- -
- {#each group.authors.slice(0, 8) as author} - - {/each} - {#if group.authors.length > 8} - +{group.authors.length - 8} - {/if} -
- - -
- - {authorNames(group.authors)} - - - {reasonText(group.reason, group.authors.length)} - - - {formatTime(group.latestAt)} - -
- - - {#if postText && !['reply', 'quote', 'mention'].includes(group.reason)} -

- {postText} -

- {/if} - - - {#if ['reply', 'quote', 'mention'].includes(group.reason)} - {@const replyPost = (() => { - // Build a minimal PostView-like object from the notification - return { - uri: notif.uri, - cid: notif.cid, - author: notif.author, - record: notif.record, - indexedAt: notif.indexedAt, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - embed: (notif as any).embed, - likeCount: 0, - replyCount: 0, - repostCount: 0 - }; - })()} - {@const { postData, embeds } = blueskyPostToPostData(replyPost, 'https://bsky.app')} - {@const postHref = getPostHref(replyPost)} -
- goto(`/profile/${handle}`)} - handleHref={(handle) => `/profile/${handle}`} - /> -
- {/if} -
-
-
- {/each} -
- - {#if loadingMore} -
- -
- {/if} - - {#if !notifCursor && notifications.length > 0} -

You've reached the end

- {/if} - -
- {/if} -
-
-{/if} diff --git a/src/routes/register/+page.svelte b/src/routes/register/+page.svelte index 37a6aaf..e41ae0f 100644 --- a/src/routes/register/+page.svelte +++ b/src/routes/register/+page.svelte @@ -5,12 +5,15 @@ import { Loader2, CheckCircle2 } from '@lucide/svelte'; import { register } from '$lib/reddit/server/communities.remote'; - let identifier = $state(''); - let password = $state(''); + let shortHandle = $state(''); let submitting = $state(false); let errorMsg = $state(null); let success = $state<{ handle: string } | null>(null); + const handlePreview = $derived( + shortHandle ? `${shortHandle}.atmo.garden` : 'yourname.atmo.garden' + ); + async function onSubmit(e: Event) { e.preventDefault(); if (submitting) return; @@ -18,13 +21,9 @@ success = null; submitting = true; try { - const result = await register({ - identifier: identifier.trim(), - password: password.trim() - }); + const result = await register({ shortHandle: shortHandle.trim() }); success = { handle: result.handle }; - identifier = ''; - password = ''; + shortHandle = ''; } catch (err) { const msg = err instanceof Error ? err.message : String(err); errorMsg = msg.replace(/^Registration failed:\s*/, '') || 'Registration failed'; @@ -35,37 +34,24 @@
-

Register a community

+

Create a community

- Use an existing Bluesky account as the community. Submissions DM'd to this account will be - turned into quote posts automatically. + Pick a name. We'll create a new Bluesky account for your community on pds.atmo.garden and set it up so people can submit posts by DM'ing it a link.

- - @@ -79,11 +65,11 @@
- Registered @{success.handle}. + Community @{success.handle} is ready. @@ -94,9 +80,9 @@ diff --git a/vite.config.ts b/vite.config.ts index 3f927d4..87b3f88 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,6 +8,6 @@ export default defineConfig({ server: { host: '127.0.0.1', port: DEV_PORT, - allowedHosts: ['vital-fancy-duke-draw.trycloudflare.com'] + allowedHosts: [] } }); diff --git a/wrangler.jsonc b/wrangler.jsonc index af39f9a..20a5e4b 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -1,6 +1,6 @@ { "$schema": "node_modules/wrangler/config-schema.json", - "name": "atmo-social", + "name": "atmo-garden", "main": ".svelte-kit/cloudflare/_worker.js", "compatibility_date": "2025-12-25", "compatibility_flags": ["nodejs_compat_v2"], @@ -12,7 +12,8 @@ "enabled": true }, "vars": { - "OAUTH_PUBLIC_URL": "https://atmo.social" + "OAUTH_PUBLIC_URL": "https://atmo.social", + "ROOKERY_HOSTNAME": "pds.atmo.garden" }, "kv_namespaces": [ { @@ -27,8 +28,8 @@ "d1_databases": [ { "binding": "DB", - "database_name": "atmo-social-db", - "database_id": "REPLACE_ME_WITH_D1_ID", + "database_name": "atmo-garden-db", + "database_id": "bd23d3c4-8845-4b45-881f-72665088104e", "migrations_dir": "migrations" } ],