@atproto/bsky 0.0.198 → 0.0.199

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.
Files changed (151) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/api/age-assurance/const.d.ts +11 -0
  3. package/dist/api/age-assurance/const.d.ts.map +1 -0
  4. package/dist/api/age-assurance/const.js +142 -0
  5. package/dist/api/age-assurance/const.js.map +1 -0
  6. package/dist/api/age-assurance/index.d.ts +4 -0
  7. package/dist/api/age-assurance/index.d.ts.map +1 -0
  8. package/dist/api/age-assurance/index.js +24 -0
  9. package/dist/api/age-assurance/index.js.map +1 -0
  10. package/dist/api/age-assurance/kws/age-verified.d.ts +109 -0
  11. package/dist/api/age-assurance/kws/age-verified.d.ts.map +1 -0
  12. package/dist/api/age-assurance/kws/age-verified.js +63 -0
  13. package/dist/api/age-assurance/kws/age-verified.js.map +1 -0
  14. package/dist/api/age-assurance/kws/const.d.ts +13 -0
  15. package/dist/api/age-assurance/kws/const.d.ts.map +1 -0
  16. package/dist/api/age-assurance/kws/const.js +36 -0
  17. package/dist/api/age-assurance/kws/const.js.map +1 -0
  18. package/dist/api/age-assurance/kws/external-payload.d.ts +75 -0
  19. package/dist/api/age-assurance/kws/external-payload.d.ts.map +1 -0
  20. package/dist/api/age-assurance/kws/external-payload.js +124 -0
  21. package/dist/api/age-assurance/kws/external-payload.js.map +1 -0
  22. package/dist/api/age-assurance/kws/external-payload.test.d.ts +2 -0
  23. package/dist/api/age-assurance/kws/external-payload.test.d.ts.map +1 -0
  24. package/dist/api/age-assurance/kws/external-payload.test.js +65 -0
  25. package/dist/api/age-assurance/kws/external-payload.test.js.map +1 -0
  26. package/dist/api/age-assurance/redirects/kws-age-verified.d.ts +4 -0
  27. package/dist/api/age-assurance/redirects/kws-age-verified.d.ts.map +1 -0
  28. package/dist/api/age-assurance/redirects/kws-age-verified.js +76 -0
  29. package/dist/api/age-assurance/redirects/kws-age-verified.js.map +1 -0
  30. package/dist/api/age-assurance/stash.d.ts +4 -0
  31. package/dist/api/age-assurance/stash.d.ts.map +1 -0
  32. package/dist/api/age-assurance/stash.js +19 -0
  33. package/dist/api/age-assurance/stash.js.map +1 -0
  34. package/dist/api/age-assurance/types.d.ts +10 -0
  35. package/dist/api/age-assurance/types.d.ts.map +1 -0
  36. package/dist/api/age-assurance/types.js +3 -0
  37. package/dist/api/age-assurance/types.js.map +1 -0
  38. package/dist/api/age-assurance/util.d.ts +15 -0
  39. package/dist/api/age-assurance/util.d.ts.map +1 -0
  40. package/dist/api/age-assurance/util.js +54 -0
  41. package/dist/api/age-assurance/util.js.map +1 -0
  42. package/dist/api/age-assurance/webhooks/kws-age-verified.d.ts +4 -0
  43. package/dist/api/age-assurance/webhooks/kws-age-verified.d.ts.map +1 -0
  44. package/dist/api/age-assurance/webhooks/kws-age-verified.js +63 -0
  45. package/dist/api/age-assurance/webhooks/kws-age-verified.js.map +1 -0
  46. package/dist/api/app/bsky/ageassurance/begin.d.ts +4 -0
  47. package/dist/api/app/bsky/ageassurance/begin.d.ts.map +1 -0
  48. package/dist/api/app/bsky/ageassurance/begin.js +131 -0
  49. package/dist/api/app/bsky/ageassurance/begin.js.map +1 -0
  50. package/dist/api/app/bsky/ageassurance/getConfig.d.ts +4 -0
  51. package/dist/api/app/bsky/ageassurance/getConfig.d.ts.map +1 -0
  52. package/dist/api/app/bsky/ageassurance/getConfig.js +16 -0
  53. package/dist/api/app/bsky/ageassurance/getConfig.js.map +1 -0
  54. package/dist/api/app/bsky/ageassurance/getState.d.ts +4 -0
  55. package/dist/api/app/bsky/ageassurance/getState.d.ts.map +1 -0
  56. package/dist/api/app/bsky/ageassurance/getState.js +42 -0
  57. package/dist/api/app/bsky/ageassurance/getState.js.map +1 -0
  58. package/dist/api/external.d.ts.map +1 -1
  59. package/dist/api/external.js +2 -0
  60. package/dist/api/external.js.map +1 -1
  61. package/dist/api/index.d.ts.map +1 -1
  62. package/dist/api/index.js +8 -2
  63. package/dist/api/index.js.map +1 -1
  64. package/dist/api/kws/api.d.ts.map +1 -1
  65. package/dist/api/kws/api.js +44 -26
  66. package/dist/api/kws/api.js.map +1 -1
  67. package/dist/api/kws/index.d.ts.map +1 -1
  68. package/dist/api/kws/index.js +3 -1
  69. package/dist/api/kws/index.js.map +1 -1
  70. package/dist/api/kws/webhook.d.ts +3 -1
  71. package/dist/api/kws/webhook.d.ts.map +1 -1
  72. package/dist/api/kws/webhook.js +48 -20
  73. package/dist/api/kws/webhook.js.map +1 -1
  74. package/dist/config.d.ts +14 -0
  75. package/dist/config.d.ts.map +1 -1
  76. package/dist/config.js +10 -2
  77. package/dist/config.js.map +1 -1
  78. package/dist/data-plane/bsync/index.d.ts.map +1 -1
  79. package/dist/data-plane/bsync/index.js +22 -0
  80. package/dist/data-plane/bsync/index.js.map +1 -1
  81. package/dist/data-plane/server/db/migrations/20251120T004738098Z-update-actor-age-assurance-v2.d.ts +4 -0
  82. package/dist/data-plane/server/db/migrations/20251120T004738098Z-update-actor-age-assurance-v2.d.ts.map +1 -0
  83. package/dist/data-plane/server/db/migrations/20251120T004738098Z-update-actor-age-assurance-v2.js +30 -0
  84. package/dist/data-plane/server/db/migrations/20251120T004738098Z-update-actor-age-assurance-v2.js.map +1 -0
  85. package/dist/data-plane/server/db/migrations/index.d.ts +1 -0
  86. package/dist/data-plane/server/db/migrations/index.d.ts.map +1 -1
  87. package/dist/data-plane/server/db/migrations/index.js +2 -1
  88. package/dist/data-plane/server/db/migrations/index.js.map +1 -1
  89. package/dist/data-plane/server/db/pagination.d.ts +3 -3
  90. package/dist/data-plane/server/db/tables/actor.d.ts +3 -0
  91. package/dist/data-plane/server/db/tables/actor.d.ts.map +1 -1
  92. package/dist/data-plane/server/db/tables/actor.js.map +1 -1
  93. package/dist/data-plane/server/routes/profile.d.ts.map +1 -1
  94. package/dist/data-plane/server/routes/profile.js +13 -1
  95. package/dist/data-plane/server/routes/profile.js.map +1 -1
  96. package/dist/hydration/hydrator.js +1 -1
  97. package/dist/hydration/hydrator.js.map +1 -1
  98. package/dist/kws.d.ts +35 -0
  99. package/dist/kws.d.ts.map +1 -1
  100. package/dist/kws.js +54 -0
  101. package/dist/kws.js.map +1 -1
  102. package/dist/logger.d.ts +1 -0
  103. package/dist/logger.d.ts.map +1 -1
  104. package/dist/logger.js +2 -1
  105. package/dist/logger.js.map +1 -1
  106. package/dist/proto/bsky_pb.d.ts +8 -0
  107. package/dist/proto/bsky_pb.d.ts.map +1 -1
  108. package/dist/proto/bsky_pb.js +20 -0
  109. package/dist/proto/bsky_pb.js.map +1 -1
  110. package/dist/stash.d.ts +1 -0
  111. package/dist/stash.d.ts.map +1 -1
  112. package/dist/stash.js +1 -0
  113. package/dist/stash.js.map +1 -1
  114. package/dist/util/uris.d.ts +2 -2
  115. package/dist/util/uris.d.ts.map +1 -1
  116. package/package.json +10 -9
  117. package/proto/bsky.proto +1 -0
  118. package/src/api/age-assurance/const.ts +142 -0
  119. package/src/api/age-assurance/index.ts +34 -0
  120. package/src/api/age-assurance/kws/age-verified.ts +75 -0
  121. package/src/api/age-assurance/kws/const.ts +33 -0
  122. package/src/api/age-assurance/kws/external-payload.test.ts +72 -0
  123. package/src/api/age-assurance/kws/external-payload.ts +149 -0
  124. package/src/api/age-assurance/redirects/kws-age-verified.ts +107 -0
  125. package/src/api/age-assurance/stash.ts +22 -0
  126. package/src/api/age-assurance/types.ts +10 -0
  127. package/src/api/age-assurance/util.ts +66 -0
  128. package/src/api/age-assurance/webhooks/kws-age-verified.ts +75 -0
  129. package/src/api/app/bsky/ageassurance/begin.ts +167 -0
  130. package/src/api/app/bsky/ageassurance/getConfig.ts +15 -0
  131. package/src/api/app/bsky/ageassurance/getState.ts +53 -0
  132. package/src/api/external.ts +2 -0
  133. package/src/api/index.ts +6 -0
  134. package/src/api/kws/api.ts +55 -34
  135. package/src/api/kws/index.ts +7 -1
  136. package/src/api/kws/webhook.ts +57 -34
  137. package/src/config.ts +26 -2
  138. package/src/data-plane/bsync/index.ts +31 -0
  139. package/src/data-plane/server/db/migrations/20251120T004738098Z-update-actor-age-assurance-v2.ts +28 -0
  140. package/src/data-plane/server/db/migrations/index.ts +1 -0
  141. package/src/data-plane/server/db/tables/actor.ts +3 -0
  142. package/src/data-plane/server/routes/profile.ts +12 -1
  143. package/src/hydration/hydrator.ts +1 -1
  144. package/src/kws.ts +81 -0
  145. package/src/logger.ts +2 -0
  146. package/src/proto/bsky_pb.ts +12 -0
  147. package/src/stash.ts +3 -0
  148. package/tests/views/age-assurance-v2.test.ts +745 -0
  149. package/tests/views/age-assurance.test.ts +2 -0
  150. package/tsconfig.build.tsbuildinfo +1 -1
  151. package/tsconfig.tests.tsbuildinfo +1 -1
