@atproto/tap 0.0.2
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/CHANGELOG.md +10 -0
- package/LICENSE.txt +7 -0
- package/README.md +221 -0
- package/dist/channel.d.ts +32 -0
- package/dist/channel.d.ts.map +1 -0
- package/dist/channel.js +146 -0
- package/dist/channel.js.map +1 -0
- package/dist/client.d.ts +19 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +104 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/simple-indexer.d.ts +17 -0
- package/dist/simple-indexer.d.ts.map +1 -0
- package/dist/simple-indexer.js +53 -0
- package/dist/simple-indexer.js.map +1 -0
- package/dist/types.d.ts +286 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +75 -0
- package/dist/types.js.map +1 -0
- package/dist/util.d.ts +4 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +37 -0
- package/dist/util.js.map +1 -0
- package/jest.config.js +10 -0
- package/package.json +43 -0
- package/src/channel.ts +152 -0
- package/src/client.ts +100 -0
- package/src/index.ts +5 -0
- package/src/simple-indexer.ts +47 -0
- package/src/types.ts +109 -0
- package/src/util.ts +33 -0
- package/tests/channel.test.ts +379 -0
- package/tests/client.test.ts +208 -0
- package/tests/simple-indexer.test.ts +188 -0
- package/tests/util.test.ts +88 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +4 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { HandlerOpts } from '../src/channel'
|
|
2
|
+
import { SimpleIndexer } from '../src/simple-indexer'
|
|
3
|
+
import { IdentityEvent, RecordEvent } from '../src/types'
|
|
4
|
+
|
|
5
|
+
const createMockOpts = (): HandlerOpts & { acked: boolean } => {
|
|
6
|
+
const opts = {
|
|
7
|
+
signal: new AbortController().signal,
|
|
8
|
+
acked: false,
|
|
9
|
+
ack: async () => {
|
|
10
|
+
opts.acked = true
|
|
11
|
+
},
|
|
12
|
+
}
|
|
13
|
+
return opts
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const createRecordEvent = (): RecordEvent => ({
|
|
17
|
+
id: 1,
|
|
18
|
+
type: 'record',
|
|
19
|
+
did: 'did:example:alice',
|
|
20
|
+
rev: 'abc123',
|
|
21
|
+
collection: 'com.example.post',
|
|
22
|
+
rkey: 'abc123',
|
|
23
|
+
action: 'create',
|
|
24
|
+
record: { text: 'hello' },
|
|
25
|
+
cid: 'bafyabc',
|
|
26
|
+
live: true,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const createIdentityEvent = (): IdentityEvent => ({
|
|
30
|
+
id: 2,
|
|
31
|
+
type: 'identity',
|
|
32
|
+
did: 'did:example:alice',
|
|
33
|
+
handle: 'alice.test',
|
|
34
|
+
isActive: true,
|
|
35
|
+
status: 'active',
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('SimpleIndexer', () => {
|
|
39
|
+
describe('event routing', () => {
|
|
40
|
+
it('routes record events to record handler', async () => {
|
|
41
|
+
const indexer = new SimpleIndexer()
|
|
42
|
+
const receivedEvents: RecordEvent[] = []
|
|
43
|
+
|
|
44
|
+
indexer.record(async (evt) => {
|
|
45
|
+
receivedEvents.push(evt)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const opts = createMockOpts()
|
|
49
|
+
await indexer.onEvent(createRecordEvent(), opts)
|
|
50
|
+
|
|
51
|
+
expect(receivedEvents).toHaveLength(1)
|
|
52
|
+
expect(receivedEvents[0].type).toBe('record')
|
|
53
|
+
expect(receivedEvents[0].collection).toBe('com.example.post')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('routes identity events to identity handler', async () => {
|
|
57
|
+
const indexer = new SimpleIndexer()
|
|
58
|
+
const receivedEvents: IdentityEvent[] = []
|
|
59
|
+
|
|
60
|
+
indexer.identity(async (evt) => {
|
|
61
|
+
receivedEvents.push(evt)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const opts = createMockOpts()
|
|
65
|
+
await indexer.onEvent(createIdentityEvent(), opts)
|
|
66
|
+
|
|
67
|
+
expect(receivedEvents).toHaveLength(1)
|
|
68
|
+
expect(receivedEvents[0].type).toBe('identity')
|
|
69
|
+
expect(receivedEvents[0].handle).toBe('alice.test')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('does not call identity handler for record events', async () => {
|
|
73
|
+
const indexer = new SimpleIndexer()
|
|
74
|
+
let identityCalled = false
|
|
75
|
+
let recordCalled = false
|
|
76
|
+
|
|
77
|
+
indexer.identity(async () => {
|
|
78
|
+
identityCalled = true
|
|
79
|
+
})
|
|
80
|
+
indexer.record(async () => {
|
|
81
|
+
recordCalled = true
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const opts = createMockOpts()
|
|
85
|
+
await indexer.onEvent(createRecordEvent(), opts)
|
|
86
|
+
|
|
87
|
+
expect(recordCalled).toBe(true)
|
|
88
|
+
expect(identityCalled).toBe(false)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('does not call record handler for identity events', async () => {
|
|
92
|
+
const indexer = new SimpleIndexer()
|
|
93
|
+
let identityCalled = false
|
|
94
|
+
let recordCalled = false
|
|
95
|
+
|
|
96
|
+
indexer.identity(async () => {
|
|
97
|
+
identityCalled = true
|
|
98
|
+
})
|
|
99
|
+
indexer.record(async () => {
|
|
100
|
+
recordCalled = true
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const opts = createMockOpts()
|
|
104
|
+
await indexer.onEvent(createIdentityEvent(), opts)
|
|
105
|
+
|
|
106
|
+
expect(identityCalled).toBe(true)
|
|
107
|
+
expect(recordCalled).toBe(false)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('ack behavior', () => {
|
|
112
|
+
it('calls ack after handler completes', async () => {
|
|
113
|
+
const indexer = new SimpleIndexer()
|
|
114
|
+
indexer.record(async () => {})
|
|
115
|
+
|
|
116
|
+
const opts = createMockOpts()
|
|
117
|
+
await indexer.onEvent(createRecordEvent(), opts)
|
|
118
|
+
|
|
119
|
+
expect(opts.acked).toBe(true)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('calls ack even when no handler is registered', async () => {
|
|
123
|
+
const indexer = new SimpleIndexer()
|
|
124
|
+
// No handlers registered
|
|
125
|
+
|
|
126
|
+
const opts = createMockOpts()
|
|
127
|
+
await indexer.onEvent(createRecordEvent(), opts)
|
|
128
|
+
|
|
129
|
+
expect(opts.acked).toBe(true)
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe('error handling', () => {
|
|
134
|
+
it('calls error handler when provided', () => {
|
|
135
|
+
const indexer = new SimpleIndexer()
|
|
136
|
+
const errors: Error[] = []
|
|
137
|
+
|
|
138
|
+
indexer.error((err) => {
|
|
139
|
+
errors.push(err)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
const testError = new Error('test error')
|
|
143
|
+
indexer.onError(testError)
|
|
144
|
+
|
|
145
|
+
expect(errors).toHaveLength(1)
|
|
146
|
+
expect(errors[0]).toBe(testError)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('throws when no error handler is registered', () => {
|
|
150
|
+
const indexer = new SimpleIndexer()
|
|
151
|
+
const testError = new Error('test error')
|
|
152
|
+
|
|
153
|
+
expect(() => indexer.onError(testError)).toThrow('test error')
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
describe('handler opts passthrough', () => {
|
|
158
|
+
it('passes opts to record handler', async () => {
|
|
159
|
+
const indexer = new SimpleIndexer()
|
|
160
|
+
let receivedOpts: HandlerOpts | undefined
|
|
161
|
+
|
|
162
|
+
indexer.record(async (_evt, opts) => {
|
|
163
|
+
receivedOpts = opts
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
const opts = createMockOpts()
|
|
167
|
+
await indexer.onEvent(createRecordEvent(), opts)
|
|
168
|
+
|
|
169
|
+
expect(receivedOpts).toBeDefined()
|
|
170
|
+
expect(receivedOpts?.signal).toBe(opts.signal)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('passes opts to identity handler', async () => {
|
|
174
|
+
const indexer = new SimpleIndexer()
|
|
175
|
+
let receivedOpts: HandlerOpts | undefined
|
|
176
|
+
|
|
177
|
+
indexer.identity(async (_evt, opts) => {
|
|
178
|
+
receivedOpts = opts
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
const opts = createMockOpts()
|
|
182
|
+
await indexer.onEvent(createIdentityEvent(), opts)
|
|
183
|
+
|
|
184
|
+
expect(receivedOpts).toBeDefined()
|
|
185
|
+
expect(receivedOpts?.signal).toBe(opts.signal)
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
})
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assureAdminAuth,
|
|
3
|
+
formatAdminAuthHeader,
|
|
4
|
+
parseAdminAuthHeader,
|
|
5
|
+
} from '../src'
|
|
6
|
+
|
|
7
|
+
describe('util', () => {
|
|
8
|
+
describe('formatAdminAuthHeader', () => {
|
|
9
|
+
it('formats password as Basic auth header', () => {
|
|
10
|
+
const header = formatAdminAuthHeader('secret')
|
|
11
|
+
expect(header).toBe('Basic YWRtaW46c2VjcmV0')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('uses admin as username', () => {
|
|
15
|
+
const header = formatAdminAuthHeader('secret')
|
|
16
|
+
const decoded = Buffer.from(
|
|
17
|
+
header.replace('Basic ', ''),
|
|
18
|
+
'base64',
|
|
19
|
+
).toString()
|
|
20
|
+
expect(decoded).toBe('admin:secret')
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('parseAdminAuthHeader', () => {
|
|
25
|
+
it('parses Basic auth header and returns password', () => {
|
|
26
|
+
const header = 'Basic YWRtaW46c2VjcmV0' // admin:secret
|
|
27
|
+
const password = parseAdminAuthHeader(header)
|
|
28
|
+
expect(password).toBe('secret')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('handles header without Basic prefix', () => {
|
|
32
|
+
const header = 'YWRtaW46c2VjcmV0' // admin:secret (no prefix)
|
|
33
|
+
const password = parseAdminAuthHeader(header)
|
|
34
|
+
expect(password).toBe('secret')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('throws if username is not admin', () => {
|
|
38
|
+
const header = 'Basic ' + Buffer.from('user:secret').toString('base64')
|
|
39
|
+
expect(() => parseAdminAuthHeader(header)).toThrow(
|
|
40
|
+
"Unexpected username in admin headers. Expected 'admin'",
|
|
41
|
+
)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('assureAdminAuth', () => {
|
|
46
|
+
it('does not throw when password matches', () => {
|
|
47
|
+
const header = formatAdminAuthHeader('secret')
|
|
48
|
+
expect(() => assureAdminAuth('secret', header)).not.toThrow()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('throws when password does not match', () => {
|
|
52
|
+
const header = formatAdminAuthHeader('wrong')
|
|
53
|
+
expect(() => assureAdminAuth('secret', header)).toThrow(
|
|
54
|
+
'Invalid admin password',
|
|
55
|
+
)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('throws when header has invalid username', () => {
|
|
59
|
+
const header =
|
|
60
|
+
'Basic ' + Buffer.from('notadmin:secret').toString('base64')
|
|
61
|
+
expect(() => assureAdminAuth('secret', header)).toThrow()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('is timing-safe (does not leak password length)', () => {
|
|
65
|
+
// This is a basic sanity check - true timing attack tests require statistical analysis
|
|
66
|
+
const header = formatAdminAuthHeader('a')
|
|
67
|
+
const start1 = performance.now()
|
|
68
|
+
try {
|
|
69
|
+
assureAdminAuth('b', header)
|
|
70
|
+
} catch {
|
|
71
|
+
// do nothing
|
|
72
|
+
}
|
|
73
|
+
const time1 = performance.now() - start1
|
|
74
|
+
|
|
75
|
+
const longHeader = formatAdminAuthHeader('a'.repeat(1000))
|
|
76
|
+
const start2 = performance.now()
|
|
77
|
+
try {
|
|
78
|
+
assureAdminAuth('b'.repeat(1000), longHeader)
|
|
79
|
+
} catch {
|
|
80
|
+
// do nothing
|
|
81
|
+
}
|
|
82
|
+
const time2 = performance.now() - start2
|
|
83
|
+
|
|
84
|
+
// Times should be in the same order of magnitude (not a rigorous test)
|
|
85
|
+
expect(Math.abs(time1 - time2)).toBeLessThan(50)
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"root":["./src/channel.ts","./src/client.ts","./src/index.ts","./src/simple-indexer.ts","./src/types.ts","./src/util.ts"],"version":"5.8.3"}
|
package/tsconfig.json
ADDED