@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,107 @@
1
+ import express, { RequestHandler } from 'express'
2
+ import { ageAssuranceLogger as logger } from '../../../logger'
3
+ import { getClientUa, validateSignature } from '../../kws/util'
4
+ import { AGE_ASSURANCE_CONFIG } from '../const'
5
+ import { parseKWSAgeVerifiedStatus } from '../kws/age-verified'
6
+ import {
7
+ type KWSExternalPayloadV2,
8
+ parseKWSExternalPayloadV2,
9
+ } from '../kws/external-payload'
10
+ import { createEvent } from '../stash'
11
+ import { AppContextWithAA } from '../types'
12
+ import { computeAgeAssuranceAccessOrThrow } from '../util'
13
+
14
+ function parseQueryParams(
15
+ ctx: AppContextWithAA,
16
+ req: express.Request,
17
+ ): {
18
+ status: string
19
+ externalPayload: string
20
+ } {
21
+ try {
22
+ const status = String(req.query.status)
23
+ const externalPayload = String(req.query.externalPayload)
24
+ const signature = String(req.query.signature)
25
+
26
+ validateSignature(
27
+ ctx.cfg.kws.ageVerifiedRedirectSecret,
28
+ `${status}:${externalPayload}`,
29
+ signature,
30
+ )
31
+
32
+ return {
33
+ status,
34
+ externalPayload,
35
+ }
36
+ } catch (err) {
37
+ throw new Error('Invalid KWS API request', { cause: err })
38
+ }
39
+ }
40
+
41
+ export const handler =
42
+ (ctx: AppContextWithAA): RequestHandler =>
43
+ async (req: express.Request, res: express.Response) => {
44
+ let externalPayload: KWSExternalPayloadV2 | undefined
45
+
46
+ try {
47
+ const query = parseQueryParams(ctx, req)
48
+ const { verified, verifiedMinimumAge } = parseKWSAgeVerifiedStatus(
49
+ query.status,
50
+ )
51
+ externalPayload = parseKWSExternalPayloadV2(query.externalPayload)
52
+ const { actorDid, attemptId, countryCode, regionCode } = externalPayload
53
+
54
+ /*
55
+ * KWS does not send unverified webhooks for age verification, so we
56
+ * expect all webhooks to be verified. This is just a sanity check.
57
+ */
58
+ if (!verified) {
59
+ const message =
60
+ 'Expected KWS verification redirect to have verified status'
61
+ logger.error({}, message)
62
+ throw new Error(message)
63
+ }
64
+
65
+ const { access } = computeAgeAssuranceAccessOrThrow(
66
+ AGE_ASSURANCE_CONFIG,
67
+ {
68
+ countryCode,
69
+ regionCode,
70
+ verifiedMinimumAge,
71
+ },
72
+ )
73
+
74
+ await createEvent(ctx, actorDid, {
75
+ attemptId,
76
+ // Assumes `app.set('trust proxy', ...)` configured with `true` or specific values.
77
+ completeIp: req.ip,
78
+ completeUa: getClientUa(req),
79
+ countryCode,
80
+ regionCode,
81
+ status: 'assured',
82
+ access,
83
+ })
84
+
85
+ const q = new URLSearchParams({ actorDid, result: 'success' })
86
+
87
+ return res
88
+ .status(302)
89
+ .setHeader('Location', `${ctx.cfg.kws.redirectUrl}?${q}`)
90
+ .end()
91
+ } catch (err) {
92
+ logger.error(
93
+ { err, ...externalPayload },
94
+ 'Failed to handle KWS verification redirect',
95
+ )
96
+
97
+ const q = new URLSearchParams({
98
+ ...(externalPayload ? { actorDid: externalPayload.actorDid } : {}),
99
+ result: 'unknown',
100
+ })
101
+
102
+ return res
103
+ .status(302)
104
+ .setHeader('Location', `${ctx.cfg.kws.redirectUrl}?${q}`)
105
+ .end()
106
+ }
107
+ }
@@ -0,0 +1,22 @@
1
+ import { TID } from '@atproto/common'
2
+ import { AppContext } from '../../context'
3
+ import { Event as AgeAssuranceEvent } from '../../lexicon/types/app/bsky/ageassurance/defs'
4
+ import { Namespaces } from '../../stash'
5
+
6
+ export async function createEvent(
7
+ ctx: AppContext,
8
+ actorDid: string,
9
+ event: Omit<AgeAssuranceEvent, 'createdAt'>,
10
+ ) {
11
+ const payload: AgeAssuranceEvent = {
12
+ createdAt: new Date().toISOString(),
13
+ ...event,
14
+ }
15
+ await ctx.stashClient.create({
16
+ actorDid: actorDid,
17
+ namespace: Namespaces.AppBskyAgeassuranceDefsEvent,
18
+ key: TID.nextStr(),
19
+ payload,
20
+ })
21
+ return payload
22
+ }
@@ -0,0 +1,10 @@
1
+ import { KwsConfig, ServerConfig } from '../../config'
2
+ import { AppContext } from '../../context'
3
+ import { KwsClient } from '../../kws'
4
+
5
+ export type AppContextWithAA = AppContext & {
6
+ kwsClient: KwsClient
7
+ cfg: ServerConfig & {
8
+ kws: KwsConfig
9
+ }
10
+ }
@@ -0,0 +1,66 @@
1
+ import {
2
+ type AppBskyAgeassuranceDefs,
3
+ computeAgeAssuranceRegionAccess,
4
+ getAgeAssuranceRegionConfig,
5
+ } from '@atproto/api'
6
+
7
+ /**
8
+ * Compute age assurance access based on verified minimum age. Thrown errors
9
+ * are internal errors, so handle them accordingly.
10
+ */
11
+ export function computeAgeAssuranceAccessOrThrow(
12
+ config: AppBskyAgeassuranceDefs.Config,
13
+ {
14
+ countryCode,
15
+ regionCode,
16
+ verifiedMinimumAge,
17
+ }: {
18
+ countryCode: string
19
+ regionCode?: string
20
+ verifiedMinimumAge: number
21
+ },
22
+ ) {
23
+ const region = getAgeAssuranceRegionConfig(config, {
24
+ countryCode,
25
+ regionCode,
26
+ })
27
+
28
+ if (region) {
29
+ const result = computeAgeAssuranceRegionAccess(region, {
30
+ assuredAge: verifiedMinimumAge,
31
+ /*
32
+ * We don't care about this here, this is a client-only rule. If we have
33
+ * verified data, we can use that, and the account creation date is
34
+ * irrelevant.
35
+ */
36
+ accountCreatedAt: undefined,
37
+ })
38
+
39
+ if (result) {
40
+ return result
41
+ } else {
42
+ /*
43
+ * If we don't get a result, it's because none of the rules matched,
44
+ * which is a configuration error: there should always be a default
45
+ * rule.
46
+ */
47
+ throw new Error('Cound not compute age assurance region access')
48
+ }
49
+ } else {
50
+ /**
51
+ * If we had geolocation data, but we don't have a region config for this
52
+ * geolocation, then it means a user outside of our configured regions
53
+ * has completed age verification. In this case, we can't determine their
54
+ * access level, so we throw an error.
55
+ *
56
+ * This case is also guarded in `app.bsky.ageassurance.begin`.
57
+ */
58
+ throw new Error('Could not get config for region')
59
+ }
60
+ }
61
+
62
+ export function createLocationString(countryCode: string, regionCode?: string) {
63
+ return regionCode
64
+ ? `${countryCode.toUpperCase()}-${regionCode.toUpperCase()}`
65
+ : countryCode.toUpperCase()
66
+ }
@@ -0,0 +1,75 @@
1
+ import express, { RequestHandler } from 'express'
2
+ import { ageAssuranceLogger as logger } from '../../../logger'
3
+ import { AGE_ASSURANCE_CONFIG } from '../const'
4
+ import {
5
+ type KWSWebhookAgeVerified,
6
+ parseKWSAgeVerifiedWebhook,
7
+ } from '../kws/age-verified'
8
+ import { parseKWSExternalPayloadV2 } from '../kws/external-payload'
9
+ import { createEvent } from '../stash'
10
+ import { type AppContextWithAA } from '../types'
11
+ import { computeAgeAssuranceAccessOrThrow } from '../util'
12
+
13
+ export const handler =
14
+ (ctx: AppContextWithAA): RequestHandler =>
15
+ async (req: express.Request, res: express.Response) => {
16
+ let body: KWSWebhookAgeVerified
17
+ try {
18
+ body = parseKWSAgeVerifiedWebhook(req.body)
19
+ } catch (err) {
20
+ const message = 'Failed to parse KWS webhook body'
21
+ logger.error({ err }, message)
22
+ return res.status(400).json({ error: message })
23
+ }
24
+
25
+ const { status, externalPayload } = body.payload
26
+ const { verified, verifiedMinimumAge } = status
27
+ const { actorDid, countryCode, regionCode, attemptId } =
28
+ parseKWSExternalPayloadV2(externalPayload)
29
+
30
+ /*
31
+ * KWS does not send unverified webhooks for age verification, so we
32
+ * expect all webhooks to be verified. This is just a sanity check.
33
+ */
34
+ if (!verified) {
35
+ const message = 'Expected KWS webhook to have verified status'
36
+ logger.error({}, message)
37
+ return res.status(400).json({ error: message })
38
+ }
39
+
40
+ let result: ReturnType<typeof computeAgeAssuranceAccessOrThrow> | undefined
41
+ try {
42
+ result = computeAgeAssuranceAccessOrThrow(AGE_ASSURANCE_CONFIG, {
43
+ countryCode,
44
+ regionCode,
45
+ verifiedMinimumAge,
46
+ })
47
+ } catch (err) {
48
+ // internal errors
49
+ logger.error(
50
+ { err, attemptId, actorDid, countryCode, regionCode },
51
+ 'Failed to compute age assurance access',
52
+ )
53
+ }
54
+
55
+ try {
56
+ if (result) {
57
+ await createEvent(ctx, actorDid, {
58
+ attemptId,
59
+ countryCode,
60
+ regionCode,
61
+ status: 'assured',
62
+ access: result.access,
63
+ })
64
+ }
65
+
66
+ return res.status(200).end()
67
+ } catch (err) {
68
+ const message = 'Failed to handle KWS webhook'
69
+ logger.error(
70
+ { err, attemptId, actorDid, countryCode, regionCode },
71
+ message,
72
+ )
73
+ return res.status(500).json({ error: message })
74
+ }
75
+ }
@@ -0,0 +1,167 @@
1
+ import crypto from 'node:crypto'
2
+ import { isEmailValid } from '@hapi/address'
3
+ import { isDisposableEmail } from 'disposable-email-domains-js'
4
+ import { getAgeAssuranceRegionConfig } from '@atproto/api'
5
+ import {
6
+ InvalidRequestError,
7
+ MethodNotImplementedError,
8
+ } from '@atproto/xrpc-server'
9
+ import { AppContext } from '../../../../context'
10
+ import { Server } from '../../../../lexicon'
11
+ import { InputSchema } from '../../../../lexicon/types/app/bsky/ageassurance/begin'
12
+ import { httpLogger as log } from '../../../../logger'
13
+ import { ActorInfo } from '../../../../proto/bsky_pb'
14
+ import { AGE_ASSURANCE_CONFIG } from '../../../age-assurance/const'
15
+ import {
16
+ KWS_SUPPORTED_LANGUAGES,
17
+ KWS_V2_COUNTRIES,
18
+ } from '../../../age-assurance/kws/const'
19
+ import {
20
+ KWSExternalPayloadTooLargeError,
21
+ KWSExternalPayloadVersion,
22
+ serializeKWSExternalPayloadV2,
23
+ } from '../../../age-assurance/kws/external-payload'
24
+ import { createEvent } from '../../../age-assurance/stash'
25
+ import { createLocationString } from '../../../age-assurance/util'
26
+ import { getClientUa } from '../../../kws/util'
27
+
28
+ export default function (server: Server, ctx: AppContext) {
29
+ server.app.bsky.ageassurance.begin({
30
+ auth: ctx.authVerifier.standard,
31
+ handler: async ({ auth, input, req }) => {
32
+ if (!ctx.kwsClient) {
33
+ throw new MethodNotImplementedError(
34
+ 'This service is not configured to support age assurance.',
35
+ )
36
+ }
37
+
38
+ const actorDid = auth.credentials.iss
39
+ const actorInfo = await getAgeVerificationState(ctx, actorDid)
40
+
41
+ if (actorInfo?.ageAssuranceStatus) {
42
+ if (
43
+ actorInfo.ageAssuranceStatus.status !== 'unknown' &&
44
+ actorInfo.ageAssuranceStatus.status !== 'pending'
45
+ ) {
46
+ throw new InvalidRequestError(
47
+ `Cannot initiate age assurance flow from current state: ${actorInfo.ageAssuranceStatus.status}`,
48
+ 'InvalidInitiation',
49
+ )
50
+ }
51
+ }
52
+
53
+ const attemptId = crypto.randomUUID()
54
+ const { email, language, countryCode, regionCode } = validateInput(
55
+ input.body,
56
+ )
57
+
58
+ let externalPayload: string
59
+ try {
60
+ externalPayload = serializeKWSExternalPayloadV2({
61
+ version: KWSExternalPayloadVersion.V2,
62
+ actorDid,
63
+ attemptId,
64
+ countryCode,
65
+ regionCode,
66
+ })
67
+ } catch (err) {
68
+ if (err instanceof KWSExternalPayloadTooLargeError) {
69
+ log.error({ err, actorDid }, err.message)
70
+ throw new InvalidRequestError(
71
+ 'Age Assurance flow failed because DID is too long',
72
+ 'DidTooLong',
73
+ )
74
+ }
75
+ throw err
76
+ }
77
+
78
+ /*
79
+ * Determine if age assurance config exists for this region. The calling
80
+ * application should already have checked for this, so this is just a
81
+ * safeguard.
82
+ */
83
+ const region = getAgeAssuranceRegionConfig(AGE_ASSURANCE_CONFIG, {
84
+ countryCode,
85
+ regionCode,
86
+ })
87
+ if (!region) {
88
+ const message = 'Age Assurance is not required in this region'
89
+ log.error({ actorDid, countryCode, regionCode }, message)
90
+ throw new InvalidRequestError(message, 'RegionNotSupported')
91
+ }
92
+
93
+ const location = createLocationString(countryCode, regionCode)
94
+
95
+ if (KWS_V2_COUNTRIES.has(region.countryCode)) {
96
+ // `age-verified` flow
97
+ await ctx.kwsClient.sendAgeVerifiedFlowEmail({
98
+ location,
99
+ email,
100
+ externalPayload,
101
+ language,
102
+ })
103
+ } else {
104
+ // `adult-verified` flow is what we've been using prior to `age-verified`
105
+ await ctx.kwsClient.sendAdultVerifiedFlowEmail({
106
+ location,
107
+ email,
108
+ externalPayload,
109
+ language,
110
+ })
111
+ }
112
+
113
+ const event = await createEvent(ctx, actorDid, {
114
+ attemptId,
115
+ email,
116
+ // Assumes `app.set('trust proxy', ...)` configured with `true` or specific values.
117
+ initIp: req.ip,
118
+ initUa: getClientUa(req),
119
+ status: 'pending',
120
+ access: 'unknown',
121
+ countryCode,
122
+ regionCode,
123
+ })
124
+
125
+ return {
126
+ encoding: 'application/json',
127
+ body: {
128
+ lastInitiatedAt: event.createdAt,
129
+ status: 'pending',
130
+ access: 'unknown',
131
+ },
132
+ }
133
+ },
134
+ })
135
+ }
136
+
137
+ function validateInput({ email, language, ...rest }: InputSchema): InputSchema {
138
+ if (!isEmailValid(email) || isDisposableEmail(email)) {
139
+ throw new InvalidRequestError(
140
+ 'This email address is not supported, please use a different email.',
141
+ 'InvalidEmail',
142
+ )
143
+ }
144
+
145
+ return {
146
+ email,
147
+ language: KWS_SUPPORTED_LANGUAGES.has(language) ? language : 'en',
148
+ ...rest,
149
+ }
150
+ }
151
+
152
+ async function getAgeVerificationState(
153
+ ctx: AppContext,
154
+ actorDid: string,
155
+ ): Promise<ActorInfo | undefined> {
156
+ try {
157
+ const res = await ctx.dataplane.getActors({
158
+ dids: [actorDid],
159
+ returnAgeAssuranceForDids: [actorDid],
160
+ skipCacheForDids: [actorDid],
161
+ })
162
+
163
+ return res.actors[0]
164
+ } catch (err) {
165
+ return undefined
166
+ }
167
+ }
@@ -0,0 +1,15 @@
1
+ import { AGE_ASSURANCE_CONFIG } from '../../../../api/age-assurance/const'
2
+ import { AppContext } from '../../../../context'
3
+ import { Server } from '../../../../lexicon'
4
+
5
+ export default function (server: Server, ctx: AppContext) {
6
+ server.app.bsky.ageassurance.getConfig({
7
+ auth: ctx.authVerifier.standardOptional,
8
+ handler: async () => {
9
+ return {
10
+ encoding: 'application/json',
11
+ body: AGE_ASSURANCE_CONFIG,
12
+ }
13
+ },
14
+ })
15
+ }
@@ -0,0 +1,53 @@
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.ageassurance.getState({
8
+ auth: ctx.authVerifier.standard,
9
+ handler: async ({ auth }) => {
10
+ const viewer = auth.credentials.iss
11
+ const actor = await getActorInfo(ctx, viewer)
12
+
13
+ return {
14
+ encoding: 'application/json',
15
+ body: {
16
+ state: {
17
+ lastInitiatedAt:
18
+ actor.ageAssuranceStatus?.lastInitiatedAt
19
+ ?.toDate()
20
+ .toISOString() || undefined,
21
+ status: actor.ageAssuranceStatus?.status || 'unknown',
22
+ access: actor.ageAssuranceStatus?.access || 'unknown',
23
+ },
24
+ metadata: {
25
+ accountCreatedAt:
26
+ actor.createdAt?.toDate().toISOString() || undefined,
27
+ },
28
+ },
29
+ }
30
+ },
31
+ })
32
+ }
33
+
34
+ const getActorInfo = async (
35
+ ctx: AppContext,
36
+ actorDid: string,
37
+ ): Promise<ActorInfo> => {
38
+ try {
39
+ const res = await ctx.dataplane.getActors({
40
+ dids: [actorDid],
41
+ returnAgeAssuranceForDids: [actorDid],
42
+ skipCacheForDids: [actorDid],
43
+ })
44
+
45
+ return res.actors[0]
46
+ } catch (err) {
47
+ throw new UpstreamFailureError(
48
+ 'Cannot get current age assurance state',
49
+ 'GetAgeAssuranceStateFailed',
50
+ { cause: err },
51
+ )
52
+ }
53
+ }
@@ -1,5 +1,6 @@
1
1
  import { Router } from 'express'
