@atproto/bsky 0.0.170 → 0.0.172

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 (162) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/api/app/bsky/notification/registerPush.d.ts.map +1 -1
  3. package/dist/api/app/bsky/notification/registerPush.js +6 -7
  4. package/dist/api/app/bsky/notification/registerPush.js.map +1 -1
  5. package/dist/api/app/bsky/notification/unregisterPush.d.ts +4 -0
  6. package/dist/api/app/bsky/notification/unregisterPush.d.ts.map +1 -0
  7. package/dist/api/app/bsky/notification/unregisterPush.js +33 -0
  8. package/dist/api/app/bsky/notification/unregisterPush.js.map +1 -0
  9. package/dist/api/app/bsky/notification/util.d.ts +4 -0
  10. package/dist/api/app/bsky/notification/util.d.ts.map +1 -1
  11. package/dist/api/app/bsky/notification/util.js +14 -1
  12. package/dist/api/app/bsky/notification/util.js.map +1 -1
  13. package/dist/api/app/bsky/unspecced/getAgeAssuranceState.d.ts +4 -0
  14. package/dist/api/app/bsky/unspecced/getAgeAssuranceState.d.ts.map +1 -0
  15. package/dist/api/app/bsky/unspecced/getAgeAssuranceState.js +36 -0
  16. package/dist/api/app/bsky/unspecced/getAgeAssuranceState.js.map +1 -0
  17. package/dist/api/app/bsky/unspecced/initAgeAssurance.d.ts +4 -0
  18. package/dist/api/app/bsky/unspecced/initAgeAssurance.d.ts.map +1 -0
  19. package/dist/api/app/bsky/unspecced/initAgeAssurance.js +59 -0
  20. package/dist/api/app/bsky/unspecced/initAgeAssurance.js.map +1 -0
  21. package/dist/api/external.d.ts +4 -0
  22. package/dist/api/external.d.ts.map +1 -0
  23. package/dist/api/external.js +47 -0
  24. package/dist/api/external.js.map +1 -0
  25. package/dist/api/index.d.ts +1 -0
  26. package/dist/api/index.d.ts.map +1 -1
  27. package/dist/api/index.js +6 -1
  28. package/dist/api/index.js.map +1 -1
  29. package/dist/api/kws/api.d.ts +4 -0
  30. package/dist/api/kws/api.d.ts.map +1 -0
  31. package/dist/api/kws/api.js +60 -0
  32. package/dist/api/kws/api.js.map +1 -0
  33. package/dist/api/kws/index.d.ts +4 -0
  34. package/dist/api/kws/index.d.ts.map +1 -0
  35. package/dist/api/kws/index.js +21 -0
  36. package/dist/api/kws/index.js.map +1 -0
  37. package/dist/api/kws/types.d.ts +100 -0
  38. package/dist/api/kws/types.d.ts.map +1 -0
  39. package/dist/api/kws/types.js +29 -0
  40. package/dist/api/kws/types.js.map +1 -0
  41. package/dist/api/kws/util.d.ts +21 -0
  42. package/dist/api/kws/util.d.ts.map +1 -0
  43. package/dist/api/kws/util.js +78 -0
  44. package/dist/api/kws/util.js.map +1 -0
  45. package/dist/api/kws/webhook.d.ts +5 -0
  46. package/dist/api/kws/webhook.d.ts.map +1 -0
  47. package/dist/api/kws/webhook.js +80 -0
  48. package/dist/api/kws/webhook.js.map +1 -0
  49. package/dist/config.d.ts +12 -0
  50. package/dist/config.d.ts.map +1 -1
  51. package/dist/config.js +40 -0
  52. package/dist/config.js.map +1 -1
  53. package/dist/context.d.ts +3 -0
  54. package/dist/context.d.ts.map +1 -1
  55. package/dist/context.js +3 -0
  56. package/dist/context.js.map +1 -1
  57. package/dist/data-plane/bsync/index.d.ts.map +1 -1
  58. package/dist/data-plane/bsync/index.js +52 -33
  59. package/dist/data-plane/bsync/index.js.map +1 -1
  60. package/dist/data-plane/server/db/migrations/20250627T025331240Z-add-actor-age-assurance-columns.d.ts +4 -0
  61. package/dist/data-plane/server/db/migrations/20250627T025331240Z-add-actor-age-assurance-columns.d.ts.map +1 -0
  62. package/dist/data-plane/server/db/migrations/20250627T025331240Z-add-actor-age-assurance-columns.js +22 -0
  63. package/dist/data-plane/server/db/migrations/20250627T025331240Z-add-actor-age-assurance-columns.js.map +1 -0
  64. package/dist/data-plane/server/db/migrations/index.d.ts +1 -0
  65. package/dist/data-plane/server/db/migrations/index.d.ts.map +1 -1
  66. package/dist/data-plane/server/db/migrations/index.js +2 -0
  67. package/dist/data-plane/server/db/migrations/index.js.map +1 -1
  68. package/dist/data-plane/server/db/tables/actor.d.ts +2 -0
  69. package/dist/data-plane/server/db/tables/actor.d.ts.map +1 -1
  70. package/dist/data-plane/server/db/tables/actor.js.map +1 -1
  71. package/dist/data-plane/server/routes/profile.d.ts.map +1 -1
  72. package/dist/data-plane/server/routes/profile.js +14 -1
  73. package/dist/data-plane/server/routes/profile.js.map +1 -1
  74. package/dist/feature-gates.d.ts +2 -1
  75. package/dist/feature-gates.d.ts.map +1 -1
  76. package/dist/feature-gates.js +1 -0
  77. package/dist/feature-gates.js.map +1 -1
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.js +5 -0
  80. package/dist/index.js.map +1 -1
  81. package/dist/kws.d.ts +16 -0
  82. package/dist/kws.d.ts.map +1 -0
  83. package/dist/kws.js +86 -0
  84. package/dist/kws.js.map +1 -0
  85. package/dist/lexicon/index.d.ts +8 -2
  86. package/dist/lexicon/index.d.ts.map +1 -1
  87. package/dist/lexicon/index.js +16 -4
  88. package/dist/lexicon/index.js.map +1 -1
  89. package/dist/lexicon/lexicons.d.ts +374 -82
  90. package/dist/lexicon/lexicons.d.ts.map +1 -1
  91. package/dist/lexicon/lexicons.js +191 -42
  92. package/dist/lexicon/lexicons.js.map +1 -1
  93. package/dist/lexicon/types/app/bsky/notification/unregisterPush.d.ts +17 -0
  94. package/dist/lexicon/types/app/bsky/notification/unregisterPush.d.ts.map +1 -0
  95. package/dist/lexicon/types/app/bsky/notification/unregisterPush.js +7 -0
  96. package/dist/lexicon/types/app/bsky/notification/unregisterPush.js.map +1 -0
  97. package/dist/lexicon/types/app/bsky/unspecced/defs.d.ts +32 -0
  98. package/dist/lexicon/types/app/bsky/unspecced/defs.d.ts.map +1 -1
  99. package/dist/lexicon/types/app/bsky/unspecced/defs.js +18 -0
  100. package/dist/lexicon/types/app/bsky/unspecced/defs.js.map +1 -1
  101. package/dist/lexicon/types/app/bsky/unspecced/getAgeAssuranceState.d.ts +18 -0
  102. package/dist/lexicon/types/app/bsky/unspecced/getAgeAssuranceState.d.ts.map +1 -0
  103. package/dist/lexicon/types/app/bsky/unspecced/getAgeAssuranceState.js +7 -0
  104. package/dist/lexicon/types/app/bsky/unspecced/getAgeAssuranceState.js.map +1 -0
  105. package/dist/lexicon/types/app/bsky/unspecced/initAgeAssurance.d.ts +28 -0
  106. package/dist/lexicon/types/app/bsky/unspecced/initAgeAssurance.d.ts.map +1 -0
  107. package/dist/lexicon/types/app/bsky/unspecced/initAgeAssurance.js +7 -0
  108. package/dist/lexicon/types/app/bsky/unspecced/initAgeAssurance.js.map +1 -0
  109. package/dist/proto/bsky_pb.d.ts +33 -0
  110. package/dist/proto/bsky_pb.d.ts.map +1 -1
  111. package/dist/proto/bsky_pb.js +112 -4
  112. package/dist/proto/bsky_pb.js.map +1 -1
  113. package/dist/proto/courier_connect.d.ts +19 -1
  114. package/dist/proto/courier_connect.d.ts.map +1 -1
  115. package/dist/proto/courier_connect.js +18 -0
  116. package/dist/proto/courier_connect.js.map +1 -1
  117. package/dist/proto/courier_pb.d.ts +76 -0
  118. package/dist/proto/courier_pb.d.ts.map +1 -1
  119. package/dist/proto/courier_pb.js +233 -1
  120. package/dist/proto/courier_pb.js.map +1 -1
  121. package/dist/stash.d.ts +1 -0
  122. package/dist/stash.d.ts.map +1 -1
  123. package/dist/stash.js +1 -0
  124. package/dist/stash.js.map +1 -1
  125. package/package.json +7 -4
  126. package/proto/bsky.proto +8 -0
  127. package/proto/courier.proto +18 -0
  128. package/src/api/app/bsky/notification/registerPush.ts +5 -8
  129. package/src/api/app/bsky/notification/unregisterPush.ts +38 -0
  130. package/src/api/app/bsky/notification/util.ts +18 -0
  131. package/src/api/app/bsky/unspecced/getAgeAssuranceState.ts +46 -0
  132. package/src/api/app/bsky/unspecced/initAgeAssurance.ts +71 -0
  133. package/src/api/external.ts +13 -0
  134. package/src/api/index.ts +6 -0
  135. package/src/api/kws/api.ts +92 -0
  136. package/src/api/kws/index.ts +23 -0
  137. package/src/api/kws/types.ts +67 -0
  138. package/src/api/kws/util.ts +111 -0
  139. package/src/api/kws/webhook.ts +107 -0
  140. package/src/config.ts +59 -0
  141. package/src/context.ts +6 -0
  142. package/src/data-plane/bsync/index.ts +69 -33
  143. package/src/data-plane/server/db/migrations/20250627T025331240Z-add-actor-age-assurance-columns.ts +22 -0
  144. package/src/data-plane/server/db/migrations/index.ts +1 -0
  145. package/src/data-plane/server/db/tables/actor.ts +2 -0
  146. package/src/data-plane/server/routes/profile.ts +16 -1
  147. package/src/feature-gates.ts +1 -0
  148. package/src/index.ts +7 -1
  149. package/src/kws.ts +108 -0
  150. package/src/lexicon/index.ts +50 -11
  151. package/src/lexicon/lexicons.ts +201 -43
  152. package/src/lexicon/types/app/bsky/notification/unregisterPush.ts +36 -0
  153. package/src/lexicon/types/app/bsky/unspecced/defs.ts +50 -0
  154. package/src/lexicon/types/app/bsky/unspecced/getAgeAssuranceState.ts +34 -0
  155. package/src/lexicon/types/app/bsky/unspecced/initAgeAssurance.ts +47 -0
  156. package/src/proto/bsky_pb.ts +90 -0
  157. package/src/proto/courier_connect.ts +22 -0
  158. package/src/proto/courier_pb.ts +246 -0
  159. package/src/stash.ts +3 -0
  160. package/tests/views/age-assurance.test.ts +425 -0
  161. package/tsconfig.build.tsbuildinfo +1 -1
  162. package/tsconfig.tests.tsbuildinfo +1 -1
