@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.
- package/CHANGELOG.md +20 -0
- package/dist/api/app/bsky/notification/registerPush.d.ts.map +1 -1
- package/dist/api/app/bsky/notification/registerPush.js +6 -7
- package/dist/api/app/bsky/notification/registerPush.js.map +1 -1
- package/dist/api/app/bsky/notification/unregisterPush.d.ts +4 -0
- package/dist/api/app/bsky/notification/unregisterPush.d.ts.map +1 -0
- package/dist/api/app/bsky/notification/unregisterPush.js +33 -0
- package/dist/api/app/bsky/notification/unregisterPush.js.map +1 -0
- package/dist/api/app/bsky/notification/util.d.ts +4 -0
- package/dist/api/app/bsky/notification/util.d.ts.map +1 -1
- package/dist/api/app/bsky/notification/util.js +14 -1
- package/dist/api/app/bsky/notification/util.js.map +1 -1
- package/dist/api/app/bsky/unspecced/getAgeAssuranceState.d.ts +4 -0
- package/dist/api/app/bsky/unspecced/getAgeAssuranceState.d.ts.map +1 -0
- package/dist/api/app/bsky/unspecced/getAgeAssuranceState.js +36 -0
- package/dist/api/app/bsky/unspecced/getAgeAssuranceState.js.map +1 -0
- package/dist/api/app/bsky/unspecced/initAgeAssurance.d.ts +4 -0
- package/dist/api/app/bsky/unspecced/initAgeAssurance.d.ts.map +1 -0
- package/dist/api/app/bsky/unspecced/initAgeAssurance.js +59 -0
- package/dist/api/app/bsky/unspecced/initAgeAssurance.js.map +1 -0
- package/dist/api/external.d.ts +4 -0
- package/dist/api/external.d.ts.map +1 -0
- package/dist/api/external.js +47 -0
- package/dist/api/external.js.map +1 -0
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/index.js +6 -1
- package/dist/api/index.js.map +1 -1
- package/dist/api/kws/api.d.ts +4 -0
- package/dist/api/kws/api.d.ts.map +1 -0
- package/dist/api/kws/api.js +60 -0
- package/dist/api/kws/api.js.map +1 -0
- package/dist/api/kws/index.d.ts +4 -0
- package/dist/api/kws/index.d.ts.map +1 -0
- package/dist/api/kws/index.js +21 -0
- package/dist/api/kws/index.js.map +1 -0
- package/dist/api/kws/types.d.ts +100 -0
- package/dist/api/kws/types.d.ts.map +1 -0
- package/dist/api/kws/types.js +29 -0
- package/dist/api/kws/types.js.map +1 -0
- package/dist/api/kws/util.d.ts +21 -0
- package/dist/api/kws/util.d.ts.map +1 -0
- package/dist/api/kws/util.js +78 -0
- package/dist/api/kws/util.js.map +1 -0
- package/dist/api/kws/webhook.d.ts +5 -0
- package/dist/api/kws/webhook.d.ts.map +1 -0
- package/dist/api/kws/webhook.js +80 -0
- package/dist/api/kws/webhook.js.map +1 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +40 -0
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts +3 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +3 -0
- package/dist/context.js.map +1 -1
- package/dist/data-plane/bsync/index.d.ts.map +1 -1
- package/dist/data-plane/bsync/index.js +52 -33
- package/dist/data-plane/bsync/index.js.map +1 -1
- package/dist/data-plane/server/db/migrations/20250627T025331240Z-add-actor-age-assurance-columns.d.ts +4 -0
- package/dist/data-plane/server/db/migrations/20250627T025331240Z-add-actor-age-assurance-columns.d.ts.map +1 -0
- package/dist/data-plane/server/db/migrations/20250627T025331240Z-add-actor-age-assurance-columns.js +22 -0
- package/dist/data-plane/server/db/migrations/20250627T025331240Z-add-actor-age-assurance-columns.js.map +1 -0
- package/dist/data-plane/server/db/migrations/index.d.ts +1 -0
- package/dist/data-plane/server/db/migrations/index.d.ts.map +1 -1
- package/dist/data-plane/server/db/migrations/index.js +2 -0
- package/dist/data-plane/server/db/migrations/index.js.map +1 -1
- package/dist/data-plane/server/db/tables/actor.d.ts +2 -0
- package/dist/data-plane/server/db/tables/actor.d.ts.map +1 -1
- package/dist/data-plane/server/db/tables/actor.js.map +1 -1
- package/dist/data-plane/server/routes/profile.d.ts.map +1 -1
- package/dist/data-plane/server/routes/profile.js +14 -1
- package/dist/data-plane/server/routes/profile.js.map +1 -1
- package/dist/feature-gates.d.ts +2 -1
- package/dist/feature-gates.d.ts.map +1 -1
- package/dist/feature-gates.js +1 -0
- package/dist/feature-gates.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/kws.d.ts +16 -0
- package/dist/kws.d.ts.map +1 -0
- package/dist/kws.js +86 -0
- package/dist/kws.js.map +1 -0
- package/dist/lexicon/index.d.ts +8 -2
- package/dist/lexicon/index.d.ts.map +1 -1
- package/dist/lexicon/index.js +16 -4
- package/dist/lexicon/index.js.map +1 -1
- package/dist/lexicon/lexicons.d.ts +374 -82
- package/dist/lexicon/lexicons.d.ts.map +1 -1
- package/dist/lexicon/lexicons.js +191 -42
- package/dist/lexicon/lexicons.js.map +1 -1
- package/dist/lexicon/types/app/bsky/notification/unregisterPush.d.ts +17 -0
- package/dist/lexicon/types/app/bsky/notification/unregisterPush.d.ts.map +1 -0
- package/dist/lexicon/types/app/bsky/notification/unregisterPush.js +7 -0
- package/dist/lexicon/types/app/bsky/notification/unregisterPush.js.map +1 -0
- package/dist/lexicon/types/app/bsky/unspecced/defs.d.ts +32 -0
- package/dist/lexicon/types/app/bsky/unspecced/defs.d.ts.map +1 -1
- package/dist/lexicon/types/app/bsky/unspecced/defs.js +18 -0
- package/dist/lexicon/types/app/bsky/unspecced/defs.js.map +1 -1
- package/dist/lexicon/types/app/bsky/unspecced/getAgeAssuranceState.d.ts +18 -0
- package/dist/lexicon/types/app/bsky/unspecced/getAgeAssuranceState.d.ts.map +1 -0
- package/dist/lexicon/types/app/bsky/unspecced/getAgeAssuranceState.js +7 -0
- package/dist/lexicon/types/app/bsky/unspecced/getAgeAssuranceState.js.map +1 -0
- package/dist/lexicon/types/app/bsky/unspecced/initAgeAssurance.d.ts +28 -0
- package/dist/lexicon/types/app/bsky/unspecced/initAgeAssurance.d.ts.map +1 -0
- package/dist/lexicon/types/app/bsky/unspecced/initAgeAssurance.js +7 -0
- package/dist/lexicon/types/app/bsky/unspecced/initAgeAssurance.js.map +1 -0
- package/dist/proto/bsky_pb.d.ts +33 -0
- package/dist/proto/bsky_pb.d.ts.map +1 -1
- package/dist/proto/bsky_pb.js +112 -4
- package/dist/proto/bsky_pb.js.map +1 -1
- package/dist/proto/courier_connect.d.ts +19 -1
- package/dist/proto/courier_connect.d.ts.map +1 -1
- package/dist/proto/courier_connect.js +18 -0
- package/dist/proto/courier_connect.js.map +1 -1
- package/dist/proto/courier_pb.d.ts +76 -0
- package/dist/proto/courier_pb.d.ts.map +1 -1
- package/dist/proto/courier_pb.js +233 -1
- package/dist/proto/courier_pb.js.map +1 -1
- package/dist/stash.d.ts +1 -0
- package/dist/stash.d.ts.map +1 -1
- package/dist/stash.js +1 -0
- package/dist/stash.js.map +1 -1
- package/package.json +7 -4
- package/proto/bsky.proto +8 -0
- package/proto/courier.proto +18 -0
- package/src/api/app/bsky/notification/registerPush.ts +5 -8
- package/src/api/app/bsky/notification/unregisterPush.ts +38 -0
- package/src/api/app/bsky/notification/util.ts +18 -0
- package/src/api/app/bsky/unspecced/getAgeAssuranceState.ts +46 -0
- package/src/api/app/bsky/unspecced/initAgeAssurance.ts +71 -0
- package/src/api/external.ts +13 -0
- package/src/api/index.ts +6 -0
- package/src/api/kws/api.ts +92 -0
- package/src/api/kws/index.ts +23 -0
- package/src/api/kws/types.ts +67 -0
- package/src/api/kws/util.ts +111 -0
- package/src/api/kws/webhook.ts +107 -0
- package/src/config.ts +59 -0
- package/src/context.ts +6 -0
- package/src/data-plane/bsync/index.ts +69 -33
- package/src/data-plane/server/db/migrations/20250627T025331240Z-add-actor-age-assurance-columns.ts +22 -0
- package/src/data-plane/server/db/migrations/index.ts +1 -0
- package/src/data-plane/server/db/tables/actor.ts +2 -0
- package/src/data-plane/server/routes/profile.ts +16 -1
- package/src/feature-gates.ts +1 -0
- package/src/index.ts +7 -1
- package/src/kws.ts +108 -0
- package/src/lexicon/index.ts +50 -11
- package/src/lexicon/lexicons.ts +201 -43
- package/src/lexicon/types/app/bsky/notification/unregisterPush.ts +36 -0
- package/src/lexicon/types/app/bsky/unspecced/defs.ts +50 -0
- package/src/lexicon/types/app/bsky/unspecced/getAgeAssuranceState.ts +34 -0
- package/src/lexicon/types/app/bsky/unspecced/initAgeAssurance.ts +47 -0
- package/src/proto/bsky_pb.ts +90 -0
- package/src/proto/courier_connect.ts +22 -0
- package/src/proto/courier_pb.ts +246 -0
- package/src/stash.ts +3 -0
- package/tests/views/age-assurance.test.ts +425 -0
- package/tsconfig.build.tsbuildinfo +1 -1
- 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
|
+
}
|