@atproto/tap 0.3.4 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/util.ts DELETED
@@ -1,42 +0,0 @@
1
- export const formatAdminAuthHeader = (password: string) => {
2
- return 'Basic ' + Buffer.from(`admin:${password}`).toString('base64')
3
- }
4
-
5
- export const parseAdminAuthHeader = (header: string) => {
6
- const noPrefix = header.startsWith('Basic ') ? header.slice(6) : header
7
- const [username, password] = Buffer.from(noPrefix, 'base64')
8
- .toString()
9
- .split(':')
10
- if (username !== 'admin') {
11
- throw new Error("Unexpected username in admin headers. Expected 'admin'")
12
- }
13
- return password
14
- }
15
-
16
- export const assureAdminAuth = (expectedPassword: string, header: string) => {
17
- const headerPassword = parseAdminAuthHeader(header)
18
- const passEqual = timingSafeEqual(headerPassword, expectedPassword)
19
- if (!passEqual) {
20
- throw new Error('Invalid admin password')
21
- }
22
- }
23
-
24
- const timingSafeEqual = (a: string, b: string): boolean => {
25
- const bufA = Buffer.from(a)
26
- const bufB = Buffer.from(b)
27
- if (bufA.length !== bufB.length) {
28
- // Compare against self to maintain constant time even with length mismatch
29
- Buffer.from(a).compare(Buffer.from(a))
30
- return false
31
- }
32
- return bufA.compare(bufB) === 0
33
- }
34
-
35
- export function isCausedBySignal(err: unknown, signal: AbortSignal) {
36
- if (!signal.aborted) return false
37
- if (signal.reason == null) return false // Ignore nullish reasons
38
- return (
39
- err === signal.reason ||
40
- (err instanceof Error && err.cause === signal.reason)
41
- )
42
- }
package/tests/_util.ts DELETED
@@ -1,63 +0,0 @@
1
- import { WebSocketServer } from 'ws'
2
- import { HandlerOpts } from '../src/channel.js'
3
- import { IdentityEvent, RecordEvent } from '../src/types.js'
4
-
5
- export type MockOpts = HandlerOpts & { acked: boolean }
6
-
7
- export const createMockOpts = (): MockOpts => {
8
- const opts = {
9
- signal: new AbortController().signal,
10
- acked: false,
11
- ack: async () => {
12
- opts.acked = true
13
- },
14
- }
15
- return opts
16
- }
17
-
18
- export const createRecordEvent = (
19
- overrides: Partial<RecordEvent> = {},
20
- ): RecordEvent => ({
21
- id: 1,
22
- type: 'record',
23
- did: 'did:example:alice',
24
- rev: 'abc123',
25
- collection: 'com.example.post',
26
- rkey: 'abc123',
27
- action: 'create',
28
- record: { text: 'hello' },
29
- cid: 'bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq',
30
- live: true,
31
- ...overrides,
32
- })
33
-
34
- export const createIdentityEvent = (): IdentityEvent => ({
35
- id: 2,
36
- type: 'identity',
37
- did: 'did:example:alice',
38
- handle: 'alice.test',
39
- isActive: true,
40
- status: 'active',
41
- })
42
-
43
- export async function createWebSocketServer() {
44
- return new Promise<WebSocketServer & AsyncDisposable>((resolve, reject) => {
45
- const server = new WebSocketServer({ port: 0 }, () => {
46
- server.off('error', reject)
47
- resolve(
48
- Object.defineProperty(server, Symbol.asyncDispose, {
49
- value: disposeWebSocketServer,
50
- }) as WebSocketServer & AsyncDisposable,
51
- )
52
- }).once('error', reject)
53
- })
54
- }
55
-
56
- async function disposeWebSocketServer(this: WebSocketServer) {
57
- return new Promise<void>((resolve, reject) => {
58
- this.close((err) => {
59
- if (err) reject(err)
60
- else resolve()
61
- })
62
- })
63
- }
@@ -1,371 +0,0 @@
1
- import { describe, expect, it } from 'vitest'
2
- import { AddressInfo } from 'ws'
3
- import { TapChannel, TapHandler } from '../src/channel.js'
4
- import { TapEvent } from '../src/types.js'
5
- import { createWebSocketServer } from './_util.js'
6
-
7
- const createRecordEvent = (id: number) => ({
8
- id,
9
- type: 'record' as const,
10
- record: {
11
- did: 'did:example:alice',
12
- rev: '3abc123',
13
- collection: 'com.example.post',
14
- rkey: 'abc123',
15
- action: 'create' as const,
16
- record: { text: 'hello' },
17
- cid: 'bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq',
18
- live: true,
19
- },
20
- })
21
-
22
- const createIdentityEvent = (id: number) => ({
23
- id,
24
- type: 'identity' as const,
25
- identity: {
26
- did: 'did:example:alice',
27
- handle: 'alice.test',
28
- is_active: true,
29
- status: 'active' as const,
30
- },
31
- })
32
-
33
- describe('TapChannel', () => {
34
- describe('receiving events', () => {
35
- it('receives and parses record events', async () => {
36
- await using server = await createWebSocketServer()
37
-
38
- const { port } = server.address() as AddressInfo
39
-
40
- const receivedEvents: TapEvent[] = []
41
-
42
- server.on('connection', (socket) => {
43
- socket.send(JSON.stringify(createRecordEvent(1)))
44
- socket.on('message', (data) => {
45
- const msg = JSON.parse(data.toString())
46
- if (msg.type === 'ack') {
47
- socket.close()
48
- }
49
- })
50
- })
51
-
52
- const handler: TapHandler = {
53
- onEvent: async (evt, opts) => {
54
- receivedEvents.push(evt)
55
- await opts.ack()
56
- },
57
- onError: (err) => {
58
- throw err
59
- },
60
- }
61
-
62
- await using channel = new TapChannel(`ws://localhost:${port}`, handler)
63
- await channel.start()
64
-
65
- expect(receivedEvents).toHaveLength(1)
66
- expect(receivedEvents[0].type).toBe('record')
67
- expect(receivedEvents[0].did).toBe('did:example:alice')
68
- if (receivedEvents[0].type === 'record') {
69
- expect(receivedEvents[0].collection).toBe('com.example.post')
70
- expect(receivedEvents[0].action).toBe('create')
71
- }
72
- })
73
-
74
- it('receives and parses identity events', async () => {
75
- await using server = await createWebSocketServer()
76
-
77
- const { port } = server.address() as AddressInfo
78
-
79
- const receivedEvents: TapEvent[] = []
80
-
81
- server.on('connection', (socket) => {
82
- socket.send(JSON.stringify(createIdentityEvent(1)))
83
- socket.on('message', (data) => {
84
- const msg = JSON.parse(data.toString())
85
- if (msg.type === 'ack') {
86
- socket.close()
87
- }
88
- })
89
- })
90
-
91
- const handler: TapHandler = {
92
- onEvent: async (evt, opts) => {
93
- receivedEvents.push(evt)
94
- await opts.ack()
95
- },
96
- onError: (err) => {
97
- throw err
98
- },
99
- }
100
-
101
- await using channel = new TapChannel(`ws://localhost:${port}`, handler)
102
- await channel.start()
103
-
104
- expect(receivedEvents).toHaveLength(1)
105
- expect(receivedEvents[0].type).toBe('identity')
106
- expect(receivedEvents[0].did).toBe('did:example:alice')
107
- if (receivedEvents[0].type === 'identity') {
108
- expect(receivedEvents[0].handle).toBe('alice.test')
109
- expect(receivedEvents[0].status).toBe('active')
110
- }
111
- })
112
- })
113
-
114
- describe('ack behavior', () => {
115
- it('sends ack when handler calls ack()', async () => {
116
- await using server = await createWebSocketServer()
117
-
118
- const { port } = server.address() as AddressInfo
119
-
120
- const receivedAcks: number[] = []
121
-
122
- server.on('connection', (socket) => {
123
- socket.send(JSON.stringify(createRecordEvent(42)))
124
- socket.on('message', (data) => {
125
- const msg = JSON.parse(data.toString())
126
- if (msg.type === 'ack') {
127
- receivedAcks.push(msg.id)
128
- socket.close()
129
- }
130
- })
131
- })
132
-
133
- const handler: TapHandler = {
134
- onEvent: async (_evt, opts) => {
135
- await opts.ack()
136
- },
137
- onError: (err) => {
138
- throw err
139
- },
140
- }
141
-
142
- await using channel = new TapChannel(`ws://localhost:${port}`, handler)
143
- await channel.start()
144
-
145
- expect(receivedAcks).toEqual([42])
146
- })
147
-
148
- it('does not send ack if handler throws', async () => {
149
- await using server = await createWebSocketServer()
150
-
151
- const { port } = server.address() as AddressInfo
152
-
153
- const receivedAcks: number[] = []
154
- const errors: Error[] = []
155
-
156
- server.on('connection', (socket) => {
157
- socket.send(JSON.stringify(createRecordEvent(1)))
158
- socket.on('message', (data) => {
159
- const msg = JSON.parse(data.toString())
160
- if (msg.type === 'ack') {
161
- receivedAcks.push(msg.id)
162
- }
163
- })
164
- // Close after a short delay to let error propagate
165
- setTimeout(() => socket.close(), 100)
166
- })
167
-
168
- const handler: TapHandler = {
169
- onEvent: async () => {
170
- throw new Error('Handler failed')
171
- },
172
- onError: (err) => {
173
- errors.push(err)
174
- },
175
- }
176
-
177
- await using channel = new TapChannel(`ws://localhost:${port}`, handler)
178
- await channel.start()
179
-
180
- expect(receivedAcks).toHaveLength(0)
181
- expect(errors).toHaveLength(1)
182
- expect(errors[0].message).toContain('Failed to process event')
183
- })
184
-
185
- it('does not send ack if handler does not call ack()', async () => {
186
- await using server = await createWebSocketServer()
187
-
188
- const { port } = server.address() as AddressInfo
189
-
190
- const receivedAcks: number[] = []
191
-
192
- server.on('connection', (socket) => {
193
- socket.send(JSON.stringify(createRecordEvent(1)))
194
- socket.on('message', (data) => {
195
- const msg = JSON.parse(data.toString())
196
- if (msg.type === 'ack') {
197
- receivedAcks.push(msg.id)
198
- }
199
- })
200
- // Close after a short delay
201
- setTimeout(() => socket.close(), 100)
202
- })
203
-
204
- const handler: TapHandler = {
205
- onEvent: async () => {
206
- // Don't call ack
207
- },
208
- onError: (err) => {
209
- throw err
210
- },
211
- }
212
-
213
- await using channel = new TapChannel(`ws://localhost:${port}`, handler)
214
- await channel.start()
215
-
216
- expect(receivedAcks).toHaveLength(0)
217
- })
218
-
219
- it('handles reconnection and receives events from new connection', async () => {
220
- await using server = await createWebSocketServer()
221
-
222
- const { port } = server.address() as AddressInfo
223
-
224
- const receivedEvents: TapEvent[] = []
225
- const receivedAcks: number[] = []
226
- let connectionCount = 0
227
-
228
- server.on('connection', (socket) => {
229
- connectionCount++
230
- // Send a different event each connection
231
- const eventId = connectionCount
232
- socket.send(JSON.stringify(createRecordEvent(eventId)))
233
- socket.on('message', (data) => {
234
- const msg = JSON.parse(data.toString())
235
- if (msg.type === 'ack') {
236
- receivedAcks.push(msg.id)
237
- if (connectionCount === 1) {
238
- // After first ack, terminate to trigger reconnect
239
- socket.terminate()
240
- } else {
241
- // After second ack, close cleanly
242
- socket.close()
243
- }
244
- }
245
- })
246
- })
247
-
248
- const handler: TapHandler = {
249
- onEvent: async (evt, opts) => {
250
- receivedEvents.push(evt)
251
- await opts.ack()
252
- },
253
- onError: () => {},
254
- }
255
-
256
- await using channel = new TapChannel(`ws://localhost:${port}`, handler, {
257
- maxReconnectSeconds: 1,
258
- })
259
-
260
- await channel.start()
261
-
262
- // Should have connected twice and received two events
263
- expect(connectionCount).toBe(2)
264
- expect(receivedEvents).toHaveLength(2)
265
- expect(receivedEvents[0].id).toBe(1)
266
- expect(receivedEvents[1].id).toBe(2)
267
- expect(receivedAcks).toContain(1)
268
- expect(receivedAcks).toContain(2)
269
- })
270
- })
271
-
272
- describe('multiple events', () => {
273
- it('processes multiple events in sequence', async () => {
274
- await using server = await createWebSocketServer()
275
-
276
- const { port } = server.address() as AddressInfo
277
-
278
- const receivedEvents: TapEvent[] = []
279
- const receivedAcks: number[] = []
280
-
281
- server.on('connection', (socket) => {
282
- socket.send(JSON.stringify(createRecordEvent(1)))
283
- socket.send(JSON.stringify(createRecordEvent(2)))
284
- socket.send(JSON.stringify(createIdentityEvent(3)))
285
- socket.on('message', (data) => {
286
- const msg = JSON.parse(data.toString())
287
- if (msg.type === 'ack') {
288
- receivedAcks.push(msg.id)
289
- if (receivedAcks.length === 3) {
290
- socket.close()
291
- }
292
- }
293
- })
294
- })
295
-
296
- const handler: TapHandler = {
297
- onEvent: async (evt, opts) => {
298
- receivedEvents.push(evt)
299
- await opts.ack()
300
- },
301
- onError: (err) => {
302
- throw err
303
- },
304
- }
305
-
306
- await using channel = new TapChannel(`ws://localhost:${port}`, handler)
307
- await channel.start()
308
-
309
- expect(receivedEvents).toHaveLength(3)
310
- expect(receivedEvents[0].id).toBe(1)
311
- expect(receivedEvents[1].id).toBe(2)
312
- expect(receivedEvents[2].id).toBe(3)
313
- expect(receivedAcks).toEqual([1, 2, 3])
314
- })
315
- })
316
-
317
- describe('auth', () => {
318
- it('includes auth header when adminPassword is provided', async () => {
319
- await using server = await createWebSocketServer()
320
-
321
- const { port } = server.address() as AddressInfo
322
-
323
- let receivedAuthHeader: string | undefined
324
-
325
- server.on('connection', (socket, request) => {
326
- receivedAuthHeader = request.headers.authorization
327
- socket.close()
328
- })
329
-
330
- const handler: TapHandler = {
331
- onEvent: async () => {},
332
- onError: () => {},
333
- }
334
-
335
- await using channel = new TapChannel(`ws://localhost:${port}`, handler, {
336
- adminPassword: 'secret',
337
- })
338
- await channel.start()
339
-
340
- expect(receivedAuthHeader).toBe('Basic YWRtaW46c2VjcmV0')
341
- })
342
- })
343
-
344
- describe('error handling', () => {
345
- it('calls onError for malformed messages', async () => {
346
- await using server = await createWebSocketServer()
347
-
348
- const { port } = server.address() as AddressInfo
349
-
350
- const errors: Error[] = []
351
-
352
- server.on('connection', (socket) => {
353
- socket.send('not valid json')
354
- setTimeout(() => socket.close(), 100)
355
- })
356
-
357
- const handler: TapHandler = {
358
- onEvent: async () => {},
359
- onError: (err) => {
360
- errors.push(err)
361
- },
362
- }
363
-
364
- await using channel = new TapChannel(`ws://localhost:${port}`, handler)
365
- await channel.start()
366
-
367
- expect(errors).toHaveLength(1)
368
- expect(errors[0].message).toBe('Failed to parse message')
369
- })
370
- })
371
- })
@@ -1,207 +0,0 @@
1
- import { once } from 'node:events'
2
- import * as http from 'node:http'
3
- import { AddressInfo } from 'node:net'
4
- import { default as express } from 'express'
5
- // eslint-disable-next-line import/default
6
- import httpTerminator from 'http-terminator'
7
- import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
8
- import { Tap } from '../src/client.js'
9
-
10
- describe('Tap client', () => {
11
- describe('constructor', () => {
12
- it('accepts http URL', () => {
13
- const tap = new Tap('http://localhost:8080')
14
- expect(tap.url).toBe('http://localhost:8080')
15
- })
16
-
17
- it('accepts https URL', () => {
18
- const tap = new Tap('https://example.com')
19
- expect(tap.url).toBe('https://example.com')
20
- })
21
-
22
- it('throws on invalid URL', () => {
23
- expect(() => new Tap('ws://localhost:8080')).toThrow(
24
- 'Invalid URL, expected http:// or https://',
25
- )
26
- expect(() => new Tap('localhost:8080')).toThrow(
27
- 'Invalid URL, expected http:// or https://',
28
- )
29
- })
30
- })
31
-
32
- describe('HTTP methods', () => {
33
- let terminator: httpTerminator.HttpTerminator
34
- let tap: Tap
35
- let requests: {
36
- path: string
37
- method: string
38
- body?: unknown
39
- headers: http.IncomingHttpHeaders
40
- }[]
41
-
42
- beforeAll(async () => {
43
- const app = express()
44
- app.use(express.json())
45
-
46
- requests = []
47
-
48
- app.post('/repos/add', (req, res) => {
49
- requests.push({
50
- path: req.path,
51
- method: req.method,
52
- body: req.body,
53
- headers: req.headers,
54
- })
55
- res.sendStatus(200)
56
- })
57
-
58
- app.post('/repos/remove', (req, res) => {
59
- requests.push({
60
- path: req.path,
61
- method: req.method,
62
- body: req.body,
63
- headers: req.headers,
64
- })
65
- res.sendStatus(200)
66
- })
67
-
68
- app.get('/resolve/:did', (req, res) => {
69
- requests.push({
70
- path: req.path,
71
- method: req.method,
72
- headers: req.headers,
73
- })
74
- if (req.params.did === 'did:example:notfound') {
75
- res.sendStatus(404)
76
- return
77
- }
78
- res.json({
79
- id: req.params.did,
80
- alsoKnownAs: ['at://alice.test'],
81
- verificationMethod: [],
82
- service: [],
83
- })
84
- })
85
-
86
- app.get('/info/:did', (req, res) => {
87
- requests.push({
88
- path: req.path,
89
- method: req.method,
90
- headers: req.headers,
91
- })
92
- res.json({
93
- did: req.params.did,
94
- handle: 'alice.test',
95
- state: 'active',
96
- rev: '3abc123',
97
- records: 42,
98
- })
99
- })
100
-
101
- const server = app.listen()
102
- await once(server, 'listening')
103
- const { port } = server.address() as AddressInfo
104
- terminator = httpTerminator.createHttpTerminator({ server })
105
- tap = new Tap(`http://localhost:${port}`, { adminPassword: 'secret' })
106
- })
107
-
108
- afterAll(async () => {
109
- await terminator?.terminate()
110
- })
111
-
112
- beforeEach(() => {
113
- requests = []
114
- })
115
-
116
- describe('addRepos', () => {
117
- it('sends POST to /repos/add with dids', async () => {
118
- await tap.addRepos(['did:example:alice', 'did:example:bob'])
119
- expect(requests).toHaveLength(1)
120
- expect(requests[0].path).toBe('/repos/add')
121
- expect(requests[0].method).toBe('POST')
122
- expect(requests[0].body).toEqual({
123
- dids: ['did:example:alice', 'did:example:bob'],
124
- })
125
- })
126
-
127
- it('includes auth header', async () => {
128
- await tap.addRepos(['did:example:alice'])
129
- expect(requests[0].headers.authorization).toBe('Basic YWRtaW46c2VjcmV0')
130
- })
131
- })
132
-
133
- describe('removeRepos', () => {
134
- it('sends POST to /repos/remove with dids', async () => {
135
- await tap.removeRepos(['did:example:alice'])
136
- expect(requests).toHaveLength(1)
137
- expect(requests[0].path).toBe('/repos/remove')
138
- expect(requests[0].method).toBe('POST')
139
- expect(requests[0].body).toEqual({ dids: ['did:example:alice'] })
140
- })
141
- })
142
-
143
- describe('resolveDid', () => {
144
- it('fetches and parses DID document', async () => {
145
- const doc = await tap.resolveDid('did:example:alice')
146
- expect(doc).not.toBeNull()
147
- expect(doc?.id).toBe('did:example:alice')
148
- expect(doc?.alsoKnownAs).toEqual(['at://alice.test'])
149
- })
150
-
151
- it('returns null for 404', async () => {
152
- const doc = await tap.resolveDid('did:example:notfound')
153
- expect(doc).toBeNull()
154
- })
155
- })
156
-
157
- describe('getRepoInfo', () => {
158
- it('fetches and parses repo info', async () => {
159
- const info = await tap.getRepoInfo('did:example:alice')
160
- expect(info.did).toBe('did:example:alice')
161
- expect(info.handle).toBe('alice.test')
162
- expect(info.state).toBe('active')
163
- expect(info.records).toBe(42)
164
- })
165
- })
166
- })
167
-
168
- describe('HTTP error handling', () => {
169
- let terminator: httpTerminator.HttpTerminator
170
- let tap: Tap
171
-
172
- beforeAll(async () => {
173
- const app = express()
174
- app.use(express.json())
175
-
176
- app.post('/repos/add', (_req, res) => {
177
- res.status(500).send('Internal Server Error')
178
- })
179
-
180
- app.get('/info/:did', (_req, res) => {
181
- res.status(500).send('Internal Server Error')
182
- })
183
-
184
- const server = app.listen(0)
185
- await once(server, 'listening')
186
- const { port } = server.address() as AddressInfo
187
- terminator = httpTerminator.createHttpTerminator({ server })
188
- tap = new Tap(`http://localhost:${port}`)
189
- })
190
-
191
- afterAll(async () => {
192
- await terminator?.terminate()
193
- })
194
-
195
- it('throws on addRepos failure', async () => {
196
- await expect(tap.addRepos(['did:example:alice'])).rejects.toThrow(
197
- 'Failed to add repos',
198
- )
199
- })
200
-
201
- it('throws on getRepoInfo failure', async () => {
202
- await expect(tap.getRepoInfo('did:example:alice')).rejects.toThrow(
203
- 'Failed to get repo info',
204
- )
205
- })
206
- })
207
- })