@@ -0,0 +1,38 @@
1
+ import {
2
+ InvalidRequestError,
3
+ MethodNotImplementedError,
4
+ } from '@atproto/xrpc-server'
5
+ import { AppContext } from '../../../../context'
6
+ import { Server } from '../../../../lexicon'
7
+ import { assertLexPlatform, lexPlatformToProtoPlatform } from './util'
8
+
9
+ export default function (server: Server, ctx: AppContext) {
10
+ server.app.bsky.notification.unregisterPush({
11
+ auth: ctx.authVerifier.standard,
12
+ handler: async ({ auth, input }) => {
13
+ if (!ctx.courierClient) {
14
+ throw new MethodNotImplementedError(
15
+ 'This service is not configured to support push token registration.',
16
+ )
17
+ }
18
+ const { token, platform, serviceDid, appId } = input.body
19
+ const did = auth.credentials.iss
20
+ if (serviceDid !== auth.credentials.aud) {
21
+ throw new InvalidRequestError('Invalid serviceDid.')
22
+ }
23
+ try {
24
+ assertLexPlatform(platform)
25
+ } catch (err) {
26
+ throw new InvalidRequestError(
27
+ 'Unsupported platform: must be "ios", "android", or "web".',
28
+ )
29
+ }
30
+ await ctx.courierClient.unregisterDeviceToken({
31
+ did,
32
+ token,
33
+ platform: lexPlatformToProtoPlatform(platform),
34
+ appId,
35
+ })
36
+ },
37
+ })
38
+ }
@@ -13,6 +13,7 @@ import {
13
13
  NotificationPreference,
14
14
  NotificationPreferences,
15
15
  } from '../../../../proto/bsky_pb'
