tapping in

+6 -1
.gitignore
··· 23 vite.config.ts.timestamp-* 24 25 .idea 26 - local.db
··· 23 vite.config.ts.timestamp-* 24 25 .idea 26 + local.db 27 + 28 + #tap 29 + tap.db 30 + tap.db-shm 31 + tap.db-wal
+14
dev.compose.yml
··· 26 timeout: 5s 27 networks: 28 - services-network 29 volumes: 30 valkey_data: 31 postgres_data:
··· 26 timeout: 5s 27 networks: 28 - services-network 29 + tap: 30 + image: ghcr.io/bluesky-social/indigo/tap:latest 31 + platform: linux/amd64 32 + depends_on: 33 + - postgres 34 + - valkey 35 + ports: 36 + - '2480:2480' 37 + env_file: 38 + - .env 39 + extra_hosts: 40 + - "host.docker.internal:host-gateway" 41 + network_mode: "host" 42 + 43 volumes: 44 valkey_data: 45 postgres_data:
+35
drizzle/0000_breezy_menace.sql
···
··· 1 + CREATE TABLE "record_pokes" ( 2 + "id" serial PRIMARY KEY NOT NULL, 3 + "recordId" integer, 4 + "pokersRepo" text NOT NULL, 5 + "atUri" text NOT NULL, 6 + "indexedAt" time DEFAULT now() NOT NULL 7 + ); 8 + --> statement-breakpoint 9 + CREATE TABLE "records" ( 10 + "id" serial PRIMARY KEY NOT NULL, 11 + "rkey" varchar NOT NULL, 12 + "collection" varchar NOT NULL, 13 + "repo" varchar NOT NULL, 14 + "atUri" text NOT NULL, 15 + "data" jsonb NOT NULL, 16 + "indexedAt" timestamp DEFAULT now() NOT NULL 17 + ); 18 + --> statement-breakpoint 19 + CREATE TABLE "user_pokes" ( 20 + "id" serial PRIMARY KEY NOT NULL, 21 + "subject" text NOT NULL, 22 + "poker" text NOT NULL, 23 + "at_uri" text NOT NULL, 24 + "indexedAt" time DEFAULT now() NOT NULL 25 + ); 26 + --> statement-breakpoint 27 + ALTER TABLE "record_pokes" ADD CONSTRAINT "record_pokes_recordId_records_id_fk" FOREIGN KEY ("recordId") REFERENCES "public"."records"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint 28 + CREATE INDEX "record_pokes_pokersRepo_index" ON "record_pokes" USING btree ("pokersRepo");--> statement-breakpoint 29 + CREATE INDEX "record_pokes_atUri_index" ON "record_pokes" USING btree ("atUri");--> statement-breakpoint 30 + CREATE INDEX "records_rkey_index" ON "records" USING btree ("rkey");--> statement-breakpoint 31 + CREATE INDEX "records_collection_index" ON "records" USING btree ("collection");--> statement-breakpoint 32 + CREATE INDEX "records_repo_index" ON "records" USING btree ("repo");--> statement-breakpoint 33 + CREATE UNIQUE INDEX "records_atUri_index" ON "records" USING btree ("atUri");--> statement-breakpoint 34 + CREATE INDEX "user_pokes_subject_index" ON "user_pokes" USING btree ("subject");--> statement-breakpoint 35 + CREATE INDEX "user_pokes_poker_index" ON "user_pokes" USING btree ("poker");
-33
drizzle/0000_yielding_psylocke.sql
··· 1 - CREATE TABLE "record_pokes" ( 2 - "id" serial PRIMARY KEY NOT NULL, 3 - "recordId" integer, 4 - "pokersRepo" text NOT NULL, 5 - "atUri" text NOT NULL, 6 - "indexedAt" time DEFAULT now() NOT NULL 7 - ); 8 - --> statement-breakpoint 9 - CREATE TABLE "records" ( 10 - "id" serial PRIMARY KEY NOT NULL, 11 - "rkey" varchar NOT NULL, 12 - "collection" varchar NOT NULL, 13 - "repo" varchar NOT NULL, 14 - "data" jsonb NOT NULL, 15 - "indexedAt" time DEFAULT now() NOT NULL 16 - ); 17 - --> statement-breakpoint 18 - CREATE TABLE "user_pokes" ( 19 - "id" serial PRIMARY KEY NOT NULL, 20 - "subject" text NOT NULL, 21 - "poker" text NOT NULL, 22 - "at_uri" text NOT NULL, 23 - "indexedAt" time DEFAULT now() NOT NULL 24 - ); 25 - --> statement-breakpoint 26 - ALTER TABLE "record_pokes" ADD CONSTRAINT "record_pokes_recordId_records_id_fk" FOREIGN KEY ("recordId") REFERENCES "public"."records"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint 27 - CREATE INDEX "pokers_repo_idx" ON "record_pokes" USING btree ("pokersRepo");--> statement-breakpoint 28 - CREATE INDEX "at_uri_idx" ON "record_pokes" USING btree ("atUri");--> statement-breakpoint 29 - CREATE INDEX "rkey_idx" ON "records" USING btree ("rkey");--> statement-breakpoint 30 - CREATE INDEX "collection_idx" ON "records" USING btree ("collection");--> statement-breakpoint 31 - CREATE INDEX "repo_idx" ON "records" USING btree ("repo");--> statement-breakpoint 32 - CREATE INDEX "subject_idx" ON "user_pokes" USING btree ("subject");--> statement-breakpoint 33 - CREATE INDEX "poker_idx" ON "user_pokes" USING btree ("poker");
···
+37 -16
drizzle/meta/0000_snapshot.json
··· 1 { 2 - "id": "eb652b7e-c43a-411e-9e94-15bfb4be773f", 3 "prevId": "00000000-0000-0000-0000-000000000000", 4 "version": "7", 5 "dialect": "postgresql", ··· 41 } 42 }, 43 "indexes": { 44 - "pokers_repo_idx": { 45 - "name": "pokers_repo_idx", 46 "columns": [ 47 { 48 "expression": "pokersRepo", ··· 56 "method": "btree", 57 "with": {} 58 }, 59 - "at_uri_idx": { 60 - "name": "at_uri_idx", 61 "columns": [ 62 { 63 "expression": "atUri", ··· 121 "primaryKey": false, 122 "notNull": true 123 }, 124 "data": { 125 "name": "data", 126 "type": "jsonb", ··· 129 }, 130 "indexedAt": { 131 "name": "indexedAt", 132 - "type": "time", 133 "primaryKey": false, 134 "notNull": true, 135 "default": "now()" 136 } 137 }, 138 "indexes": { 139 - "rkey_idx": { 140 - "name": "rkey_idx", 141 "columns": [ 142 { 143 "expression": "rkey", ··· 151 "method": "btree", 152 "with": {} 153 }, 154 - "collection_idx": { 155 - "name": "collection_idx", 156 "columns": [ 157 { 158 "expression": "collection", ··· 166 "method": "btree", 167 "with": {} 168 }, 169 - "repo_idx": { 170 - "name": "repo_idx", 171 "columns": [ 172 { 173 "expression": "repo", ··· 177 } 178 ], 179 "isUnique": false, 180 "concurrently": false, 181 "method": "btree", 182 "with": {} ··· 226 } 227 }, 228 "indexes": { 229 - "subject_idx": { 230 - "name": "subject_idx", 231 "columns": [ 232 { 233 "expression": "subject", ··· 241 "method": "btree", 242 "with": {} 243 }, 244 - "poker_idx": { 245 - "name": "poker_idx", 246 "columns": [ 247 { 248 "expression": "poker",
··· 1 { 2 + "id": "b5b063c6-2451-42ca-8bef-0119b8202cb3", 3 "prevId": "00000000-0000-0000-0000-000000000000", 4 "version": "7", 5 "dialect": "postgresql", ··· 41 } 42 }, 43 "indexes": { 44 + "record_pokes_pokersRepo_index": { 45 + "name": "record_pokes_pokersRepo_index", 46 "columns": [ 47 { 48 "expression": "pokersRepo", ··· 56 "method": "btree", 57 "with": {} 58 }, 59 + "record_pokes_atUri_index": { 60 + "name": "record_pokes_atUri_index", 61 "columns": [ 62 { 63 "expression": "atUri", ··· 121 "primaryKey": false, 122 "notNull": true 123 }, 124 + "atUri": { 125 + "name": "atUri", 126 + "type": "text", 127 + "primaryKey": false, 128 + "notNull": true 129 + }, 130 "data": { 131 "name": "data", 132 "type": "jsonb", ··· 135 }, 136 "indexedAt": { 137 "name": "indexedAt", 138 + "type": "timestamp", 139 "primaryKey": false, 140 "notNull": true, 141 "default": "now()" 142 } 143 }, 144 "indexes": { 145 + "records_rkey_index": { 146 + "name": "records_rkey_index", 147 "columns": [ 148 { 149 "expression": "rkey", ··· 157 "method": "btree", 158 "with": {} 159 }, 160 + "records_collection_index": { 161 + "name": "records_collection_index", 162 "columns": [ 163 { 164 "expression": "collection", ··· 172 "method": "btree", 173 "with": {} 174 }, 175 + "records_repo_index": { 176 + "name": "records_repo_index", 177 "columns": [ 178 { 179 "expression": "repo", ··· 183 } 184 ], 185 "isUnique": false, 186 + "concurrently": false, 187 + "method": "btree", 188 + "with": {} 189 + }, 190 + "records_atUri_index": { 191 + "name": "records_atUri_index", 192 + "columns": [ 193 + { 194 + "expression": "atUri", 195 + "isExpression": false, 196 + "asc": true, 197 + "nulls": "last" 198 + } 199 + ], 200 + "isUnique": true, 201 "concurrently": false, 202 "method": "btree", 203 "with": {} ··· 247 } 248 }, 249 "indexes": { 250 + "user_pokes_subject_index": { 251 + "name": "user_pokes_subject_index", 252 "columns": [ 253 { 254 "expression": "subject", ··· 262 "method": "btree", 263 "with": {} 264 }, 265 + "user_pokes_poker_index": { 266 + "name": "user_pokes_poker_index", 267 "columns": [ 268 { 269 "expression": "poker",
+2 -2
drizzle/meta/_journal.json
··· 5 { 6 "idx": 0, 7 "version": "7", 8 - "when": 1766032962283, 9 - "tag": "0000_yielding_psylocke", 10 "breakpoints": true 11 } 12 ]
··· 5 { 6 "idx": 0, 7 "version": "7", 8 + "when": 1766121859810, 9 + "tag": "0000_breezy_menace", 10 "breakpoints": true 11 } 12 ]
+1
package.json
··· 48 "@atproto/jwk-jose": "^0.1.11", 49 "@atproto/oauth-client-node": "^0.3.13", 50 "@atproto/oauth-types": "^0.5.2", 51 "@oslojs/crypto": "^1.0.1", 52 "@oslojs/encoding": "^1.1.0", 53 "@sveltejs/adapter-node": "^5.4.0",
··· 48 "@atproto/jwk-jose": "^0.1.11", 49 "@atproto/oauth-client-node": "^0.3.13", 50 "@atproto/oauth-types": "^0.5.2", 51 + "@atproto/tap": "^0.0.2", 52 "@oslojs/crypto": "^1.0.1", 53 "@oslojs/encoding": "^1.1.0", 54 "@sveltejs/adapter-node": "^5.4.0",
+44
pnpm-lock.yaml
··· 32 '@atproto/oauth-types': 33 specifier: ^0.5.2 34 version: 0.5.2 35 '@oslojs/crypto': 36 specifier: ^1.0.1 37 version: 1.0.1 ··· 218 219 '@atproto/syntax@0.4.2': 220 resolution: {integrity: sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==} 221 222 '@atproto/xrpc@0.7.7': 223 resolution: {integrity: sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==} ··· 2350 wrappy@1.0.2: 2351 resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} 2352 2353 xtend@4.0.2: 2354 resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} 2355 engines: {node: '>=0.4'} ··· 2550 zod: 3.25.76 2551 2552 '@atproto/syntax@0.4.2': {} 2553 2554 '@atproto/xrpc@0.7.7': 2555 dependencies: ··· 4391 4392 wrappy@1.0.2: 4393 optional: true 4394 4395 xtend@4.0.2: {} 4396
··· 32 '@atproto/oauth-types': 33 specifier: ^0.5.2 34 version: 0.5.2 35 + '@atproto/tap': 36 + specifier: ^0.0.2 37 + version: 0.0.2 38 '@oslojs/crypto': 39 specifier: ^1.0.1 40 version: 1.0.1 ··· 221 222 '@atproto/syntax@0.4.2': 223 resolution: {integrity: sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==} 224 + 225 + '@atproto/tap@0.0.2': 226 + resolution: {integrity: sha512-CrfJWrvozuSIokOQLMeSFcF5ZpstpxIZ9PnBpgIkbLQQKb3wO+0dn90xZN5jlLjczPHvT4PrF1z8uYgVlujTlg==} 227 + engines: {node: '>=18.7.0'} 228 + 229 + '@atproto/ws-client@0.0.4': 230 + resolution: {integrity: sha512-dox1XIymuC7/ZRhUqKezIGgooZS45C6vHCfu0PnWjfvsLCK2kAlnvX4IBkA/WpcoijDhQ9ejChnFbo/sLmgvAg==} 231 + engines: {node: '>=18.7.0'} 232 233 '@atproto/xrpc@0.7.7': 234 resolution: {integrity: sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==} ··· 2361 wrappy@1.0.2: 2362 resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} 2363 2364 + ws@8.18.3: 2365 + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} 2366 + engines: {node: '>=10.0.0'} 2367 + peerDependencies: 2368 + bufferutil: ^4.0.1 2369 + utf-8-validate: '>=5.0.2' 2370 + peerDependenciesMeta: 2371 + bufferutil: 2372 + optional: true 2373 + utf-8-validate: 2374 + optional: true 2375 + 2376 xtend@4.0.2: 2377 resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} 2378 engines: {node: '>=0.4'} ··· 2573 zod: 3.25.76 2574 2575 '@atproto/syntax@0.4.2': {} 2576 + 2577 + '@atproto/tap@0.0.2': 2578 + dependencies: 2579 + '@atproto/common': 0.5.3 2580 + '@atproto/syntax': 0.4.2 2581 + '@atproto/ws-client': 0.0.4 2582 + ws: 8.18.3 2583 + zod: 3.25.76 2584 + transitivePeerDependencies: 2585 + - bufferutil 2586 + - utf-8-validate 2587 + 2588 + '@atproto/ws-client@0.0.4': 2589 + dependencies: 2590 + '@atproto/common': 0.5.3 2591 + ws: 8.18.3 2592 + transitivePeerDependencies: 2593 + - bufferutil 2594 + - utf-8-validate 2595 2596 '@atproto/xrpc@0.7.7': 2597 dependencies: ··· 4433 4434 wrappy@1.0.2: 4435 optional: true 4436 + 4437 + ws@8.18.3: {} 4438 4439 xtend@4.0.2: {} 4440
-1
src/lib/server/db/index.ts
··· 6 if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set'); 7 8 logger.info('Connected to database'); 9 - logger.info(`Database url: ${env.DATABASE_URL}`); 10 export const db = drizzle(env.DATABASE_URL!,{ schema });
··· 6 if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set'); 7 8 logger.info('Connected to database'); 9 export const db = drizzle(env.DATABASE_URL!,{ schema });
+11 -9
src/lib/server/db/schema.ts
··· 1 - import { index, text, integer, jsonb, pgTable, serial, time, varchar } from 'drizzle-orm/pg-core'; 2 3 /** 4 * Holds collected records from firehose, backfilled, or manually entered ··· 8 rkey: varchar().notNull(), 9 collection: varchar().notNull(), 10 repo: varchar().notNull(), 11 data: jsonb().notNull(), 12 - indexedAt: time().notNull().defaultNow() 13 }, (table) => [ 14 - index('rkey_idx').on(table.rkey), 15 - index('collection_idx').on(table.collection), 16 - index('repo_idx').on(table.repo), 17 ]); 18 19 ··· 30 atUri: text().notNull(), 31 indexedAt: time().notNull().defaultNow() 32 },(table) => [ 33 - index('pokers_repo_idx').on(table.pokersRepo), 34 - index('at_uri_idx').on(table.atUri) 35 ]); 36 37 /** ··· 48 at_uri: text().notNull(), 49 indexedAt: time().notNull().defaultNow() 50 }, (table) => [ 51 - index('subject_idx').on(table.subject), 52 - index('poker_idx').on(table.poker) 53 ]); 54
··· 1 + import { index, text, integer, jsonb, pgTable, serial, time, varchar, uniqueIndex, timestamp } from 'drizzle-orm/pg-core'; 2 3 /** 4 * Holds collected records from firehose, backfilled, or manually entered ··· 8 rkey: varchar().notNull(), 9 collection: varchar().notNull(), 10 repo: varchar().notNull(), 11 + atUri: text().notNull(), 12 data: jsonb().notNull(), 13 + indexedAt: timestamp({ mode: 'date' }).notNull().defaultNow() 14 }, (table) => [ 15 + index().on(table.rkey), 16 + index().on(table.collection), 17 + index().on(table.repo), 18 + uniqueIndex().on(table.atUri), 19 ]); 20 21 ··· 32 atUri: text().notNull(), 33 indexedAt: time().notNull().defaultNow() 34 },(table) => [ 35 + index().on(table.pokersRepo), 36 + index().on(table.atUri) 37 ]); 38 39 /** ··· 50 at_uri: text().notNull(), 51 indexedAt: time().notNull().defaultNow() 52 }, (table) => [ 53 + index().on(table.subject), 54 + index().on(table.poker) 55 ]); 56
+81
src/routes/webhooks/tap/+server.ts
···
··· 1 + import { json } from '@sveltejs/kit'; 2 + import { env } from '$env/dynamic/private'; 3 + import type { RequestHandler } from './$types'; 4 + import { logger } from '$lib/server/logger'; 5 + import { assureAdminAuth, type RecordEvent, type TapEvent } from '@atproto/tap'; 6 + import { db } from '$lib/server/db'; 7 + import { recordsTable } from '$lib/server/db/schema'; 8 + import { eq } from 'drizzle-orm'; 9 + 10 + export const POST: RequestHandler = async ({ request }) => { 11 + const auth = request.headers.get('Authorization'); 12 + 13 + if (auth === null) { 14 + logger.error('Missing Authorization header'); 15 + return json({ error: 'Missing Authorization header' }, { status: 401 }); 16 + } 17 + 18 + try{ 19 + if(env.TAP_ADMIN_PASSWORD === undefined) throw new Error('TAP_ADMIN_PASSWORD is not set'); 20 + assureAdminAuth(env.TAP_ADMIN_PASSWORD, auth); 21 + }catch (err){ 22 + const errorMessage = (err as Error).message; 23 + logger.error('Tap webhook auth error: ' + errorMessage + ''); 24 + return json({ error: 'Not authenticated' }, { status: 401 }); 25 + } 26 + 27 + try { 28 + const body = await request.json(); 29 + await parseAndProcessTapEvent(body); 30 + 31 + 32 + //This should just respond with 200 OK. comment out to not ack 33 + return json({ }); 34 + } catch (err) { 35 + console.error('Failed to process event:', err); 36 + return json({ error: 'Failed to process event' }, { status: 500 }); 37 + } 38 + 39 + }; 40 + 41 + const parseAndProcessTapEvent = async (event: TapEvent) => { 42 + switch (event.type) { 43 + case 'identity': 44 + logger.info(event); 45 + break; 46 + case 'record': 47 + await saveRecord(event.record as RecordEvent); 48 + break; 49 + default: 50 + throw new Error(`Unsupported event type: ${JSON.stringify(event)}`); 51 + } 52 + }; 53 + 54 + 55 + const saveRecord = async (event: RecordEvent) => { 56 + const atUri = `${event.did}/${event.collection}/${event.rkey}`; 57 + logger.info(`Processing record event: ${atUri}`); 58 + if (event.action === 'create' || event.action === 'update') { 59 + if (!event.record) { 60 + logger.warn(`Record event with action ${event.action} missing record data: ${event.rkey}`); 61 + return; 62 + } 63 + 64 + await db.insert(recordsTable).values({ 65 + rkey: event.rkey, 66 + collection: event.collection, 67 + repo: event.did, 68 + atUri: atUri, 69 + data: event.record, 70 + }).onConflictDoUpdate({ 71 + target: recordsTable.atUri, 72 + set: { 73 + data: event.record, 74 + 75 + } 76 + }); 77 + logger.info(`Saved record: ${event.did}/${event.collection}/${event.rkey}`); 78 + } else if (event.action === 'delete') { 79 + await db.delete(recordsTable).where(eq(recordsTable.atUri, atUri)); 80 + } 81 + };