2
2
  import { AppContext } from '../context'
3
+ import * as aaApi from './age-assurance'
3
4
  import * as kwsApi from './kws'
4
5
 
5
6
  export const createRouter = (ctx: AppContext): Router => {
@@ -7,6 +8,7 @@ export const createRouter = (ctx: AppContext): Router => {
7
8
 
8
9
  if (ctx.kwsClient) {
9
10
  router.use('/kws', kwsApi.createRouter(ctx))
11
+ router.use(aaApi.createRouter(ctx))
10
12
  }
11
13
 
12
14
  return router
package/src/api/index.ts CHANGED
@@ -5,6 +5,9 @@ import getProfiles from './app/bsky/actor/getProfiles'
5
5
  import getSuggestions from './app/bsky/actor/getSuggestions'
6
6
  import searchActors from './app/bsky/actor/searchActors'
7
7
  import searchActorsTypeahead from './app/bsky/actor/searchActorsTypeahead'
8
+ import aaBegin from './app/bsky/ageassurance/begin'
9
+ import aaGetConfig from './app/bsky/ageassurance/getConfig'
10
+ import aaGetState from './app/bsky/ageassurance/getState'
8
11
  import createBookmark from './app/bsky/bookmark/createBookmark'
9
12
  import deleteBookmark from './app/bsky/bookmark/deleteBookmark'
10
13
  import getBookmarks from './app/bsky/bookmark/getBookmarks'
@@ -156,6 +159,9 @@ export default function (server: Server, ctx: AppContext) {
156
159
  getTaggedSuggestions(server, ctx)
157
160
  getAgeAssuranceState(server, ctx)
158
161
  initAgeAssurance(server, ctx)
162
+ aaGetConfig(server, ctx)
163
+ aaGetState(server, ctx)
164
+ aaBegin(server, ctx)
159
165
  // com.atproto
160
166
  getSubjectStatus(server, ctx)
161
167
  updateSubjectStatus(server, ctx)
@@ -1,42 +1,41 @@
1
1
  import express, { RequestHandler } from 'express'
2
2
  import { httpLogger as log } from '../../logger'
3
+ import { AGE_ASSURANCE_CONFIG } from '../age-assurance/const'
3
4
  import {
4
- AppContextWithKwsClient,
5
- KwsVerificationIntermediateQuery,
6
- KwsVerificationQuery,
7
- verificationIntermediateQuerySchema,
8
- } from './types'
5
+ KWSExternalPayloadVersion,
6
+ parseKWSExternalPayloadV1WithV2Compat,
7
+ } from '../age-assurance/kws/external-payload'
8
+ import { createEvent } from '../age-assurance/stash'
9
+ import { computeAgeAssuranceAccessOrThrow } from '../age-assurance/util'
10
+ import { AppContextWithKwsClient } from './types'
9
11
  import {
10
12
  createStashEvent,
11
13
  getClientUa,
12
- parseExternalPayload,
13
14
  parseStatus,
14
15
  validateSignature,
15
16
  } from './util'
16
17
 
17
- const validateRequest = (
18
+ function parseQueryParams(
18
19
  ctx: AppContextWithKwsClient,
19
20
  req: express.Request,
20
- ): KwsVerificationQuery => {
21
+ ): {
22
+ status: string
23
+ externalPayload: string
24
+ } {
21
25
  try {
22
- const intermediate: KwsVerificationIntermediateQuery =
23
- verificationIntermediateQuerySchema.parse({
24
- externalPayload: req.query.externalPayload,
25
- signature: req.query.signature,
26
- status: req.query.status,
27
- })
26
+ const status = String(req.query.status)
27
+ const externalPayload = String(req.query.externalPayload)
28
+ const signature = String(req.query.signature)
28
29
 
29
- const data = `${intermediate.status}:${intermediate.externalPayload}`
30
30
  validateSignature(
31
31
  ctx.cfg.kws.verificationSecret,
32
- data,
33
- intermediate.signature,
32
+ `${status}:${externalPayload}`,
33
+ signature,
34
34
  )
35
35
 
36
36
  return {
37
- ...intermediate,
38
- externalPayload: parseExternalPayload(intermediate.externalPayload),
39
- status: parseStatus(intermediate.status),
37
+ status,
38
+ externalPayload,
40
39
  }
41
40
  } catch (err) {
42
41
  throw new Error('Invalid KWS API request', { cause: err })
@@ -48,29 +47,51 @@ export const verificationHandler =
48
47
  async (req: express.Request, res: express.Response) => {
49
48
  let actorDid: string | undefined
50
49
  try {
51
- const query = validateRequest(ctx, req)
52
- const {
53
- externalPayload,
54
- status: { verified },
55
- } = query
50
+ const query = parseQueryParams(ctx, req)
51
+ const { verified } = parseStatus(query.status)
56
52
  if (!verified) {
57
53
  throw new Error(
58
54
  'Unexpected KWS verification response call with unverified status',
59
55
  )
60
56
  }
61
57
 
62
- const { actorDid: externalPayloadActorDid, attemptId } = externalPayload
63
- actorDid = externalPayloadActorDid
64
58
  // Assumes `app.set('trust proxy', ...)` configured with `true` or specific values.
65
59
  const completeIp = req.ip
66
60
  const completeUa = getClientUa(req)
67
- await createStashEvent(ctx, {
68
- actorDid,
69
- attemptId,
70
- completeIp,
71
- completeUa,
72
- status: 'assured',
73
- })
61
+ const externalPayload = parseKWSExternalPayloadV1WithV2Compat(
62
+ query.externalPayload,
63
+ )
64
+ actorDid = externalPayload.actorDid
65
+
66
+ if (externalPayload.version === KWSExternalPayloadVersion.V2) {
67
+ const { countryCode, regionCode, attemptId } = externalPayload
68
+ const { access } = computeAgeAssuranceAccessOrThrow(
69
+ AGE_ASSURANCE_CONFIG,
70
+ {
71
+ countryCode: countryCode,
72
+ regionCode: regionCode,
73
+ verifiedMinimumAge: 18, // `adult-verified` is 18+ only
74
+ },
75
+ )
76
+ await createEvent(ctx, actorDid, {
77
+ attemptId,
78
+ status: 'assured',
79
+ access,
80
+ countryCode,
81
+ regionCode,
82
+ completeIp,
83
+ completeUa,
84
+ })
85
+ } else {
86
+ await createStashEvent(ctx, {
87
+ actorDid,
88
+ attemptId: externalPayload.attemptId,
89
+ status: 'assured',
90
+ completeIp,
91
+ completeUa,
92
+ })
93
+ }
94
+
74
95
  return res
75
96
  .status(302)
76
97
  .setHeader(