16
+ import { AppPlatform } from '../../../../proto/courier_pb'
16
17
 
17
18
  type DeepPartial<T> = T extends object
18
19
  ? {
@@ -122,3 +123,20 @@ export const protobufToLex = (
122
123
  verified: protobufPreferenceToLex(res.verified),
123
124
  })
124
125
  }
126
+
127
+ type LexPlatform = 'ios' | 'android' | 'web'
128
+
129
+ export function assertLexPlatform(
130
+ platform: string,
131
+ ): asserts platform is LexPlatform {
132
+ if (platform !== 'ios' && platform !== 'android' && platform !== 'web') {
133
+ throw new Error('Unsupported platform: must be "ios", "android", or "web".')
134
+ }
135
+ }
136
+
137
+ export const lexPlatformToProtoPlatform = (platform: string): AppPlatform =>
138
+ platform === 'ios'
139
+ ? AppPlatform.IOS
140
+ : platform === 'android'
141
+ ? AppPlatform.ANDROID
142
+ : AppPlatform.WEB
@@ -0,0 +1,46 @@
1
+ import { UpstreamFailureError } from '@atproto/xrpc-server'
2
+ import { AppContext } from '../../../../context'
3
+ import { Server } from '../../../../lexicon'
4
+ import { ActorInfo } from '../../../../proto/bsky_pb'
5
+
6
+ export default function (server: Server, ctx: AppContext) {
7
+ server.app.bsky.unspecced.getAgeAssuranceState({
8
+ auth: ctx.authVerifier.standard,
9
+ handler: async ({ auth }) => {
10
+ const viewer = auth.credentials.iss
11
+ const actorInfo = await getAgeVerificationState(ctx, viewer)
12
+
13
+ return {
14
+ encoding: 'application/json',
15
+ body: {
16
+ lastInitiatedAt:
17
+ actorInfo.ageAssuranceStatus?.lastInitiatedAt
18
+ ?.toDate()
19
+ .toISOString() ?? undefined,
20
+ status: actorInfo.ageAssuranceStatus?.status ?? 'unknown',
21
+ },
22
+ }
23
+ },
24
+ })
25
+ }
26
+
27
+ const getAgeVerificationState = async (
28
+ ctx: AppContext,
29
+ actorDid: string,
30
+ ): Promise<ActorInfo> => {
31
+ try {
32
+ const res = await ctx.dataplane.getActors({
33
+ dids: [actorDid],
34
+ returnAgeAssuranceForDids: [actorDid],
35
+ skipCacheForDids: [actorDid],
36
+ })
37
+
38
+ return res.actors[0]
39
+ } catch (err) {
40
+ throw new UpstreamFailureError(
41
+ 'Cannot get current age assurance state',
42
+ 'GetAgeAssuranceStateFailed',
43
+ { cause: err },
44
+ )
45
+ }
46
+ }
@@ -0,0 +1,71 @@
1
+ import crypto from 'node:crypto'
2
+ import { isEmailValid } from '@hapi/address'
3
+ import { isDisposableEmail } from 'disposable-email-domains-js'
4
+ import {
5
+ ForbiddenError,
6
+ InvalidRequestError,
7
+ MethodNotImplementedError,
8
+ } from '@atproto/xrpc-server'
9
+ import { AppContext } from '../../../../context'
10
+ import { GateID } from '../../../../feature-gates'
11
+ import { Server } from '../../../../lexicon'
12
+ import { KwsExternalPayload } from '../../../kws/types'
13
+ import { createStashEvent, getClientUa } from '../../../kws/util'
14
+
15
+ export default function (server: Server, ctx: AppContext) {
16
+ server.app.bsky.unspecced.initAgeAssurance({
17
+ auth: ctx.authVerifier.standard,
18
+ handler: async ({ auth, input, req }) => {
19
+ if (!ctx.kwsClient) {
20
+ throw new MethodNotImplementedError(
21
+ 'This service is not configured to support age assurance.',
22
+ )
23
+ }
24
+
25
+ const actorDid = auth.credentials.iss
26
+ const enabled =
27
+ ctx.cfg.debugMode ||
28
+ ctx.featureGates.check({ userID: actorDid }, GateID.AgeAssurance)
29
+ if (!enabled) {
30
+ throw new ForbiddenError()
31
+ }
32
+
33
+ const { email, language, countryCode } = input.body
34
+ if (!isEmailValid(email) || isDisposableEmail(email)) {
35
+ throw new InvalidRequestError(
36
+ 'This email address is not supported, please use a different email.',
37
+ )
38
+ }
39
+
40
+ const attemptId = crypto.randomUUID()
41
+ // Assumes `app.set('trust proxy', ...)` configured with `true` or specific values.
42
+ const initIp = req.ip
43
+ const initUa = getClientUa(req)
44
+ const externalPayload: KwsExternalPayload = { actorDid, attemptId }
45
+
46
+ await ctx.kwsClient.sendEmail({
47
+ countryCode: countryCode.toUpperCase(),
48
+ email,
49
+ externalPayload,
50
+ language,
51
+ })
52
+
53
+ const event = await createStashEvent(ctx, {
54
+ actorDid,
55
+ attemptId,
56
+ email,
57
+ initIp,
58
+ initUa,
59
+ status: 'pending',
60
+ })
61
+
62
+ return {
63
+ encoding: 'application/json',
64
+ body: {
65
+ status: event.status,
66
+ lastInitiatedAt: event.createdAt,
67
+ },
68
+ }
69
+ },
70
+ })
71
+ }
@@ -0,0 +1,13 @@
1
+ import { Router } from 'express'
2
+ import { AppContext } from '../context'
3
+ import * as kwsApi from './kws'
4
+
5
+ export const createRouter = (ctx: AppContext): Router => {
6
+ const router = Router()
7
+
8
+ if (ctx.kwsClient) {
9
+ router.use('/kws', kwsApi.createRouter(ctx))
10
+ }
11
+
12
+ return router
13
+ }
package/src/api/index.ts CHANGED
@@ -51,6 +51,7 @@ import putPreferences from './app/bsky/notification/putPreferences'
51
51
  import putPreferencesV2 from './app/bsky/notification/putPreferencesV2'
