+6
-1
.gitignore
+6
-1
.gitignore
+14
dev.compose.yml
+14
dev.compose.yml
···
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
+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
-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
+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
+2
-2
drizzle/meta/_journal.json
+1
package.json
+1
package.json
+44
pnpm-lock.yaml
+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
-1
src/lib/server/db/index.ts
+11
-9
src/lib/server/db/schema.ts
+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
+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
+
};