@@ -0,0 +1,745 @@
1
+ import crypto from 'node:crypto'
2
+ import { once } from 'node:events'
3
+ import { Server, createServer } from 'node:http'
4
+ import { AddressInfo } from 'node:net'
5
+ import express, { Application } from 'express'
6
+ import {
7
+ AppBskyAgeassuranceDefs,
8
+ AtpAgent,
9
+ ageAssuranceRuleIDs as ruleIds,
10
+ } from '@atproto/api'
11
+ import { SeedClient, TestNetwork, basicSeed } from '@atproto/dev-env'
12
+ import {
13
+ type KWSWebhookAgeVerified,
14
+ serializeKWSAgeVerifiedStatus,
15
+ } from '../../src/api/age-assurance/kws/age-verified'
16
+ import {
17
+ KWSExternalPayloadVersion,
18
+ serializeKWSExternalPayloadV1,
19
+ serializeKWSExternalPayloadV2,
20
+ } from '../../src/api/age-assurance/kws/external-payload'
21
+ import { KwsWebhookBody } from '../../src/api/kws/types'
22
+ import { ids } from '../../src/lexicon/lexicons'
23
+ import * as AppBskyAgeassuranceBegin from '../../src/lexicon/types/app/bsky/ageassurance/begin'
24
+ import * as AppBskyAgeassuranceGetState from '../../src/lexicon/types/app/bsky/ageassurance/getState'
25
+
26
+ type Database = TestNetwork['bsky']['db']
27
+
28
+ const BSKY_REDIRECT_URL = 'http://bsky'
29
+
30
+ jest.mock('../../dist/api/age-assurance/const.js', () => {
31
+ const AGE_ASSURANCE_CONFIG: AppBskyAgeassuranceDefs.Config = {
32
+ regions: [
33
+ {
34
+ countryCode: 'AA',
35
+ regionCode: undefined,
36
+ rules: [
37
+ {
38
+ $type: ruleIds.IfAssuredOverAge,
39
+ age: 18,
40
+ access: 'full',
41
+ },
42
+ {
43
+ $type: ruleIds.Default,
44
+ access: 'safe',
45
+ },
46
+ ],
47
+ },
48
+ {
49
+ countryCode: 'BB',
50
+ regionCode: undefined,
51
+ rules: [
52
+ {
53
+ $type: ruleIds.IfAssuredOverAge,
54
+ age: 18,
55
+ access: 'full',
56
+ },
57
+ {
58
+ $type: ruleIds.Default,
59
+ access: 'safe',
60
+ },
61
+ ],
62
+ },
63
+ ],
64
+ }
65
+ return {
66
+ AGE_ASSURANCE_CONFIG,
67
+ }
68
+ })
69
+
70
+ jest.mock('../../dist/api/age-assurance/kws/const.js', () => {
71
+ const actual = jest.requireActual('../../dist/api/age-assurance/kws/const.js')
72
+ const KWS_V2_COUNTRIES = new Set(['AA'])
73
+ return {
74
+ ...actual,
75
+ KWS_V2_COUNTRIES,
76
+ }
77
+ })
78
+
79
+ describe('age assurance v2 views', () => {
80
+ let network: TestNetwork
81
+ let db: Database
82
+ let agent: AtpAgent
83
+ let sc: SeedClient
84
+ let kws: MockKwsServer
85
+
86
+ const kwsOauthMock = jest.fn()
87
+ const kwsSendAgeVerifiedFlowEmailMock = jest.fn()
88
+ const kwsSendAdultVerifiedFlowEmailMock = jest.fn()
89
+ const actor = {
90
+ did: '',
91
+ email: '',
92
+ }
93
+
94
+ beforeAll(async () => {
95
+ kws = new MockKwsServer({
96
+ oauthMock: kwsOauthMock,
97
+ sendAgeVerifiedFlowEmailMock: kwsSendAgeVerifiedFlowEmailMock,
98
+ sendAdultVerifiedFlowEmailMock: kwsSendAdultVerifiedFlowEmailMock,
99
+ })
100
+ await kws.listen()
101
+
102
+ network = await TestNetwork.create({
103
+ dbPostgresSchema: 'bsky_views_age_assurance_v_two',
104
+ bsky: {
105
+ statsigEnv: 'test',
106
+ statsigKey: 'secret-key',
107
+ kws: {
108
+ apiKey: 'apiKey',
109
+ apiOrigin: kws.url,
110
+ authOrigin: kws.url,
111
+ clientId: 'clientId',
112
+ redirectUrl: BSKY_REDIRECT_URL,
113
+ userAgent: 'userAgent',
114
+ verificationSecret: kws.verificationSecret,
115
+ webhookSecret: kws.webhookSecret,
116
+ ageVerifiedWebhookSecret: kws.ageVerifiedWebhookSecret,
117
+ ageVerifiedRedirectSecret: kws.ageVerifiedRedirectSecret,
118
+ },
119
+ },
120
+ })
121
+
122
+ kws.setBskyBaseUrl(network.bsky.url)
123
+
124
+ db = network.bsky.db
125
+ agent = network.bsky.getClient()
126
+ sc = network.getSeedClient()
127
+
128
+ await basicSeed(sc)
129
+ await network.processAll()
130
+
131
+ actor.did = sc.dids.alice
132
+ actor.email = sc.accounts[actor.did].email
133
+ })
134
+
135
+ beforeEach(async () => {
136
+ // Default mocks for KWS endpoints.
137
+ kwsOauthMock.mockImplementation(
138
+ (_req: express.Request, res: express.Response) =>
139
+ res.json({
140
+ access_token:
141
+ 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.INVALID',
142
+ expires_in: 3600,
143
+ }),
144
+ )
145
+ kwsSendAgeVerifiedFlowEmailMock.mockImplementation(
146
+ (_req: express.Request, res: express.Response) => {
147
+ res.json({})
148
+ },
149
+ )
150
+ kwsSendAdultVerifiedFlowEmailMock.mockImplementation(
151
+ (_req: express.Request, res: express.Response) => {
152
+ res.json({})
153
+ },
154
+ )
155
+ })
156
+
157
+ afterEach(async () => {
158
+ jest.resetAllMocks()
159
+ await clearPrivateData(db)
160
+ await clearActorAgeAssurance(db)
161
+ })
162
+
163
+ afterAll(async () => {
164
+ await network.close()
165
+ await kws.stop()
166
+ })
167
+
168
+ const getState = async (params: AppBskyAgeassuranceGetState.QueryParams) => {
169
+ const { data } = await agent.app.bsky.ageassurance.getState(params, {
170
+ headers: await network.serviceHeaders(
171
+ actor.did,
172
+ ids.AppBskyAgeassuranceGetState,
173
+ ),
174
+ })
175
+ return data
176
+ }
177
+
178
+ const beginAgeAssurance = async (
179
+ params: Omit<AppBskyAgeassuranceBegin.InputSchema, 'email' | 'language'> & {
180
+ email?: string
181
+ },
182
+ ) => {
183
+ const { data } = await agent.app.bsky.ageassurance.begin(
184
+ {
185
+ ...params,
186
+ email: params.email || sc.accounts[actor.did].email,
187
+ language: 'en',
188
+ },
189
+ {
190
+ headers: await network.serviceHeaders(
191
+ actor.did,
192
+ ids.AppBskyAgeassuranceBegin,
193
+ ),
194
+ },
195
+ )
196
+ return data
197
+ }
198
+
199
+ describe('app.bsky.ageassurance.getState', () => {
200
+ it('initially returns defaults', async () => {
201
+ const { state, metadata } = await getState({
202
+ countryCode: 'US',
203
+ regionCode: undefined,
204
+ })
205
+ expect(metadata.accountCreatedAt).toBeDefined()
206
+ expect(state).toEqual({
207
+ lastInitatedAt: undefined,
208
+ status: 'unknown',
209
+ access: 'unknown',
210
+ })
211
+ })
212
+ })
213
+
214
+ describe('app.bsky.ageassurance.begin', () => {
215
+ it('fails if region not supported', async () => {
216
+ const call = beginAgeAssurance({
217
+ countryCode: 'XX',
218
+ })
219
+ await expect(call).rejects.toHaveProperty('error', 'RegionNotSupported')
220
+ })
221
+
222
+ it('fails if email is invalid', async () => {
223
+ const call = beginAgeAssurance({
224
+ email: 'invalid-email',
225
+ countryCode: 'XX',
226
+ })
227
+ await expect(call).rejects.toHaveProperty('error', 'InvalidEmail')
228
+ })
229
+
230
+ it('succeeds for V2 country', async () => {
231
+ const res = await beginAgeAssurance({
232
+ countryCode: 'AA',
233
+ })
234
+ await network.processAll()
235
+ const { state } = await getState({
236
+ countryCode: 'AA',
237
+ })
238
+ expect(kwsSendAgeVerifiedFlowEmailMock).toHaveBeenCalledTimes(1)
239
+ expect(res).toEqual(state)
240
+ expect(state.lastInitiatedAt).toBeDefined()
241
+ expect(state.status).toEqual('pending')
242
+ expect(state.access).toEqual('unknown')
243
+ })
244
+
245
+ it('succeeds for V1 country', async () => {
246
+ const res = await beginAgeAssurance({
247
+ countryCode: 'BB',
248
+ })
249
+ await network.processAll()
250
+ const { state } = await getState({
251
+ countryCode: 'BB',
252
+ })
253
+ expect(kwsSendAdultVerifiedFlowEmailMock).toHaveBeenCalledTimes(1)
254
+ expect(res).toEqual(state)
255
+ expect(state.lastInitiatedAt).toBeDefined()
256
+ expect(state.status).toEqual('pending')
257
+ expect(state.access).toEqual('unknown')
258
+ })
259
+ })
260
+
261
+ describe('external handlers', () => {
262
+ describe('V2 redirects', () => {
263
+ it('redirects with result=unknown if we fail to parse the status object', async () => {
264
+ const res = await kws.redirectV2({
265
+ externalPayload: serializeKWSExternalPayloadV2({
266
+ version: KWSExternalPayloadVersion.V2,
267
+ actorDid: actor.did,
268
+ attemptId: crypto.randomUUID(),
269
+ countryCode: 'AA',
270
+ }),
271
+ status: JSON.stringify({
272
+ verified: true,
273
+ verifiedMinimumAge: '18', // will fail parsing
274
+ }),
275
+ })
276
+ expect(res.status).toBe(302)
277
+ expect(res.headers.get('Location')).toBe(
278
+ `${BSKY_REDIRECT_URL}?result=unknown`,
279
+ )
280
+ })
281
+
282
+ it('redirects with result=unknown if status is not verified', async () => {
283
+ const res = await kws.redirectV2({
284
+ externalPayload: serializeKWSExternalPayloadV2({
285
+ version: KWSExternalPayloadVersion.V2,
286
+ actorDid: actor.did,
287
+ attemptId: crypto.randomUUID(),
288
+ countryCode: 'AA',
289
+ }),
290
+ status: serializeKWSAgeVerifiedStatus({
291
+ verified: false,
292
+ verifiedMinimumAge: 18,
293
+ }),
294
+ })
295
+ expect(res.status).toBe(302)
296
+ expect(res.headers.get('Location')).toBe(
297
+ `${BSKY_REDIRECT_URL}?actorDid=${encodeURIComponent(actor.did)}&result=unknown`,
298
+ )
299
+ })
300
+
301
+ // this also covers any other thrown errors
302
+ it('redirects with result=unknown if access check throws', async () => {
303
+ const res = await kws.redirectV2({
304
+ externalPayload: serializeKWSExternalPayloadV2({
305
+ version: KWSExternalPayloadVersion.V2,
306
+ actorDid: actor.did,
307
+ attemptId: crypto.randomUUID(),
308
+ countryCode: 'XX', // should never reach KWS anyway
309
+ }),
310
+ status: serializeKWSAgeVerifiedStatus({
311
+ verified: true,
312
+ verifiedMinimumAge: 18,
313
+ }),
314
+ })
315
+ expect(res.status).toBe(302)
316
+ expect(res.headers.get('Location')).toBe(
317
+ `${BSKY_REDIRECT_URL}?actorDid=${encodeURIComponent(actor.did)}&result=unknown`,
318
+ )
319
+ })
320
+
321
+ it('success', async () => {
322
+ await beginAgeAssurance({
323
+ countryCode: 'AA',
324
+ })
325
+ await network.processAll()
326
+ await kws.redirectV2({
327
+ externalPayload: serializeKWSExternalPayloadV2({
328
+ version: KWSExternalPayloadVersion.V2,
329
+ actorDid: actor.did,
330
+ attemptId: crypto.randomUUID(),
331
+ countryCode: 'AA',
332
+ }),
333
+ status: serializeKWSAgeVerifiedStatus({
334
+ verified: true,
335
+ verifiedMinimumAge: 18,
336
+ }),
337
+ })
338
+ await network.processAll()
339
+ const { state } = await getState({
340
+ countryCode: 'AA',
341
+ })
342
+ expect(state.lastInitiatedAt).toBeDefined()
343
+ expect(state.status).toEqual('assured')
344
+ expect(state.access).toEqual('full')
345
+ })
346
+ })
347
+
348
+ describe('V2 webhooks', () => {
349
+ it('returns 400 if we fail to parse the external payload', async () => {
350
+ const res = await kws.webhookV2({
351
+ name: 'age-verified',
352
+ time: new Date().toISOString(),
353
+ orgId: crypto.randomUUID(),
354
+ productId: crypto.randomUUID(),
355
+ payload: {
356
+ email: actor.email,
357
+ externalPayload: serializeKWSExternalPayloadV2({
358
+ version: KWSExternalPayloadVersion.V2,
359
+ actorDid: actor.did,
360
+ attemptId: crypto.randomUUID(),
361
+ countryCode: 'AA',
362
+ }),
363
+ status: {
364
+ verified: true,
365
+ // @ts-ignore testing invalid payload
366
+ verifiedMinimumAge: '18',
367
+ },
368
+ },
369
+ })
370
+ expect(res.status).toBe(400)
371
+ await expect(res.json()).resolves.toHaveProperty(
372
+ 'error',
373
+ 'Failed to parse KWS webhook body',
374
+ )
375
+ })
376
+
377
+ it('returns 400 if status is not verified', async () => {
378
+ const res = await kws.webhookV2({
379
+ name: 'age-verified',
380
+ time: new Date().toISOString(),
381
+ orgId: crypto.randomUUID(),
382
+ productId: crypto.randomUUID(),
383
+ payload: {
384
+ email: actor.email,
385
+ externalPayload: serializeKWSExternalPayloadV2({
386
+ version: KWSExternalPayloadVersion.V2,
387
+ actorDid: actor.did,
388
+ attemptId: crypto.randomUUID(),
389
+ countryCode: 'AA',
390
+ }),
391
+ status: {
392
+ verified: false,
393
+ verifiedMinimumAge: 18,
394
+ },
395
+ },
396
+ })
397
+ expect(res.status).toBe(400)
398
+ await expect(res.json()).resolves.toHaveProperty(
399
+ 'error',
400
+ 'Expected KWS webhook to have verified status',
401
+ )
402
+ })
403
+
404
+ it('returns 200, but AA state unchanged due to invalid region', async () => {
405
+ const res = await kws.webhookV2({
406
+ name: 'age-verified',
407
+ time: new Date().toISOString(),
408
+ orgId: crypto.randomUUID(),
409
+ productId: crypto.randomUUID(),
410
+ payload: {
411
+ email: actor.email,
412
+ externalPayload: serializeKWSExternalPayloadV2({
413
+ version: KWSExternalPayloadVersion.V2,
414
+ actorDid: actor.did,
415
+ attemptId: crypto.randomUUID(),
416
+ countryCode: 'XX',
417
+ }),
418
+ status: {
419
+ verified: true,
420
+ verifiedMinimumAge: 18,
421
+ },
422
+ },
423
+ })
424
+ await network.processAll()
425
+ expect(res.status).toBe(200)
426
+ const { state } = await getState({
427
+ countryCode: 'XX',
428
+ })
429
+ expect(state.status).toEqual('unknown') // we never began, so it's still unknown
430
+ })
431
+
432
+ it('success', async () => {
433
+ await beginAgeAssurance({
434
+ countryCode: 'AA',
435
+ })
436
+ await network.processAll()
437
+ await kws.webhookV2({
438
+ name: 'age-verified',
439
+ time: new Date().toISOString(),
440
+ orgId: crypto.randomUUID(),
441
+ productId: crypto.randomUUID(),
442
+ payload: {
443
+ email: actor.email,
444
+ externalPayload: serializeKWSExternalPayloadV2({
445
+ version: KWSExternalPayloadVersion.V2,
446
+ actorDid: actor.did,
447
+ attemptId: crypto.randomUUID(),
448
+ countryCode: 'AA',
449
+ }),
450
+ status: {
451
+ verified: true,
452
+ verifiedMinimumAge: 18,
453
+ },
454
+ },
455
+ })
456
+ await network.processAll()
457
+ const { state } = await getState({
458
+ countryCode: 'AA',
459
+ })
460
+ expect(state.lastInitiatedAt).toBeDefined()
461
+ expect(state.status).toEqual('assured')
462
+ expect(state.access).toEqual('full')
463
+ })
464
+ })
465
+
466
+ describe('V1 compat', () => {
467
+ it('works via webhook', async () => {
468
+ await beginAgeAssurance({
469
+ countryCode: 'BB',
470
+ })
471
+ await network.processAll()
472
+ await kws.webhookV1({
473
+ payload: {
474
+ externalPayload: serializeKWSExternalPayloadV2({
475
+ version: KWSExternalPayloadVersion.V2,
476
+ actorDid: actor.did,
477
+ attemptId: crypto.randomUUID(),
478
+ countryCode: 'BB',
479
+ }),
480
+ status: {
481
+ verified: true,
482
+ },
483
+ },
484
+ })
485
+ await network.processAll()
486
+ const { state } = await getState({
487
+ countryCode: 'BB',
488
+ })
489
+ expect(state.lastInitiatedAt).toBeDefined()
490
+ expect(state.status).toEqual('assured')
491
+ expect(state.access).toEqual('full')
492
+ })
493
+
494
+ it('works via redirect', async () => {
495
+ await beginAgeAssurance({
496
+ countryCode: 'BB',
497
+ })
498
+ await network.processAll()
499
+ await kws.redirectV1({
500
+ externalPayload: serializeKWSExternalPayloadV2({
501
+ version: KWSExternalPayloadVersion.V2,
502
+ actorDid: actor.did,
503
+ attemptId: crypto.randomUUID(),
504
+ countryCode: 'BB',
505
+ }),
506
+ status: JSON.stringify({
507
+ verified: true,
508
+ }),
509
+ })
510
+ await network.processAll()
511
+ const { state } = await getState({
512
+ countryCode: 'BB',
513
+ })
514
+ expect(state.lastInitiatedAt).toBeDefined()
515
+ expect(state.status).toEqual('assured')
516
+ expect(state.access).toEqual('full')
517
+ })
518
+ })
519
+ })
520
+
521
+ describe('misc', () => {
522
+ it('cannot re-init from terminal state', async () => {
523
+ await kws.redirectV2({
524
+ externalPayload: serializeKWSExternalPayloadV2({
525
+ version: KWSExternalPayloadVersion.V2,
526
+ actorDid: actor.did,
527
+ attemptId: crypto.randomUUID(),
528
+ countryCode: 'AA',
529
+ }),
530
+ status: serializeKWSAgeVerifiedStatus({
531
+ verified: true,
532
+ verifiedMinimumAge: 18,
533
+ }),
534
+ })
535
+ await network.processAll()
536
+ const call = beginAgeAssurance({
537
+ countryCode: 'AA',
538
+ })
539
+ await expect(call).rejects.toHaveProperty('error', 'InvalidInitiation')
540
+ })
541
+
542
+ /*
543
+ * This tests local dataplane behavior, but the actual prod implementation
544
+ * lives in the dataplane repo, obviously.
545
+ */
546
+ it('dataplane converts v1 to v2 state at read time', async () => {
547
+ await beginAgeAssurance({
548
+ countryCode: 'BB',
549
+ })
550
+ await network.processAll()
551
+ await kws.webhookV1({
552
+ payload: {
553
+ externalPayload: serializeKWSExternalPayloadV1({
554
+ actorDid: actor.did,
555
+ attemptId: crypto.randomUUID(),
556
+ }),
557
+ status: {
558
+ verified: true,
559
+ },
560
+ },
561
+ })
562
+ await network.processAll()
563
+ const { state } = await getState({
564
+ countryCode: 'BB',
565
+ })
566
+ expect(state.lastInitiatedAt).toBeDefined()
567
+ expect(state.status).toEqual('assured')
568
+ expect(state.access).toEqual('full')
569
+ })
570
+ })
571
+ })
572
+
573
+ const clearPrivateData = async (db: Database) => {
574
+ await db.db.deleteFrom('private_data').execute()
575
+ }
576
+
577
+ const clearActorAgeAssurance = async (db: Database) => {
578
+ await db.db
579
+ .updateTable('actor')
580
+ .set({
581
+ ageAssuranceStatus: null,
582
+ ageAssuranceLastInitiatedAt: null,
583
+ ageAssuranceAccess: null,
584
+ ageAssuranceCountryCode: null,
585
+ ageAssuranceRegionCode: null,
586
+ })
587
+ .execute()
588
+ }
589
+
590
+ class MockKwsServer {
591
+ verificationSecret = 'verificationSecret' // unused here
592
+ webhookSecret = 'webhookSecret' // unused here
593
+ ageVerifiedWebhookSecret = 'ageVerifiedWebhookSecret'
594
+ ageVerifiedRedirectSecret = 'ageVerifiedRedirectSecret'
595
+
596
+ private app: Application
597
+ private server: Server
598
+ private bskyUrlBase = ''
599
+
600
+ constructor({
601
+ oauthMock,
602
+ sendAgeVerifiedFlowEmailMock,
603
+ sendAdultVerifiedFlowEmailMock,
604
+ }: {
605
+ oauthMock: jest.Mock
606
+ sendAgeVerifiedFlowEmailMock: jest.Mock
607
+ sendAdultVerifiedFlowEmailMock: jest.Mock
608
+ }) {
609
+ this.app = express()
610
+ .use(express.json())
611
+ .post('/auth/realms/kws/protocol/openid-connect/token', (_, res) =>
612
+ oauthMock(_, res),
613
+ )
614
+ .post('/v1/verifications/send-email', (req, res) => {
615
+ const body = req.body
616
+ if (body.userContext === 'age') {
617
+ return sendAgeVerifiedFlowEmailMock(req, res)
618
+ } else if (body.userContext === 'adult') {
619
+ return sendAdultVerifiedFlowEmailMock(req, res)
620
+ }
621
+ })
622
+
623
+ this.server = createServer(this.app)
624
+ }
625
+
626
+ async listen(port?: number) {
627
+ this.server.listen(port)
628
+ await once(this.server, 'listening')
629
+ }
630
+
631
+ async stop() {
632
+ this.server.close()
633
+ await once(this.server, 'close')
634
+ }
635
+
636
+ setBskyBaseUrl(url: string) {
637
+ this.bskyUrlBase = url
638
+ }
639
+
640
+ redirectV1({
641
+ externalPayload,
642
+ status,
643
+ }: {
644
+ externalPayload: string
645
+ status: string
646
+ }) {
647
+ const sig = crypto
648
+ .createHmac('sha256', this.verificationSecret)
649
+ .update(`${status}:${externalPayload}`)
650
+ .digest('hex')
651
+
652
+ const queryString = new URLSearchParams({
653
+ externalPayload,
654
+ signature: sig,
655
+ status,
656
+ }).toString()
657
+
658
+ return fetch(
659
+ `${this.bskyUrlBase}/external/kws/age-assurance-verification?${queryString}`,
660
+ {
661
+ method: 'GET',
662
+ redirect: 'manual',
663
+ },
664
+ )
665
+ }
666
+
667
+ redirectV2({
668
+ externalPayload,
669
+ status,
670
+ }: {
671
+ externalPayload: string
672
+ status: string
673
+ }) {
674
+ const sig = crypto
675
+ .createHmac('sha256', this.ageVerifiedRedirectSecret)
676
+ .update(`${status}:${externalPayload}`)
677
+ .digest('hex')
678
+
679
+ const queryString = new URLSearchParams({
680
+ externalPayload,
681
+ signature: sig,
682
+ status,
683
+ }).toString()
684
+
685
+ return fetch(
686
+ `${this.bskyUrlBase}/external/age-assurance/redirects/kws-age-verified?${queryString}`,
687
+ {
688
+ method: 'GET',
689
+ redirect: 'manual',
690
+ },
691
+ )
692
+ }
693
+
694
+ webhookV1(
695
+ body: Omit<KwsWebhookBody, 'payload'> & {
696
+ payload: Omit<KwsWebhookBody['payload'], 'externalPayload'> & {
697
+ externalPayload: string
698
+ }
699
+ },
700
+ ): Promise<Response> {
701
+ const bodyBuffer = Buffer.from(JSON.stringify(body))
702
+
703
+ const timestamp = new Date().valueOf()
704
+ const sig = crypto
705
+ .createHmac('sha256', this.webhookSecret)
706
+ .update(`${timestamp}.${bodyBuffer}`)
707
+ .digest('hex')
708
+
709
+ return fetch(`${this.bskyUrlBase}/external/kws/age-assurance-webhook`, {
710
+ method: 'POST',
711
+ body: bodyBuffer,
712
+ headers: {
713
+ 'x-kws-signature': `t=${timestamp},v1=${sig}`,
714
+ 'Content-Type': 'application/json',
715
+ },
716
+ })
717
+ }
718
+
719
+ webhookV2(body: KWSWebhookAgeVerified): Promise<Response> {
720
+ const bodyBuffer = Buffer.from(JSON.stringify(body))
721
+
722
+ const timestamp = new Date().valueOf()
723
+ const sig = crypto
724
+ .createHmac('sha256', this.ageVerifiedWebhookSecret)
725
+ .update(`${timestamp}.${bodyBuffer}`)
726
+ .digest('hex')
727
+
728
+ return fetch(
729
+ `${this.bskyUrlBase}/external/age-assurance/webhooks/kws-age-verified`,
730
+ {
731
+ method: 'POST',
732
+ body: bodyBuffer,
733
+ headers: {
734
+ 'x-kws-signature': `t=${timestamp},v1=${sig}`,
735
+ 'Content-Type': 'application/json',
736
+ },
737
+ },
738
+ )
739
+ }
740
+
741
+ get url() {
742
+ const address = this.server.address() as AddressInfo
743
+ return `http://localhost:${address.port}`
744
+ }
745
+ }