52
52
  import registerPush from './app/bsky/notification/registerPush'
53
53
  import updateSeen from './app/bsky/notification/updateSeen'
54
+ import getAgeAssuranceState from './app/bsky/unspecced/getAgeAssuranceState'
54
55
  import getConfig from './app/bsky/unspecced/getConfig'
55
56
  import getPopularFeedGenerators from './app/bsky/unspecced/getPopularFeedGenerators'
56
57
  import getPostThreadOtherV2 from './app/bsky/unspecced/getPostThreadOtherV2'
@@ -61,6 +62,7 @@ import getSuggestedUsers from './app/bsky/unspecced/getSuggestedUsers'
61
62
  import getTaggedSuggestions from './app/bsky/unspecced/getTaggedSuggestions'
62
63
  import getTrendingTopics from './app/bsky/unspecced/getTrendingTopics'
63
64
  import getTrends from './app/bsky/unspecced/getTrends'
65
+ import initAgeAssurance from './app/bsky/unspecced/initAgeAssurance'
64
66
  import getAccountInfos from './com/atproto/admin/getAccountInfos'
65
67
  import getSubjectStatus from './com/atproto/admin/getSubjectStatus'
66
68
  import updateSubjectStatus from './com/atproto/admin/updateSubjectStatus'
@@ -75,6 +77,8 @@ export * as wellKnown from './well-known'
75
77
 
