@atproto/bsky 0.0.172 → 0.0.174

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 (42) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/api/app/bsky/unspecced/checkHandleAvailability.d.ts +4 -0
  3. package/dist/api/app/bsky/unspecced/checkHandleAvailability.d.ts.map +1 -0
  4. package/dist/api/app/bsky/unspecced/checkHandleAvailability.js +238 -0
  5. package/dist/api/app/bsky/unspecced/checkHandleAvailability.js.map +1 -0
  6. package/dist/api/app/bsky/unspecced/initAgeAssurance.d.ts.map +1 -1
  7. package/dist/api/app/bsky/unspecced/initAgeAssurance.js +71 -9
  8. package/dist/api/app/bsky/unspecced/initAgeAssurance.js.map +1 -1
  9. package/dist/api/index.d.ts.map +1 -1
  10. package/dist/api/index.js +2 -0
  11. package/dist/api/index.js.map +1 -1
  12. package/dist/kws.d.ts +2 -0
  13. package/dist/kws.d.ts.map +1 -1
  14. package/dist/kws.js +10 -2
  15. package/dist/kws.js.map +1 -1
  16. package/dist/lexicon/index.d.ts +4 -2
  17. package/dist/lexicon/index.d.ts.map +1 -1
  18. package/dist/lexicon/index.js +8 -4
  19. package/dist/lexicon/index.js.map +1 -1
  20. package/dist/lexicon/lexicons.d.ts +268 -82
  21. package/dist/lexicon/lexicons.d.ts.map +1 -1
  22. package/dist/lexicon/lexicons.js +145 -42
  23. package/dist/lexicon/lexicons.js.map +1 -1
  24. package/dist/lexicon/types/app/bsky/unspecced/checkHandleAvailability.d.ts +58 -0
  25. package/dist/lexicon/types/app/bsky/unspecced/checkHandleAvailability.d.ts.map +1 -0
  26. package/dist/lexicon/types/app/bsky/unspecced/checkHandleAvailability.js +34 -0
  27. package/dist/lexicon/types/app/bsky/unspecced/checkHandleAvailability.js.map +1 -0
  28. package/dist/lexicon/types/app/bsky/unspecced/initAgeAssurance.d.ts +1 -0
  29. package/dist/lexicon/types/app/bsky/unspecced/initAgeAssurance.d.ts.map +1 -1
  30. package/package.json +7 -6
  31. package/src/api/app/bsky/unspecced/checkHandleAvailability.ts +291 -0
  32. package/src/api/app/bsky/unspecced/initAgeAssurance.ts +96 -11
  33. package/src/api/index.ts +2 -0
  34. package/src/kws.ts +9 -1
  35. package/src/lexicon/index.ts +24 -11
  36. package/src/lexicon/lexicons.ts +154 -43
  37. package/src/lexicon/types/app/bsky/unspecced/checkHandleAvailability.ts +99 -0
  38. package/src/lexicon/types/app/bsky/unspecced/initAgeAssurance.ts +1 -0
  39. package/tests/views/age-assurance.test.ts +44 -0
  40. package/tests/views/handle-availability.test.ts +294 -0
  41. package/tsconfig.build.tsbuildinfo +1 -1
  42. package/tsconfig.tests.tsbuildinfo +1 -1