76
78
  export * as blobResolver from './blob-resolver'
77
79
 
80
+ export * as external from './external'
81
+
78
82
  export default function (server: Server, ctx: AppContext) {
79
83
  // app.bsky
80
84
  getTimeline(server, ctx)
@@ -138,6 +142,8 @@ export default function (server: Server, ctx: AppContext) {
138
142
  getConfig(server, ctx)
139
143
  getPopularFeedGenerators(server, ctx)
140
144
  getTaggedSuggestions(server, ctx)
145
+ getAgeAssuranceState(server, ctx)
146
+ initAgeAssurance(server, ctx)
141
147
  // com.atproto
142
148
  getSubjectStatus(server, ctx)
143
149
  updateSubjectStatus(server, ctx)
@@ -0,0 +1,92 @@
1
+ import express, { RequestHandler } from 'express'
2
+ import { httpLogger as log } from '../../logger'
3
+ import {
4
+ AppContextWithKwsClient,
5
+ KwsVerificationIntermediateQuery,
6
+ KwsVerificationQuery,
7
+ verificationIntermediateQuerySchema,
8
+ } from './types'
9
+ import {
10
+ createStashEvent,
11
+ getClientUa,
12
+ parseExternalPayload,
13
+ parseStatus,
14
+ validateSignature,
15
+ } from './util'
16
+
17
+ const validateRequest = (
18
+ ctx: AppContextWithKwsClient,
19
+ req: express.Request,
20
+ ): KwsVerificationQuery => {
21
+ try {
22
+ const intermediate: KwsVerificationIntermediateQuery =
23
+ verificationIntermediateQuerySchema.parse({
24
+ externalPayload: req.query.externalPayload,
25
+ signature: req.query.signature,
26
+ status: req.query.status,
27
+ })
28
+
29
+ const data = `${intermediate.status}:${intermediate.externalPayload}`
30
+ validateSignature(
31
+ ctx.cfg.kws.verificationSecret,
32
+ data,
33
+ intermediate.signature,
34
+ )
35
+
36
+ return {
37
+ ...intermediate,
38
+ externalPayload: parseExternalPayload(intermediate.externalPayload),
39
+ status: parseStatus(intermediate.status),
40
+ }
41
+ } catch (err) {
42
+ throw new Error('Invalid KWS API request', { cause: err })
43
+ }
44
+ }
45
+
46
+ export const verificationHandler =
47
+ (ctx: AppContextWithKwsClient): RequestHandler =>
48
+ async (req: express.Request, res: express.Response) => {
49
+ let actorDid: string | undefined
50
+ try {
51
+ const query = validateRequest(ctx, req)
52
+ const {
53
+ externalPayload,
54
+ status: { verified },
55
+ } = query
56
+ if (!verified) {
57
+ throw new Error(
58
+ 'Unexpected KWS verification response call with unverified status',
59
+ )
60
+ }
61
+
62
+ const { actorDid: externalPayloadActorDid, attemptId } = externalPayload
63
+ actorDid = externalPayloadActorDid
64
+ // Assumes `app.set('trust proxy', ...)` configured with `true` or specific values.
65
+ const completeIp = req.ip
66
+ const completeUa = getClientUa(req)
67
+ await createStashEvent(ctx, {
68
+ actorDid,
69
+ attemptId,
70
+ completeIp,
71
+ completeUa,
72
+ status: 'assured',
73
+ })
74
+ return res
75
+ .status(302)
76
+ .setHeader(
77
+ 'Location',
78
+ `${ctx.cfg.kws.redirectUrl}?${new URLSearchParams({ actorDid, result: 'success' })}`,
79
+ )
80
+ .end()
81
+ } catch (err) {
82
+ log.error({ err }, 'Failed to handle KWS verification response')
83
+
84
+ return res
85
+ .status(302)
86
+ .setHeader(
87
+ 'Location',
88
+ `${ctx.cfg.kws.redirectUrl}?${new URLSearchParams({ ...(actorDid ? { actorDid } : {}), result: 'unknown' })}`,
89
+ )
90
+ .end()
91
+ }
92
+ }
@@ -0,0 +1,23 @@
1
+ import { Router, raw } from 'express'
2
+ import { AppContext } from '../../context'
3
+ import { verificationHandler } from './api'
4
+ import { AppContextWithKwsClient } from './types'
5
+ import { webhookAuth, webhookHandler } from './webhook'
6
+
7
+ export const createRouter = (ctx: AppContext): Router => {
8
+ assertAppContextWithAgeAssuranceClient(ctx)
9
+
10
+ const router = Router()
11
+ router.use(raw({ type: 'application/json' }))
12
+ router.post('/age-assurance-webhook', webhookAuth(ctx), webhookHandler(ctx))
13
+ router.get('/age-assurance-verification', verificationHandler(ctx))
14
+ return router
15
+ }
16
+
17
+ const assertAppContextWithAgeAssuranceClient: (
18
+ ctx: AppContext,
19
+ ) => asserts ctx is AppContextWithKwsClient = (ctx: AppContext) => {
20
+ if (!ctx.kwsClient) {
21
+ throw new Error('Tried to set up KWS router without kwsClient configured.')
22
+ }
23
+ }
@@ -0,0 +1,67 @@
1
+ import { z } from 'zod'
2
+ import { KwsConfig, ServerConfig } from '../../config'
3
+ import { AppContext } from '../../context'
4
+ import { KwsClient } from '../../kws'
5
+
6
+ export type AppContextWithKwsClient = AppContext & {
7
+ kwsClient: KwsClient
8
+ cfg: ServerConfig & {
9
+ kws: KwsConfig
10
+ }
11
+ }
12
+
13
+ export type KwsExternalPayload = {
14
+ actorDid: string
15
+ attemptId: string
16
+ }
17
+
18
+ // `.strict()` because we control the payload structure.
19
+ export const externalPayloadSchema = z
20
+ .object({
21
+ actorDid: z.string(),
22
+ attemptId: z.string(),
23
+ })
24
+ .strict()
25
+
26
+ export type KwsStatus = {
27
+ verified: boolean
28
+ }
29
+
30
+ export type KwsVerificationIntermediateQuery = {
31
+ externalPayload: string
32
+ status: string
33
+ signature: string
34
+ }
35
+
36
+ // Not `.strict()` to avoid breaking if KWS adds fields.
37
+ export const verificationIntermediateQuerySchema = z.object({
38
+ externalPayload: z.string(),
39
+ signature: z.string(),
40
+ status: z.string(),
41
+ })
42
+
43
+ export type KwsVerificationQuery = {
44
+ externalPayload: KwsExternalPayload
45
+ signature: string
46
+ status: KwsStatus
47
+ }
48
+
49
+ export type KwsWebhookBody = {
50
+ payload: {
51
+ externalPayload: KwsExternalPayload
52
+ status: KwsStatus
53
+ }
54
+ }
55
+
56
+ // Not `.strict()` to avoid breaking if KWS adds fields.
57
+ export const statusSchema = z.object({
58
+ verified: z.boolean(),
59
+ })
60
+
61
+ // Not `.strict()` to avoid breaking if KWS adds fields.
62
+ export const webhookBodyIntermediateSchema = z.object({
63
+ payload: z.object({
64
+ externalPayload: z.string(),
65
+ status: statusSchema,
66
+ }),
67
+ })
@@ -0,0 +1,111 @@
1
+ import crypto from 'node:crypto'
2
+ import express from 'express'
3
+ import { TID } from '@atproto/common'
4
+ import { AppContext } from '../../context'
5
+ import {
6
+ AgeAssuranceEvent,
7
+ AgeAssuranceState,
8
+ } from '../../lexicon/types/app/bsky/unspecced/defs'
9
+ import { Namespaces } from '../../stash'
10
+ import {
11
+ KwsExternalPayload,
12
+ KwsStatus,
13
+ externalPayloadSchema,
14
+ statusSchema,
15
+ } from './types'
16
+
17
+ export const createStashEvent = async (
18
+ ctx: AppContext,
19
+ {
20
+ actorDid,
21
+ attemptId,
22
+ email,
23
+ initIp,
24
+ initUa,
25
+ completeIp,
26
+ completeUa,
27
+ status,
28
+ }: {
29
+ actorDid: string
30
+ attemptId: string
31
+ email?: string
32
+ initIp?: string
33
+ initUa?: string
34
+ completeIp?: string
35
+ completeUa?: string
36
+ status: AgeAssuranceState['status']
37
+ },
38
+ ) => {
39
+ const stashPayload: AgeAssuranceEvent = {
40
+ createdAt: new Date().toISOString(),
41
+ email,
42
+ status,
43
+ attemptId,
44
+ initIp,
45
+ initUa,
46
+ completeIp,
47
+ completeUa,
48
+ }
49
+
50
+ await ctx.stashClient.create({
51
+ actorDid,
52
+ namespace: Namespaces.AppBskyUnspeccedDefsAgeAssuranceEvent,
53
+ key: TID.nextStr(),
54
+ payload: stashPayload,
55
+ })
56
+ return stashPayload
57
+ }
58
+
59
+ export const validateSignature = (
60
+ key: string,
61
+ data: string,
62
+ signature: string,
63
+ ) => {
64
+ const expectedSignature = crypto
65
+ .createHmac('sha256', key)
66
+ .update(data)
67
+ .digest('hex')
68
+
69
+ const expectedSignatureBuf = Buffer.from(expectedSignature, 'hex')
70
+ const actualSignatureBuf = Buffer.from(signature, 'hex')
71
+
72
+ if (expectedSignatureBuf.length !== actualSignatureBuf.length) {
73
+ throw new Error(`Signature mismatch`)
74
+ }
75
+
76
+ if (!crypto.timingSafeEqual(expectedSignatureBuf, actualSignatureBuf)) {
77
+ throw new Error(`Signature mismatch`)
78
+ }
79
+ }
80
+
81
+ export const serializeExternalPayload = (value: KwsExternalPayload): string => {
82
+ return JSON.stringify(value)
83
+ }
84
+
85
+ export const parseExternalPayload = (
86
+ serialized: string,
87
+ ): KwsExternalPayload => {
88
+ try {
89
+ const value: unknown = JSON.parse(serialized)
90
+ return externalPayloadSchema.parse(value)
91
+ } catch (err) {
92
+ throw new Error(`Invalid external payload: ${serialized}`, { cause: err })
93
+ }
94
+ }
95
+
96
+ export const parseStatus = (serialized: string): KwsStatus => {
97
+ try {
98
+ const value: unknown = JSON.parse(serialized)
99
+ return statusSchema.parse(value)
100
+ } catch (err) {
101
+ throw new Error(`Invalid status: ${serialized}`, { cause: err })
102
+ }
103
+ }
104
+
105
+ export const kwsWwwAuthenticate = (): Record<string, string> => ({
106
+ 'www-authenticate': `Signature realm="kws"`,
107
+ })
108
+
109
+ export const getClientUa = (req: express.Request): string | undefined => {
110
+ return req.headers['user-agent']
111
+ }
@@ -0,0 +1,107 @@
1
+ import express, { RequestHandler } from 'express'
2
+ import { httpLogger as log } from '../../logger'
3
+ import {
4
+ AppContextWithKwsClient,
5
+ KwsWebhookBody,
6
+ webhookBodyIntermediateSchema,
7
+ } from './types'
8
+ import {
9
+ createStashEvent,
10
+ kwsWwwAuthenticate,
11
+ parseExternalPayload,
12
+ validateSignature,
13
+ } from './util'
14
+
15
+ export const webhookAuth =
16
+ (ctx: AppContextWithKwsClient): RequestHandler =>
17
+ (req: express.Request, res: express.Response, next: express.NextFunction) => {
18
+ const body: Buffer = req.body
19
+ const sigHeader = req.headers['x-kws-signature']
20
+ if (!sigHeader || typeof sigHeader !== 'string') {
21
+ return res.status(401).header(kwsWwwAuthenticate()).json({
22
+ success: false,
23
+ error:
24
+ 'Invalid authentication for KWS webhook: missing signature header',
25
+ })
26
+ }
27
+
28
+ try {
29
+ const parts = sigHeader.split(',')
30
+ const timestamp = parts.find((p) => p.startsWith('t='))?.split('=')[1]
31
+ const signature = parts.find((p) => p.startsWith('v1='))?.split('=')[1]
32
+ if (typeof timestamp !== 'string' || typeof signature !== 'string') {
33
+ throw new Error('Invalid webhook signature format')
34
+ }
35
+
36
+ const data = `${timestamp}.${body}`
37
+ validateSignature(ctx.cfg.kws.webhookSecret, data, signature)
38
+ next()
39
+ } catch (err) {
40
+ log.error({ err }, 'Invalid KWS webhook signature')
41
+ return res.status(401).header(kwsWwwAuthenticate()).json({
42
+ success: false,
43
+ error: 'Invalid authentication for KWS webhook: signature mismatch',
44
+ })
45
+ }
46
+ }
47
+
48
+ type AgeAssuranceWebhookIntermediateBody = {
49
+ payload: Omit<KwsWebhookBody['payload'], 'externalPayload'> & {
50
+ externalPayload: string
51
+ }
52
+ }
53
+
54
+ const parseBody = (serialized: string): KwsWebhookBody => {
55
+ try {
56
+ const value: unknown = JSON.parse(serialized)
57
+ const intermediate: AgeAssuranceWebhookIntermediateBody =
58
+ webhookBodyIntermediateSchema.parse(value)
59
+
60
+ return {
61
+ ...intermediate,
62
+ payload: {
63
+ ...intermediate.payload,
64
+ externalPayload: parseExternalPayload(
65
+ intermediate.payload.externalPayload,
66
+ ),
67
+ },
68
+ }
69
+ } catch (err) {
70
+ throw new Error(`Invalid webhook body: ${serialized}`, { cause: err })
71
+ }
72
+ }
73
+
74
+ export const webhookHandler =
75
+ (ctx: AppContextWithKwsClient): RequestHandler =>
76
+ async (req: express.Request, res: express.Response) => {
77
+ let body: KwsWebhookBody
78
+ try {
79
+ body = parseBody(req.body)
80
+ } catch (err) {
81
+ log.error({ err }, 'Invalid KWS webhook body')
82
+ return res.status(400).json(err)
83
+ }
84
+
85
+ const {
86
+ payload: {
87
+ status: { verified },
88
+ externalPayload,
89
+ },
90
+ } = body
91
+ const { actorDid, attemptId } = externalPayload
92
+ if (!verified) {
93
+ throw new Error('Unexpected KWS webhook call with unverified status')
94
+ }
95
+
96
+ try {
97
+ await createStashEvent(ctx, {
98
+ actorDid,
99
+ attemptId,
100
+ status: 'assured',
101
+ })
102
+ return res.status(200).end()
103
+ } catch (err) {
104
+ log.error({ err }, 'Failed to handle KWS webhook')
105
+ return res.status(500).json(err)
106
+ }
107
+ }