@@ -0,0 +1,58 @@
1
+ /**
2
+ * GENERATED CODE - DO NOT MODIFY
3
+ */
4
+ import { type ValidationResult } from '@atproto/lexicon';
5
+ import { type $Typed } from '../../../../util';
6
+ export type QueryParams = {
7
+ /** Tentative handle. Will be checked for availability or used to build handle suggestions. */
8
+ handle: string;
9
+ /** User-provided email. Might be used to build handle suggestions. */
10
+ email?: string;
11
+ /** User-provided birth date. Might be used to build handle suggestions. */
12
+ birthDate?: string;
13
+ };
14
+ export type InputSchema = undefined;
15
+ export interface OutputSchema {
16
+ /** Echo of the input handle. */
17
+ handle: string;
18
+ result: $Typed<ResultAvailable> | $Typed<ResultUnavailable> | {
19
+ $type: string;
20
+ };
21
+ }
22
+ export type HandlerInput = void;
23
+ export interface HandlerSuccess {
24
+ encoding: 'application/json';
25
+ body: OutputSchema;
26
+ headers?: {
27
+ [key: string]: string;
28
+ };
29
+ }
30
+ export interface HandlerError {
31
+ status: number;
32
+ message?: string;
33
+ error?: 'InvalidEmail';
34
+ }
35
+ export type HandlerOutput = HandlerError | HandlerSuccess;
36
+ /** Indicates the provided handle is available. */
37
+ export interface ResultAvailable {
38
+ $type?: 'app.bsky.unspecced.checkHandleAvailability#resultAvailable';
39
+ }
40
+ export declare function isResultAvailable<V>(v: V): v is import("../../../../util").$TypedObject<V, "app.bsky.unspecced.checkHandleAvailability", "resultAvailable">;
41
+ export declare function validateResultAvailable<V>(v: V): ValidationResult<ResultAvailable & V>;
42
+ /** Indicates the provided handle is unavailable and gives suggestions of available handles. */
43
+ export interface ResultUnavailable {
44
+ $type?: 'app.bsky.unspecced.checkHandleAvailability#resultUnavailable';
45
+ /** List of suggested handles based on the provided inputs. */
46
+ suggestions: Suggestion[];
47
+ }
48
+ export declare function isResultUnavailable<V>(v: V): v is import("../../../../util").$TypedObject<V, "app.bsky.unspecced.checkHandleAvailability", "resultUnavailable">;
49
+ export declare function validateResultUnavailable<V>(v: V): ValidationResult<ResultUnavailable & V>;
50
+ export interface Suggestion {
51
+ $type?: 'app.bsky.unspecced.checkHandleAvailability#suggestion';
52
+ handle: string;
53
+ /** Method used to build this suggestion. Should be considered opaque to clients. Can be used for metrics. */
54
+ method: string;
55
+ }
56
+ export declare function isSuggestion<V>(v: V): v is import("../../../../util").$TypedObject<V, "app.bsky.unspecced.checkHandleAvailability", "suggestion">;
57
+ export declare function validateSuggestion<V>(v: V): ValidationResult<Suggestion & V>;
58
+ //# sourceMappingURL=checkHandleAvailability.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"checkHandleAvailability.d.ts","sourceRoot":"","sources":["../../../../../../src/lexicon/types/app/bsky/unspecced/checkHandleAvailability.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,OAAO,EAAE,KAAK,gBAAgB,EAAW,MAAM,kBAAkB,CAAA;AAGjE,OAAO,EACL,KAAK,MAAM,EAGZ,MAAM,kBAAkB,CAAA;AAMzB,MAAM,MAAM,WAAW,GAAG;IACxB,8FAA8F;IAC9F,MAAM,EAAE,MAAM,CAAA;IACd,sEAAsE;IACtE,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,2EAA2E;IAC3E,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB,CAAA;AACD,MAAM,MAAM,WAAW,GAAG,SAAS,CAAA;AAEnC,MAAM,WAAW,YAAY;IAC3B,gCAAgC;IAChC,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EACF,MAAM,CAAC,eAAe,CAAC,GACvB,MAAM,CAAC,iBAAiB,CAAC,GACzB;QAAE,KAAK,EAAE,MAAM,CAAA;KAAE,CAAA;CACtB;AAED,MAAM,MAAM,YAAY,GAAG,IAAI,CAAA;AAE/B,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,kBAAkB,CAAA;IAC5B,IAAI,EAAE,YAAY,CAAA;IAClB,OAAO,CAAC,EAAE;QAAE,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAA;CACpC;AAED,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,KAAK,CAAC,EAAE,cAAc,CAAA;CACvB;AAED,MAAM,MAAM,aAAa,GAAG,YAAY,GAAG,cAAc,CAAA;AAEzD,kDAAkD;AAClD,MAAM,WAAW,eAAe;IAC9B,KAAK,CAAC,EAAE,4DAA4D,CAAA;CACrE;AAID,wBAAgB,iBAAiB,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,oHAExC;AAED,wBAAgB,uBAAuB,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,yCAE9C;AAED,+FAA+F;AAC/F,MAAM,WAAW,iBAAiB;IAChC,KAAK,CAAC,EAAE,8DAA8D,CAAA;IACtE,8DAA8D;IAC9D,WAAW,EAAE,UAAU,EAAE,CAAA;CAC1B;AAID,wBAAgB,mBAAmB,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,sHAE1C;AAED,wBAAgB,yBAAyB,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,2CAEhD;AAED,MAAM,WAAW,UAAU;IACzB,KAAK,CAAC,EAAE,uDAAuD,CAAA;IAC/D,MAAM,EAAE,MAAM,CAAA;IACd,6GAA6G;IAC7G,MAAM,EAAE,MAAM,CAAA;CACf;AAID,wBAAgB,YAAY,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,+GAEnC;AAED,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,oCAEzC"}
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isResultAvailable = isResultAvailable;
4
+ exports.validateResultAvailable = validateResultAvailable;
5
+ exports.isResultUnavailable = isResultUnavailable;
6
+ exports.validateResultUnavailable = validateResultUnavailable;
7
+ exports.isSuggestion = isSuggestion;
8
+ exports.validateSuggestion = validateSuggestion;
9
+ const lexicons_1 = require("../../../../lexicons");
10
+ const util_1 = require("../../../../util");
11
+ const is$typed = util_1.is$typed, validate = lexicons_1.validate;
12
+ const id = 'app.bsky.unspecced.checkHandleAvailability';
13
+ const hashResultAvailable = 'resultAvailable';
14
+ function isResultAvailable(v) {
15
+ return is$typed(v, id, hashResultAvailable);
16
+ }
17
+ function validateResultAvailable(v) {
18
+ return validate(v, id, hashResultAvailable);
19
+ }
20
+ const hashResultUnavailable = 'resultUnavailable';
21
+ function isResultUnavailable(v) {
22
+ return is$typed(v, id, hashResultUnavailable);
23
+ }
24
+ function validateResultUnavailable(v) {
25
+ return validate(v, id, hashResultUnavailable);
26
+ }
27
+ const hashSuggestion = 'suggestion';
28
+ function isSuggestion(v) {
29
+ return is$typed(v, id, hashSuggestion);
30
+ }
31
+ function validateSuggestion(v) {
32
+ return validate(v, id, hashSuggestion);
33
+ }
34
+ //# sourceMappingURL=checkHandleAvailability.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"checkHandleAvailability.js","sourceRoot":"","sources":["../../../../../../src/lexicon/types/app/bsky/unspecced/checkHandleAvailability.ts"],"names":[],"mappings":";;AA0DA,8CAEC;AAED,0DAEC;AAWD,kDAEC;AAED,8DAEC;AAWD,oCAEC;AAED,gDAEC;AA7FD,mDAA4D;AAC5D,2CAIyB;AAEzB,MAAM,QAAQ,GAAG,eAAS,EACxB,QAAQ,GAAG,mBAAS,CAAA;AACtB,MAAM,EAAE,GAAG,4CAA4C,CAAA;AA0CvD,MAAM,mBAAmB,GAAG,iBAAiB,CAAA;AAE7C,SAAgB,iBAAiB,CAAI,CAAI;IACvC,OAAO,QAAQ,CAAC,CAAC,EAAE,EAAE,EAAE,mBAAmB,CAAC,CAAA;AAC7C,CAAC;AAED,SAAgB,uBAAuB,CAAI,CAAI;IAC7C,OAAO,QAAQ,CAAsB,CAAC,EAAE,EAAE,EAAE,mBAAmB,CAAC,CAAA;AAClE,CAAC;AASD,MAAM,qBAAqB,GAAG,mBAAmB,CAAA;AAEjD,SAAgB,mBAAmB,CAAI,CAAI;IACzC,OAAO,QAAQ,CAAC,CAAC,EAAE,EAAE,EAAE,qBAAqB,CAAC,CAAA;AAC/C,CAAC;AAED,SAAgB,yBAAyB,CAAI,CAAI;IAC/C,OAAO,QAAQ,CAAwB,CAAC,EAAE,EAAE,EAAE,qBAAqB,CAAC,CAAA;AACtE,CAAC;AASD,MAAM,cAAc,GAAG,YAAY,CAAA;AAEnC,SAAgB,YAAY,CAAI,CAAI;IAClC,OAAO,QAAQ,CAAC,CAAC,EAAE,EAAE,EAAE,cAAc,CAAC,CAAA;AACxC,CAAC;AAED,SAAgB,kBAAkB,CAAI,CAAI;IACxC,OAAO,QAAQ,CAAiB,CAAC,EAAE,EAAE,EAAE,cAAc,CAAC,CAAA;AACxD,CAAC"}
@@ -23,6 +23,7 @@ export interface HandlerSuccess {
23
23
  export interface HandlerError {
24
24
  status: number;
25
25
  message?: string;
26
+ error?: 'InvalidEmail' | 'DidTooLong' | 'InvalidInitiation';
26
27
  }
27
28
  export type HandlerOutput = HandlerError | HandlerSuccess;
28
29
  //# sourceMappingURL=initAgeAssurance.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"initAgeAssurance.d.ts","sourceRoot":"","sources":["../../../../../../src/lexicon/types/app/bsky/unspecced/initAgeAssurance.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,KAAK,oBAAoB,MAAM,WAAW,CAAA;AAMtD,MAAM,MAAM,WAAW,GAAG,EAAE,CAAA;AAE5B,MAAM,WAAW,WAAW;IAC1B,kEAAkE;IAClE,KAAK,EAAE,MAAM,CAAA;IACb,oFAAoF;IACpF,QAAQ,EAAE,MAAM,CAAA;IAChB,yDAAyD;IACzD,WAAW,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,MAAM,YAAY,GAAG,oBAAoB,CAAC,iBAAiB,CAAA;AAEjE,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,kBAAkB,CAAA;IAC5B,IAAI,EAAE,WAAW,CAAA;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,kBAAkB,CAAA;IAC5B,IAAI,EAAE,YAAY,CAAA;IAClB,OAAO,CAAC,EAAE;QAAE,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAA;CACpC;AAED,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB;AAED,MAAM,MAAM,aAAa,GAAG,YAAY,GAAG,cAAc,CAAA"}
1
+ {"version":3,"file":"initAgeAssurance.d.ts","sourceRoot":"","sources":["../../../../../../src/lexicon/types/app/bsky/unspecced/initAgeAssurance.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,KAAK,oBAAoB,MAAM,WAAW,CAAA;AAMtD,MAAM,MAAM,WAAW,GAAG,EAAE,CAAA;AAE5B,MAAM,WAAW,WAAW;IAC1B,kEAAkE;IAClE,KAAK,EAAE,MAAM,CAAA;IACb,oFAAoF;IACpF,QAAQ,EAAE,MAAM,CAAA;IAChB,yDAAyD;IACzD,WAAW,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,MAAM,YAAY,GAAG,oBAAoB,CAAC,iBAAiB,CAAA;AAEjE,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,kBAAkB,CAAA;IAC5B,IAAI,EAAE,WAAW,CAAA;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,kBAAkB,CAAA;IAC5B,IAAI,EAAE,YAAY,CAAA;IAClB,OAAO,CAAC,EAAE;QAAE,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAA;CACpC;AAED,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,KAAK,CAAC,EAAE,cAAc,GAAG,YAAY,GAAG,mBAAmB,CAAA;CAC5D;AAED,MAAM,MAAM,aAAa,GAAG,YAAY,GAAG,cAAc,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atproto/bsky",
3
- "version": "0.0.172",
3
+ "version": "0.0.174",
4
4
  "license": "MIT",
5
5
  "description": "Reference implementation of app.bsky App View (Bluesky API)",
6
6
  "keywords": [
@@ -37,6 +37,7 @@
37
37
  "jose": "^5.0.1",
38
38
  "key-encoder": "^2.0.3",
39
39
  "kysely": "^0.22.0",
40
+ "leo-profanity": "^1.8.0",
40
41
  "multiformats": "^9.9.0",
41
42
  "murmurhash": "^2.0.1",
42
43
  "p-queue": "^6.6.2",
@@ -52,16 +53,16 @@
52
53
  "zod": "3.23.8",
53
54
  "@atproto-labs/fetch-node": "0.1.9",
54
55
  "@atproto-labs/xrpc-utils": "0.0.17",
55
- "@atproto/api": "^0.15.26",
56
56
  "@atproto/common": "^0.4.11",
57
57
  "@atproto/crypto": "^0.4.4",
58
58
  "@atproto/did": "^0.1.5",
59
59
  "@atproto/identity": "^0.4.8",
60
60
  "@atproto/lexicon": "^0.4.12",
61
- "@atproto/repo": "^0.8.5",
62
61
  "@atproto/sync": "^0.1.29",
63
62
  "@atproto/syntax": "^0.4.0",
64
- "@atproto/xrpc-server": "^0.9.0"
63
+ "@atproto/repo": "^0.8.5",
64
+ "@atproto/xrpc-server": "^0.9.0",
65
+ "@atproto/api": "^0.15.27"
65
66
  },
66
67
  "devDependencies": {
67
68
  "@bufbuild/buf": "^1.28.1",
@@ -76,9 +77,9 @@
76
77
  "jest": "^28.1.2",
77
78
  "ts-node": "^10.8.2",
78
79
  "typescript": "^5.6.3",
79
- "@atproto/api": "^0.15.26",
80
+ "@atproto/api": "^0.15.27",
80
81
  "@atproto/lex-cli": "^0.9.0",
81
- "@atproto/pds": "^0.4.161",
82
+ "@atproto/pds": "^0.4.162",
82
83
  "@atproto/xrpc": "^0.7.1"
83
84
  },
84
85
  "scripts": {
@@ -0,0 +1,291 @@
1
+ import { isEmailValid } from '@hapi/address'
2
+ import filter from 'leo-profanity'
3
+ import * as ident from '@atproto/syntax'
4
+ import { InvalidRequestError } from '@atproto/xrpc-server'
5
+ import { AppContext } from '../../../../context'
6
+ import { Server } from '../../../../lexicon'
7
+ import {
8
+ QueryParams,
9
+ Suggestion,
10
+ } from '../../../../lexicon/types/app/bsky/unspecced/checkHandleAvailability'
11
+
12
+ // THIS IS A TEMPORARY UNSPECCED ROUTE
13
+ export default function (server: Server, ctx: AppContext) {
14
+ server.app.bsky.unspecced.checkHandleAvailability({
15
+ handler: async ({ params }) => {
16
+ const { birthDate, email, handle } = validateParams(params)
17
+
18
+ if (isSlur(handle)) {
19
+ return {
20
+ encoding: 'application/json',
21
+ body: {
22
+ handle,
23
+ result: {
24
+ $type:
25
+ 'app.bsky.unspecced.checkHandleAvailability#resultUnavailable',
26
+ suggestions: [],
27
+ },
28
+ },
29
+ }
30
+ }
31
+
32
+ const [did] = await ctx.hydrator.actor.getDids([handle], {
33
+ lookupUnidirectional: true,
34
+ })
35
+ if (!did) {
36
+ return {
37
+ encoding: 'application/json',
38
+ body: {
39
+ handle,
40
+ result: {
41
+ $type:
42
+ 'app.bsky.unspecced.checkHandleAvailability#resultAvailable',
43
+ },
44
+ },
45
+ }
46
+ }
47
+
48
+ const suggestions = await getSuggestions(ctx, handle, email, birthDate)
49
+
50
+ return {
51
+ encoding: 'application/json',
52
+ body: {
53
+ handle,
54
+ result: {
55
+ $type:
56
+ 'app.bsky.unspecced.checkHandleAvailability#resultUnavailable',
57
+ suggestions,
58
+ },
59
+ },
60
+ }
61
+ },
62
+ })
63
+ }
64
+
65
+ const validateParams = (params: QueryParams) => {
66
+ const { email } = params
67
+ if (email && !isEmailValid(email)) {
68
+ throw new InvalidRequestError('Invalid email address.', 'InvalidEmail')
69
+ }
70
+
71
+ return {
72
+ birthDate: params.birthDate,
73
+ email,
74
+ handle: ident.normalizeHandle(params.handle),
75
+ }
76
+ }
77
+
78
+ const uniqueSuggestions = (suggestions: Suggestion[]): Suggestion[] =>
79
+ suggestions.filter((s0, i, ss) => {
80
+ return ss.findIndex((s1) => s0.handle === s1.handle) === i
81
+ })
82
+
83
+ /** Gets the target number of suggestions, ensuring uniqueness and availability. */
84
+ const getSuggestions = async (
85
+ ctx: AppContext,
86
+ tentativeHandle: string,
87
+ email: string | undefined,
88
+ birthDate: string | undefined,
89
+ ): Promise<Suggestion[]> => {
90
+ const [subdomain, ...rest] = tentativeHandle.split('.')
91
+ const domain = rest.join('.')
92
+
93
+ let suggestions: Suggestion[] = []
94
+
95
+ const want = 5
96
+ let attempt = 0
97
+
98
+ const deterministic = await availableSuggestions(
99
+ ctx,
100
+ deterministicSuggestions(subdomain, email, birthDate),
101
+ tentativeHandle,
102
+ domain,
103
+ )
104
+ suggestions.push(...deterministic)
105
+
106
+ while (suggestions.length < want && attempt < 3) {
107
+ const random = await availableSuggestions(
108
+ ctx,
109
+ randomSuggestions(subdomain),
110
+ tentativeHandle,
111
+ domain,
112
+ )
113
+ suggestions.push(...random)
114
+
115
+ suggestions = uniqueSuggestions([...suggestions, ...random])
116
+ attempt++
117
+ }
118
+
119
+ return suggestions.slice(0, want)
120
+ }
121
+
122
+ type IntermediateSuggestion = {
123
+ subdomain: string
124
+ method: string
125
+ }
126
+
127
+ const availableSuggestions = async (
128
+ ctx: AppContext,
129
+ suggestions: IntermediateSuggestion[],
130
+ tentativeHandle: string,
131
+ domain: string,
132
+ ): Promise<Suggestion[]> => {
133
+ const join = (subdomain: string, domain: string) => `${subdomain}.${domain}`
134
+
135
+ const validSuggestions = suggestions
136
+ .filter((s) => {
137
+ // @TODO: from a magic number in the PDS code.
138
+ if (s.subdomain.length < 3) return false
139
+
140
+ // @TODO: from a magic number in the PDS code.
141
+ if (s.subdomain.length > 18) return false
142
+
143
+ const handle = join(s.subdomain, domain)
144
+ // @TODO: from a magic number in the entryway code.
145
+ if (handle.length > 30) return false
146
+
147
+ // Only valid, and not the tentative one.
148
+ return ident.isValidHandle(handle) && handle !== tentativeHandle
149
+ })
150
+ .map(
151
+ (s): Suggestion => ({
152
+ handle: join(s.subdomain, domain),
153
+ method: s.method,
154
+ }),
155
+ )
156
+
157
+ const dids = await ctx.hydrator.actor.getDids(
158
+ validSuggestions.map((s) => s.handle),
159
+ {
160
+ lookupUnidirectional: true,
161
+ },
162
+ )
163
+ return validSuggestions.filter((_, i) => !dids[i])
164
+ }
165
+
166
+ const deterministicSuggestions = (
167
+ subdomain: string,
168
+ email: string | undefined,
169
+ birthDate: string | undefined,
170
+ ): IntermediateSuggestion[] => {
171
+ const localPart = email
172
+ ?.split('@')[0]
173
+ .toLowerCase()
174
+ .replace('.', '-')
175
+ .replace(/[^a-zA-Z0-9-]/g, '')
176
+ const year = getYear(birthDate)
177
+
178
+ return [
179
+ ...suggestAppendDigits('handle_yob', subdomain, year),
180
+ ...suggestValue('email', localPart),
181
+ ...suggestAppendDigits('email_yob', localPart, year),
182
+ ]
183
+ }
184
+
185
+ const randomSuggestions = (subdomain: string): IntermediateSuggestion[] => [
186
+ ...suggestHyphens('hyphen', subdomain),
187
+ ...suggestAppendRandomDigits('random_digits', subdomain),
188
+ ]
189
+
190
+ const avoidDigits = ['69']
191
+
192
+ const getYear = (d: string | undefined): string | undefined => {
193
+ if (!d) return undefined
194
+
195
+ const date = new Date(d)
196
+ if (isNaN(date.getTime())) return undefined
197
+
198
+ const year = date.getFullYear().toString().slice(-2)
199
+ return avoidDigits.includes(year) ? undefined : year
200
+ }
201
+
202
+ const suggestValue = (
203
+ method: string,
204
+ s: string | undefined,
205
+ ): IntermediateSuggestion[] => {
206
+ if (!s) return []
207
+ return [{ subdomain: `${s}`, method }]
208
+ }
209
+
210
+ const suggestAppendDigits = (
211
+ method: string,
212
+ s: string | undefined,
213
+ d: string | undefined,
214
+ ): IntermediateSuggestion[] => {
215
+ if (!s || !d) return []
216
+
217
+ // If s already ends in digits, add an hyphen before appending the number.
218
+ const separator = /\d$/.test(s) ? '-' : ''
219
+ return [{ subdomain: `${s}${separator}${d}`, method }]
220
+ }
221
+
222
+ const suggestAppendRandomDigits = (
223
+ method: string,
224
+ s: string,
225
+ ): IntermediateSuggestion[] => {
226
+ const ss: IntermediateSuggestion[] = []
227
+ const want = 2
228
+ let got = 0
229
+ while (got < want) {
230
+ const randomDigits = Math.floor(Math.random() * 100).toString()
231
+ if (avoidDigits.includes(randomDigits)) continue
232
+ ss.push(...suggestAppendDigits(method, s, randomDigits))
233
+ got++
234
+ }
235
+ return ss
236
+ }
237
+
238
+ const suggestHyphens = (
239
+ method: string,
240
+ s: string,
241
+ ): IntermediateSuggestion[] => {
242
+ const ss: IntermediateSuggestion[] = []
243
+ // 2 suggestions or less, if the string is too short.
244
+ const want = Math.min(Math.floor(s.length / 2), 2)
245
+ let got = 0
246
+
247
+ while (got < want) {
248
+ // Exclude first and last character to avoid leading/trailing hyphens.
249
+ for (let i = 1; i < s.length && got < want; i++) {
250
+ // Randomly skip some combinations.
251
+ if (Math.random() > 0.5) continue
252
+
253
+ const left = s.slice(0, i)
254
+ const right = s.slice(i)
255
+ if (isSlur(left) || isSlur(right)) {
256
+ // Skip but count to avoid infinite loop.
257
+ got++
258
+ continue
259
+ }
260
+ ss.push({ subdomain: `${left}-${right}`, method })
261
+ got++
262
+ }
263
+ }
264
+
265
+ return ss
266
+ }
267
+
268
+ // regexes taken from: https://github.com/Blank-Cheque/Slurs
269
+ /* eslint-disable no-misleading-character-class */
270
+ const explicitSlurRegexes = [
271
+ /\b[cĆćĈĉČčĊċÇçḈḉȻȼꞒꞓꟄꞔƇƈɕ][hĤĥȞȟḦḧḢḣḨḩḤḥḪḫH̱ẖĦħⱧⱨꞪɦꞕΗНн][iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLl][nŃńǸǹŇňÑñṄṅŅņṆṇṊṋṈṉN̈n̈ƝɲŊŋꞐꞑꞤꞥᵰᶇɳȵꬻꬼИиПпNn][kḰḱǨǩĶķḲḳḴḵƘƙⱩⱪᶄꝀꝁꝂꝃꝄꝅꞢꞣ][sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?\b/,
272
+ /\b[cĆćĈĉČčĊċÇçḈḉȻȼꞒꞓꟄꞔƇƈɕ][ÓóÒòŎŏÔôỐốỒồỖỗỔổǑǒÖöȪȫŐőÕõṌṍṎṏȬȭȮȯO͘o͘ȰȱØøǾǿǪǫǬǭŌōṒṓṐṑỎỏȌȍȎȏƠơỚớỜờỠỡỞởỢợỌọỘộO̩o̩Ò̩ò̩Ó̩ó̩ƟɵꝊꝋꝌꝍⱺOo0]{2}[nŃńǸǹŇňÑñṄṅŅņṆṇṊṋṈṉN̈n̈ƝɲŊŋꞐꞑꞤꞥᵰᶇɳȵꬻꬼИиПпNn][sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?\b/,
273
+ /\b[fḞḟƑƒꞘꞙᵮᶂ][aÁáÀàĂăẮắẰằẴẵẲẳÂâẤấẦầẪẫẨẩǍǎÅåǺǻÄäǞǟÃãȦȧǠǡĄąĄ́ą́Ą̃ą̃ĀāĀ̀ā̀ẢảȀȁA̋a̋ȂȃẠạẶặẬậḀḁȺⱥꞺꞻᶏẚAa@4][gǴǵĞğĜĝǦǧĠġG̃g̃ĢģḠḡǤǥꞠꞡƓɠᶃꬶGgqꝖꝗꝘꝙɋʠ]{1,2}([ÓóÒòŎŏÔôỐốỒồỖỗỔổǑǒÖöȪȫŐőÕõṌṍṎṏȬȭȮȯO͘o͘ȰȱØøǾǿǪǫǬǭŌōṒṓṐṑỎỏȌȍȎȏƠơỚớỜờỠỡỞởỢợỌọỘộO̩o̩Ò̩ò̩Ó̩ó̩ƟɵꝊꝋꝌꝍⱺOo0e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEeiÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLl][tŤťṪṫŢţṬṭȚțṰṱṮṯŦŧȾⱦƬƭƮʈT̈ẗᵵƫȶ]{1,2}([rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ][yÝýỲỳŶŷY̊ẙŸÿỸỹẎẏȲȳỶỷỴỵɎɏƳƴỾỿ]|[rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ][iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLl][e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe])?)?[sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?\b/,
274
+ /\b[kḰḱǨǩĶķḲḳḴḵƘƙⱩⱪᶄꝀꝁꝂꝃꝄꝅꞢꞣ][iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLlyÝýỲỳŶŷY̊ẙŸÿỸỹẎẏȲȳỶỷỴỵɎɏƳƴỾỿ][kḰḱǨǩĶķḲḳḴḵƘƙⱩⱪᶄꝀꝁꝂꝃꝄꝅꞢꞣ][e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe]([rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ][yÝýỲỳŶŷY̊ẙŸÿỸỹẎẏȲȳỶỷỴỵɎɏƳƴỾỿ]|[rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ][iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLl][e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe])?[sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]*\b/,
275
+ /\b[nŃńǸǹŇňÑñṄṅŅņṆṇṊṋṈṉN̈n̈ƝɲŊŋꞐꞑꞤꞥᵰᶇɳȵꬻꬼИиПпNn][iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLloÓóÒòŎŏÔôỐốỒồỖỗỔổǑǒÖöȪȫŐőÕõṌṍṎṏȬȭȮȯO͘o͘ȰȱØøǾǿǪǫǬǭŌōṒṓṐṑỎỏȌȍȎȏƠơỚớỜờỠỡỞởỢợỌọỘộO̩o̩Ò̩ò̩Ó̩ó̩ƟɵꝊꝋꝌꝍⱺOoІіa4ÁáÀàĂăẮắẰằẴẵẲẳÂâẤấẦầẪẫẨẩǍǎÅåǺǻÄäǞǟÃãȦȧǠǡĄąĄ́ą́Ą̃ą̃ĀāĀ̀ā̀ẢảȀȁA̋a̋ȂȃẠạẶặẬậḀḁȺⱥꞺꞻᶏẚAa][gǴǵĞğĜĝǦǧĠġG̃g̃ĢģḠḡǤǥꞠꞡƓɠᶃꬶGgqꝖꝗꝘꝙɋʠ]{2}(l[e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe]t|[e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEeaÁáÀàĂăẮắẰằẴẵẲẳÂâẤấẦầẪẫẨẩǍǎÅåǺǻÄäǞǟÃãȦȧǠǡĄąĄ́ą́Ą̃ą̃ĀāĀ̀ā̀ẢảȀȁA̋a̋ȂȃẠạẶặẬậḀḁȺⱥꞺꞻᶏẚAa][rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ]?|n[ÓóÒòŎŏÔôỐốỒồỖỗỔổǑǒÖöȪȫŐőÕõṌṍṎṏȬȭȮȯO͘o͘ȰȱØøǾǿǪǫǬǭŌōṒṓṐṑỎỏȌȍȎȏƠơỚớỜờỠỡỞởỢợỌọỘộO̩o̩Ò̩ò̩Ó̩ó̩ƟɵꝊꝋꝌꝍⱺOo0][gǴǵĞğĜĝǦǧĠġG̃g̃ĢģḠḡǤǥꞠꞡƓɠᶃꬶGgqꝖꝗꝘꝙɋʠ]|[a4ÁáÀàĂăẮắẰằẴẵẲẳÂâẤấẦầẪẫẨẩǍǎÅåǺǻÄäǞǟÃãȦȧǠǡĄąĄ́ą́Ą̃ą̃ĀāĀ̀ā̀ẢảȀȁA̋a̋ȂȃẠạẶặẬậḀḁȺⱥꞺꞻᶏẚAa]?)?[sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?\b/,
276
+ /[nŃńǸǹŇňÑñṄṅŅņṆṇṊṋṈṉN̈n̈ƝɲŊŋꞐꞑꞤꞥᵰᶇɳȵꬻꬼИиПпNn][iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLloÓóÒòŎŏÔôỐốỒồỖỗỔổǑǒÖöȪȫŐőÕõṌṍṎṏȬȭȮȯO͘o͘ȰȱØøǾǿǪǫǬǭŌōṒṓṐṑỎỏȌȍȎȏƠơỚớỜờỠỡỞởỢợỌọỘộO̩o̩Ò̩ò̩Ó̩ó̩ƟɵꝊꝋꝌꝍⱺOoІіa4ÁáÀàĂăẮắẰằẴẵẲẳÂâẤấẦầẪẫẨẩǍǎÅåǺǻÄäǞǟÃãȦȧǠǡĄąĄ́ą́Ą̃ą̃ĀāĀ̀ā̀ẢảȀȁA̋a̋ȂȃẠạẶặẬậḀḁȺⱥꞺꞻᶏẚAa][gǴǵĞğĜĝǦǧĠġG̃g̃ĢģḠḡǤǥꞠꞡƓɠᶃꬶGgqꝖꝗꝘꝙɋʠ]{2}(l[e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe]t|[e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe][rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ])[sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?/,
277
+ /\b[tŤťṪṫŢţṬṭȚțṰṱṮṯŦŧȾⱦƬƭƮʈT̈ẗᵵƫȶ][rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ][aÁáÀàĂăẮắẰằẴẵẲẳÂâẤấẦầẪẫẨẩǍǎÅåǺǻÄäǞǟÃãȦȧǠǡĄąĄ́ą́Ą̃ą̃ĀāĀ̀ā̀ẢảȀȁA̋a̋ȂȃẠạẶặẬậḀḁȺⱥꞺꞻᶏẚAa4]+[nŃńǸǹŇňÑñṄṅŅņṆṇṊṋṈṉN̈n̈ƝɲŊŋꞐꞑꞤꞥᵰᶇɳȵꬻꬼИиПпNn]{1,2}([iÍíi̇́Ììi̇̀ĬĭÎîǏǐÏïḮḯĨĩi̇̃ĮįĮ́į̇́Į̃į̇̃ĪīĪ̀ī̀ỈỉȈȉI̋i̋ȊȋỊịꞼꞽḬḭƗɨᶖİiIıIi1lĺľļḷḹl̃ḽḻłŀƚꝉⱡɫɬꞎꬷꬸꬹᶅɭȴLl][e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe]|[yÝýỲỳŶŷY̊ẙŸÿỸỹẎẏȲȳỶỷỴỵɎɏƳƴỾỿ]|[e3ЄєЕеÉéÈèĔĕÊêẾếỀềỄễỂểÊ̄ê̄Ê̌ê̌ĚěËëẼẽĖėĖ́ė́Ė̃ė̃ȨȩḜḝĘęĘ́ę́Ę̃ę̃ĒēḖḗḔḕẺẻȄȅE̋e̋ȆȇẸẹỆệḘḙḚḛɆɇE̩e̩È̩è̩É̩é̩ᶒⱸꬴꬳEe][rŔŕŘřṘṙŖŗȐȑȒȓṚṛṜṝṞṟR̃r̃ɌɍꞦꞧⱤɽᵲᶉꭉ])[sŚśṤṥŜŝŠšṦṧṠṡŞşṢṣṨṩȘșS̩s̩ꞨꞩⱾȿꟅʂᶊᵴ]?\b/,
278
+ ]
279
+
280
+ const isSlur = (handle: string): boolean => {
281
+ return (
282
+ filter.check(handle) ||
283
+ explicitSlurRegexes.some(
284
+ (reg) =>
285
+ reg.test(handle) ||
286
+ reg.test(
287
+ handle.replaceAll('.', '').replaceAll('-', '').replaceAll('_', ''),
288
+ ),
289
+ )
290
+ )
291
+ }
@@ -8,7 +8,11 @@ import {
8
8
  } from '@atproto/xrpc-server'
9
9
  import { AppContext } from '../../../../context'
10
10
  import { GateID } from '../../../../feature-gates'
11
+ import { KwsExternalPayloadError } from '../../../../kws'
11
12
  import { Server } from '../../../../lexicon'
13
+ import { InputSchema } from '../../../../lexicon/types/app/bsky/unspecced/initAgeAssurance'
14
+ import { httpLogger as log } from '../../../../logger'
15
+ import { ActorInfo } from '../../../../proto/bsky_pb'
12
16
  import { KwsExternalPayload } from '../../../kws/types'
13
17
  import { createStashEvent, getClientUa } from '../../../kws/util'
14
18
 
@@ -30,25 +34,48 @@ export default function (server: Server, ctx: AppContext) {
30
34
  throw new ForbiddenError()
31
35
  }
32
36
 
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
- )
37
+ const actorInfo = await getAgeVerificationState(ctx, actorDid)
38
+
39
+ if (actorInfo?.ageAssuranceStatus) {
40
+ if (
41
+ actorInfo.ageAssuranceStatus.status !== 'unknown' &&
42
+ actorInfo.ageAssuranceStatus.status !== 'pending'
43
+ ) {
44
+ throw new InvalidRequestError(
45
+ `Cannot initiate age assurance flow from current state: ${actorInfo.ageAssuranceStatus.status}`,
46
+ 'InvalidInitiation',
47
+ )
48
+ }
38
49
  }
39
50
 
51
+ const { countryCode, email, language } = validateInput(input.body)
52
+
40
53
  const attemptId = crypto.randomUUID()
41
54
  // Assumes `app.set('trust proxy', ...)` configured with `true` or specific values.
42
55
  const initIp = req.ip
43
56
  const initUa = getClientUa(req)
44
57
  const externalPayload: KwsExternalPayload = { actorDid, attemptId }
45
58
 
46
- await ctx.kwsClient.sendEmail({
47
- countryCode: countryCode.toUpperCase(),
48
- email,
49
- externalPayload,
50
- language,
51
- })
59
+ try {
60
+ await ctx.kwsClient.sendEmail({
61
+ countryCode: countryCode.toUpperCase(),
62
+ email,
63
+ externalPayload,
64
+ language,
65
+ })
66
+ } catch (err) {
67
+ if (err instanceof KwsExternalPayloadError) {
68
+ log.error(
69
+ { externalPayload },
70
+ 'Age Assurance flow failed because external payload got too long, which is caused by the DID being too long',
71
+ )
72
+ throw new InvalidRequestError(
73
+ 'Age Assurance flow failed because DID is too long',
74
+ 'DidTooLong',
75
+ )
76
+ }
77
+ throw err
78
+ }
52
79
 
53
80
  const event = await createStashEvent(ctx, {
54
81
  actorDid,
@@ -69,3 +96,61 @@ export default function (server: Server, ctx: AppContext) {
69
96
  },
70
97
  })
71
98
  }
99
+
100
+ // Supported languages for KWS Adult Verification.
101
+ // This list comes from KWS's AV Developer Guide PDF doc.
102
+ const kwsAvSupportedLanguages = [
103
+ 'en',
104
+ 'ar',
105
+ 'zh-Hans',
106
+ 'nl',
107
+ 'tl',
108
+ 'fr',
109
+ 'de',
110
+ 'id',
111
+ 'it',
112
+ 'ja',
113
+ 'ko',
114
+ 'pl',
115
+ 'pt-BR',
116
+ 'pt',
117
+ 'ru',
118
+ 'es',
119
+ 'th',
120
+ 'tr',
121
+ 'vi',
122
+ ]
123
+
124
+ const validateInput = (input: InputSchema): InputSchema => {
125
+ const { countryCode, email, language } = input
126
+
127
+ if (!isEmailValid(email) || isDisposableEmail(email)) {
128
+ throw new InvalidRequestError(
129
+ 'This email address is not supported, please use a different email.',
130
+ 'InvalidEmail',
131
+ )
132
+ }
133
+
134
+ return {
135
+ countryCode,
136
+ email,
137
+ language: kwsAvSupportedLanguages.includes(language) ? language : 'en',
138
+ }
139
+ }
140
+
141
+ const getAgeVerificationState = async (
142
+ ctx: AppContext,
143
+ actorDid: string,
144
+ ): Promise<ActorInfo | undefined> => {
145
+ try {
146
+ const res = await ctx.dataplane.getActors({
147
+ dids: [actorDid],
148
+ returnAgeAssuranceForDids: [actorDid],
149
+ skipCacheForDids: [actorDid],
150
+ })
151
+
152
+ return res.actors[0]
153
+ } catch (err) {
154
+ return undefined
155
+ }
156
+ }
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 checkHandleAvailability from './app/bsky/unspecced/checkHandleAvailability'
54
55
  import getAgeAssuranceState from './app/bsky/unspecced/getAgeAssuranceState'
55
56
  import getConfig from './app/bsky/unspecced/getConfig'
56
57
  import getPopularFeedGenerators from './app/bsky/unspecced/getPopularFeedGenerators'
@@ -142,6 +143,7 @@ export default function (server: Server, ctx: AppContext) {
142
143
  getConfig(server, ctx)
143
144
  getPopularFeedGenerators(server, ctx)
144
145
  getTaggedSuggestions(server, ctx)
146
+ checkHandleAvailability(server, ctx)
145
147
  getAgeAssuranceState(server, ctx)
146
148
  initAgeAssurance(server, ctx)
147
149
  // com.atproto
package/src/kws.ts CHANGED
@@ -14,6 +14,9 @@ const authResponseSchema = z.object({
14
14
  access_token: z.string(),
15
15
  })
16
16
 
17
+ const EXTERNAL_PAYLOAD_CHAR_LIMIT = 200
18
+ export class KwsExternalPayloadError extends Error {}
19
+
17
20
  export class KwsClient {
18
21
  constructor(public cfg: KwsConfig) {}
19
22
 
@@ -76,6 +79,11 @@ export class KwsClient {
76
79
  externalPayload: KwsExternalPayload
77
80
  language: string
78
81
  }) {
82
+ const serializedExternalPayload = serializeExternalPayload(externalPayload)
83
+ if (serializedExternalPayload.length > EXTERNAL_PAYLOAD_CHAR_LIMIT) {
84
+ throw new KwsExternalPayloadError()
85
+ }
86
+
79
87
  const res = await this.fetchWithAuth(
80
88
  `${this.cfg.apiOrigin}/v1/verifications/send-email`,
81
89
  {
@@ -86,7 +94,7 @@ export class KwsClient {
86
94
  },
87
95
  body: JSON.stringify({
88
96
  email,
89
- externalPayload: serializeExternalPayload(externalPayload),
97
+ externalPayload: serializedExternalPayload,
90
98
  language,
91
99
  location: countryCode,
92
100
  userContext: 'adult',