@prosopo/provider-mock 2.8.143 → 2.8.145
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/.turbo/turbo-build$colon$cjs.log +8 -6
- package/.turbo/turbo-build$colon$tsc.log +26 -26
- package/.turbo/turbo-build.log +9 -7
- package/CHANGELOG.md +52 -0
- package/Dockerfile +11 -0
- package/dist/api.d.ts +35 -2
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +97 -67
- package/dist/api.js.map +1 -1
- package/dist/cjs/api.cjs +105 -66
- package/dist/cjs/db.cjs +16 -7
- package/dist/cjs/isMain.cjs +16 -0
- package/dist/cjs/start.cjs +59 -16
- package/dist/cjs/verify.cjs +39 -0
- package/dist/db.d.ts +8 -4
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +16 -7
- package/dist/db.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/isMain.d.ts +2 -0
- package/dist/isMain.d.ts.map +1 -0
- package/dist/isMain.js +16 -0
- package/dist/isMain.js.map +1 -0
- package/dist/start.d.ts +17 -0
- package/dist/start.d.ts.map +1 -1
- package/dist/start.js +54 -17
- package/dist/start.js.map +1 -1
- package/dist/tests/db.unit.test.d.ts +2 -0
- package/dist/tests/db.unit.test.d.ts.map +1 -0
- package/dist/tests/db.unit.test.js +184 -0
- package/dist/tests/db.unit.test.js.map +1 -0
- package/dist/tests/fixtures.d.ts +33 -0
- package/dist/tests/fixtures.d.ts.map +1 -0
- package/dist/tests/fixtures.js +55 -0
- package/dist/tests/fixtures.js.map +1 -0
- package/dist/tests/handlers.unit.test.d.ts +2 -0
- package/dist/tests/handlers.unit.test.d.ts.map +1 -0
- package/dist/tests/handlers.unit.test.js +204 -0
- package/dist/tests/handlers.unit.test.js.map +1 -0
- package/dist/tests/providerMock.test-d.d.ts +2 -0
- package/dist/tests/providerMock.test-d.d.ts.map +1 -0
- package/dist/tests/providerMock.test-d.js +88 -0
- package/dist/tests/providerMock.test-d.js.map +1 -0
- package/dist/tests/start.unit.test.d.ts +2 -0
- package/dist/tests/start.unit.test.d.ts.map +1 -0
- package/dist/tests/start.unit.test.js +205 -0
- package/dist/tests/start.unit.test.js.map +1 -0
- package/dist/tests/verify.unit.test.d.ts +2 -0
- package/dist/tests/verify.unit.test.d.ts.map +1 -0
- package/dist/tests/verify.unit.test.js +63 -0
- package/dist/tests/verify.unit.test.js.map +1 -0
- package/dist/tests/wiring.unit.test.d.ts +2 -0
- package/dist/tests/wiring.unit.test.d.ts.map +1 -0
- package/dist/tests/wiring.unit.test.js +181 -0
- package/dist/tests/wiring.unit.test.js.map +1 -0
- package/dist/verify.d.ts +13 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +34 -0
- package/dist/verify.js.map +1 -0
- package/package.json +20 -12
- package/src/api.ts +170 -79
- package/src/db.ts +39 -12
- package/src/index.ts +18 -0
- package/src/isMain.ts +37 -0
- package/src/start.ts +92 -18
- package/src/tests/db.unit.test.ts +287 -0
- package/src/tests/fixtures.ts +117 -0
- package/src/tests/handlers.unit.test.ts +325 -0
- package/src/tests/providerMock.test-d.ts +165 -0
- package/src/tests/start.unit.test.ts +291 -0
- package/src/tests/verify.unit.test.ts +143 -0
- package/src/tests/wiring.unit.test.ts +293 -0
- package/src/verify.ts +63 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/vite.test.config.ts +32 -0
package/dist/verify.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
//#region src/verify.ts
|
|
2
|
+
/**
|
|
3
|
+
* The fixtures this mock provider treats as already verified.
|
|
4
|
+
*
|
|
5
|
+
* They are the accounts and commitment the integration suites sign with; any
|
|
6
|
+
* token carrying one of them is approved without a captcha ever being solved,
|
|
7
|
+
* which is the whole point of the mock.
|
|
8
|
+
*/
|
|
9
|
+
var TEST_COMMITMENT_ID = "0x123456789test";
|
|
10
|
+
var TEST_ACCOUNT = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY";
|
|
11
|
+
var TEST_DAPP = "5C4hrfjw9DjXZTzV3MwzrrAr9P1MJhSrvWGWqi1eSuyUpnhM";
|
|
12
|
+
var VERIFIED_MESSAGE = "API.USER_VERIFIED";
|
|
13
|
+
var NOT_VERIFIED_MESSAGE = "API.USER_NOT_VERIFIED";
|
|
14
|
+
/**
|
|
15
|
+
* Decide whether a decoded token counts as verified.
|
|
16
|
+
*
|
|
17
|
+
* Any one of the three fixtures matching is enough — a token only carries the
|
|
18
|
+
* fields the client filled in, so requiring all three would never approve.
|
|
19
|
+
* Empty strings are not matches: an absent field decodes to "" and must not be
|
|
20
|
+
* able to satisfy a comparison against a fixture.
|
|
21
|
+
*/
|
|
22
|
+
var verifyProcaptchaOutput = (output) => {
|
|
23
|
+
if (!(output.user === "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" || output.commitmentId === "0x123456789test" || output.dapp === "5C4hrfjw9DjXZTzV3MwzrrAr9P1MJhSrvWGWqi1eSuyUpnhM")) return {
|
|
24
|
+
statusMessage: NOT_VERIFIED_MESSAGE,
|
|
25
|
+
verified: false
|
|
26
|
+
};
|
|
27
|
+
return {
|
|
28
|
+
statusMessage: VERIFIED_MESSAGE,
|
|
29
|
+
verified: true,
|
|
30
|
+
commitmentId: TEST_COMMITMENT_ID
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
//#endregion
|
|
34
|
+
export { NOT_VERIFIED_MESSAGE, TEST_ACCOUNT, TEST_COMMITMENT_ID, TEST_DAPP, VERIFIED_MESSAGE, verifyProcaptchaOutput };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"verify.js","sourceRoot":"","sources":["../src/verify.ts"],"names":[],"mappings":"AAuBA,MAAM,CAAC,MAAM,kBAAkB,GAAG,iBAAiB,CAAC;AACpD,MAAM,CAAC,MAAM,YAAY,GAAG,kDAAkD,CAAC;AAC/E,MAAM,CAAC,MAAM,SAAS,GAAG,kDAAkD,CAAC;AAE5E,MAAM,CAAC,MAAM,gBAAgB,GAAG,mBAAmB,CAAC;AACpD,MAAM,CAAC,MAAM,oBAAoB,GAAG,uBAAuB,CAAC;AAiB5D,MAAM,CAAC,MAAM,sBAAsB,GAAG,CACrC,MAAgE,EAC1C,EAAE;IACxB,MAAM,OAAO,GACZ,MAAM,CAAC,IAAI,KAAK,YAAY;QAC5B,MAAM,CAAC,YAAY,KAAK,kBAAkB;QAC1C,MAAM,CAAC,IAAI,KAAK,SAAS,CAAC;IAE3B,IAAI,CAAC,OAAO,EAAE,CAAC;QACd,OAAO,EAAE,aAAa,EAAE,oBAAoB,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC;IACjE,CAAC;IAED,OAAO;QACN,aAAa,EAAE,gBAAgB;QAC/B,QAAQ,EAAE,IAAI;QACd,YAAY,EAAE,kBAAkB;KAChC,CAAC;AACH,CAAC,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prosopo/provider-mock",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.145",
|
|
4
4
|
"author": "PROSOPO LIMITED <info@prosopo.io>",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -22,29 +22,37 @@
|
|
|
22
22
|
"build:cross-env": "vite build --config vite.esm.config.ts",
|
|
23
23
|
"build:tsc": "tsc --build --verbose",
|
|
24
24
|
"build:cjs": "NODE_ENV=${NODE_ENV:-development}; vite build --config vite.cjs.config.ts --mode $NODE_ENV",
|
|
25
|
+
"docker-tag": "node ../../docker/docker-tag.mjs",
|
|
26
|
+
"build:docker": "docker buildx build --progress=plain --tag $(npm run --silent docker-tag) --platform linux/amd64 -f ./Dockerfile ../..",
|
|
27
|
+
"publish:docker": "docker push $(npm run --silent docker-tag)",
|
|
25
28
|
"typecheck": "tsc --project tsconfig.types.json",
|
|
26
|
-
"bundle": "NODE_ENV=${NODE_ENV:-development}; vite build --config vite.config.ts --mode $NODE_ENV"
|
|
29
|
+
"bundle": "NODE_ENV=${NODE_ENV:-development}; vite build --config vite.config.ts --mode $NODE_ENV",
|
|
30
|
+
"test": "NODE_ENV=${NODE_ENV:-test}; npx vitest run --config ./vite.test.config.ts",
|
|
31
|
+
"test:watch": "NODE_ENV=${NODE_ENV:-test}; npx vitest --config ./vite.test.config.ts",
|
|
32
|
+
"test:coverage": "NODE_ENV=${NODE_ENV:-test}; npx vitest run --coverage --config ./vite.test.config.ts"
|
|
27
33
|
},
|
|
28
34
|
"dependencies": {
|
|
29
|
-
"@prosopo/api-express-router": "3.1.
|
|
30
|
-
"@prosopo/common": "3.1.
|
|
31
|
-
"@prosopo/logger": "2.0.
|
|
32
|
-
"@prosopo/database": "4.0.
|
|
33
|
-
"@prosopo/dotenv": "3.0.
|
|
34
|
-
"@prosopo/locale": "3.
|
|
35
|
-
"@prosopo/provider": "5.0.
|
|
36
|
-
"@prosopo/types": "5.0.
|
|
37
|
-
"@prosopo/types-database": "5.0.
|
|
35
|
+
"@prosopo/api-express-router": "3.1.59",
|
|
36
|
+
"@prosopo/common": "3.1.50",
|
|
37
|
+
"@prosopo/logger": "2.0.6",
|
|
38
|
+
"@prosopo/database": "4.0.5",
|
|
39
|
+
"@prosopo/dotenv": "3.0.52",
|
|
40
|
+
"@prosopo/locale": "3.3.0",
|
|
41
|
+
"@prosopo/provider": "5.0.5",
|
|
42
|
+
"@prosopo/types": "5.0.4",
|
|
43
|
+
"@prosopo/types-database": "5.0.4",
|
|
38
44
|
"cors": "2.8.5",
|
|
39
45
|
"express": "4.21.2",
|
|
46
|
+
"express-rate-limit": "7.4.0",
|
|
40
47
|
"mongoose": "8.24.1"
|
|
41
48
|
},
|
|
42
49
|
"devDependencies": {
|
|
43
|
-
"@prosopo/config": "3.3.
|
|
50
|
+
"@prosopo/config": "3.3.8",
|
|
44
51
|
"@types/node": "22.10.2",
|
|
45
52
|
"@vitest/coverage-v8": "4.1.10",
|
|
46
53
|
"concurrently": "9.0.1",
|
|
47
54
|
"del-cli": "6.0.0",
|
|
55
|
+
"dotenv": "16.4.7",
|
|
48
56
|
"npm-run-all": "4.1.5",
|
|
49
57
|
"tslib": "2.7.0",
|
|
50
58
|
"tsx": "4.20.3",
|
package/src/api.ts
CHANGED
|
@@ -12,106 +12,197 @@
|
|
|
12
12
|
// See the License for the specific language governing permissions and
|
|
13
13
|
// limitations under the License.
|
|
14
14
|
|
|
15
|
+
import type { IncomingHttpHeaders } from "node:http";
|
|
15
16
|
import { ProsopoApiError } from "@prosopo/common";
|
|
16
|
-
import { getLogger } from "@prosopo/logger";
|
|
17
|
+
import { type Logger, getLogger } from "@prosopo/logger";
|
|
17
18
|
import { getJA4 } from "@prosopo/provider";
|
|
18
19
|
import {
|
|
19
20
|
ClientApiPaths,
|
|
21
|
+
type ProcaptchaOutput,
|
|
22
|
+
type ProcaptchaToken,
|
|
20
23
|
VerifySolutionBody,
|
|
21
24
|
decodeProcaptchaOutput,
|
|
22
25
|
} from "@prosopo/types";
|
|
23
26
|
import type { VerifySolutionBodyTypeOutput } from "@prosopo/types";
|
|
24
|
-
import express, { type Router } from "express";
|
|
25
|
-
import {
|
|
27
|
+
import express, { type RequestHandler, type Router } from "express";
|
|
28
|
+
import { rateLimit } from "express-rate-limit";
|
|
29
|
+
import { JA4Database, type JA4Store } from "./db.js";
|
|
30
|
+
import { verifyProcaptchaOutput } from "./verify.js";
|
|
31
|
+
|
|
32
|
+
/** Just enough of an express request for the handlers below. */
|
|
33
|
+
export interface ApiRequest {
|
|
34
|
+
body: unknown;
|
|
35
|
+
headers: IncomingHttpHeaders;
|
|
36
|
+
t: (key: string) => string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Just enough of an express response for the handlers below. */
|
|
40
|
+
export interface ApiResponse {
|
|
41
|
+
json: (body: object) => unknown;
|
|
42
|
+
status: (code: number) => { send: (body: string) => unknown };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type ApiNext = (error: unknown) => void;
|
|
46
|
+
|
|
47
|
+
export interface RouterDeps {
|
|
48
|
+
db: JA4Store;
|
|
49
|
+
getJA4: (
|
|
50
|
+
headers: IncomingHttpHeaders,
|
|
51
|
+
logger?: Logger,
|
|
52
|
+
) => Promise<{ ja4PlusFingerprint: string }>;
|
|
53
|
+
decodeToken: (token: ProcaptchaToken) => ProcaptchaOutput;
|
|
54
|
+
logger: Logger;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const DEFAULT_MONGO_URL = "mongodb://localhost:27017";
|
|
58
|
+
export const DEFAULT_MONGO_DBNAME = "client";
|
|
59
|
+
export const DEFAULT_MONGO_AUTH_SOURCE = "admin";
|
|
60
|
+
|
|
61
|
+
export const createDatabase = (
|
|
62
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
63
|
+
): JA4Database =>
|
|
64
|
+
new JA4Database(
|
|
65
|
+
env.MONGO_URL || DEFAULT_MONGO_URL,
|
|
66
|
+
env.MONGO_DBNAME || DEFAULT_MONGO_DBNAME,
|
|
67
|
+
env.MONGO_AUTH_SOURCE || DEFAULT_MONGO_AUTH_SOURCE,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
export const defaultRouterDeps = (): RouterDeps => ({
|
|
71
|
+
db: createDatabase(),
|
|
72
|
+
getJA4,
|
|
73
|
+
decodeToken: decodeProcaptchaOutput,
|
|
74
|
+
logger: getLogger("info", "provider-mock:api"),
|
|
75
|
+
});
|
|
26
76
|
|
|
27
77
|
/**
|
|
28
|
-
*
|
|
78
|
+
* Verify a solution, as the real provider would.
|
|
29
79
|
*
|
|
30
|
-
*
|
|
80
|
+
* A body that does not parse is the caller's fault and is reported as a 400; a
|
|
81
|
+
* token that parses but cannot be decoded is reported as a 500, because at that
|
|
82
|
+
* point the request looked well formed and the mock cannot tell the difference
|
|
83
|
+
* between a corrupt token and a bug of its own.
|
|
31
84
|
*/
|
|
32
|
-
export
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
85
|
+
export const createVerifyHandler =
|
|
86
|
+
(deps: RouterDeps) =>
|
|
87
|
+
async (req: ApiRequest, res: ApiResponse, next: ApiNext): Promise<void> => {
|
|
88
|
+
let body: VerifySolutionBodyTypeOutput;
|
|
89
|
+
try {
|
|
90
|
+
body = VerifySolutionBody.parse(req.body);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
return next(
|
|
93
|
+
new ProsopoApiError("CAPTCHA.PARSE_ERROR", {
|
|
94
|
+
context: { error: err, code: 400 },
|
|
95
|
+
logLevel: "info",
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const outcome = verifyProcaptchaOutput(deps.decodeToken(body.token));
|
|
101
|
+
res.json({
|
|
102
|
+
status: req.t(outcome.statusMessage),
|
|
103
|
+
verified: outcome.verified,
|
|
104
|
+
...(outcome.commitmentId === undefined
|
|
105
|
+
? {}
|
|
106
|
+
: { commitmentId: outcome.commitmentId }),
|
|
107
|
+
});
|
|
108
|
+
} catch (err) {
|
|
109
|
+
return next(
|
|
110
|
+
new ProsopoApiError("API.UNKNOWN", {
|
|
111
|
+
context: { error: err, code: 500 },
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
39
116
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
let body: VerifySolutionBodyTypeOutput;
|
|
50
|
-
try {
|
|
51
|
-
body = VerifySolutionBody.parse(req.body);
|
|
52
|
-
} catch (err) {
|
|
53
|
-
return next(
|
|
54
|
-
new ProsopoApiError("CAPTCHA.PARSE_ERROR", {
|
|
55
|
-
context: { error: err, code: 400 },
|
|
56
|
-
logLevel: "info",
|
|
57
|
-
}),
|
|
58
|
-
);
|
|
59
|
-
}
|
|
117
|
+
/** Record the caller's JA4 fingerprint and hand it back to them. */
|
|
118
|
+
export const createTestHandler =
|
|
119
|
+
(deps: RouterDeps) =>
|
|
120
|
+
async (req: ApiRequest, res: ApiResponse): Promise<void> => {
|
|
121
|
+
try {
|
|
122
|
+
const { ja4PlusFingerprint } = await deps.getJA4(
|
|
123
|
+
req.headers,
|
|
124
|
+
deps.logger,
|
|
125
|
+
);
|
|
60
126
|
try {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const testDapp = "5C4hrfjw9DjXZTzV3MwzrrAr9P1MJhSrvWGWqi1eSuyUpnhM";
|
|
66
|
-
let statusMessage = "API.USER_NOT_VERIFIED";
|
|
67
|
-
let approved = false;
|
|
68
|
-
if (
|
|
69
|
-
(user && user === testAccount) ||
|
|
70
|
-
(commitmentId && commitmentId === testCommitmentId) ||
|
|
71
|
-
(dapp && dapp === testDapp)
|
|
72
|
-
) {
|
|
73
|
-
approved = true;
|
|
74
|
-
statusMessage = "API.USER_VERIFIED";
|
|
75
|
-
return res.json({
|
|
76
|
-
status: req.t(statusMessage),
|
|
77
|
-
verified: approved,
|
|
78
|
-
commitmentId: testCommitmentId,
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
return res.json({
|
|
83
|
-
status: req.t(statusMessage),
|
|
84
|
-
verified: false,
|
|
127
|
+
await deps.db.connect();
|
|
128
|
+
await deps.db.addOrUpdateJA4Record({
|
|
129
|
+
ja4_fingerprint: ja4PlusFingerprint,
|
|
130
|
+
user_agent_string: req.headers["user-agent"] || "",
|
|
85
131
|
});
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}),
|
|
91
|
-
);
|
|
132
|
+
} finally {
|
|
133
|
+
// The close used to be skipped whenever anything above it threw, so
|
|
134
|
+
// every failing request leaked its connection.
|
|
135
|
+
await deps.db.close();
|
|
92
136
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
router.get("/test", async (req, res) => {
|
|
97
|
-
try {
|
|
98
|
-
const logger = getLogger("info", "provider-mock:api:test");
|
|
99
|
-
const ja4PlusFingerprint = await getJA4(req.headers, logger);
|
|
100
|
-
await db.connect();
|
|
101
|
-
await db.addOrUpdateJA4Record({
|
|
102
|
-
ja4_fingerprint: ja4PlusFingerprint.ja4PlusFingerprint,
|
|
103
|
-
user_agent_string: req.headers["user-agent"] || "",
|
|
104
|
-
});
|
|
105
|
-
await db.close();
|
|
106
|
-
return res.json({
|
|
107
|
-
ja4: ja4PlusFingerprint.ja4PlusFingerprint,
|
|
137
|
+
res.json({
|
|
138
|
+
ja4: ja4PlusFingerprint,
|
|
108
139
|
ua: req.headers["user-agent"],
|
|
109
140
|
});
|
|
110
141
|
} catch (e) {
|
|
111
|
-
|
|
112
|
-
|
|
142
|
+
// The block above fingerprints and then writes to mongo, so naming
|
|
143
|
+
// ClientHello here reported a database outage as a parse failure.
|
|
144
|
+
deps.logger.error(() => ({
|
|
145
|
+
err: e instanceof Error ? e : new Error(String(e)),
|
|
146
|
+
msg: "Failed to record the caller's JA4 fingerprint",
|
|
147
|
+
}));
|
|
148
|
+
res.status(500).send("Failed to record the caller's JA4 fingerprint.");
|
|
113
149
|
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Both routes do unauthenticated work on the caller's behalf — one verifies a
|
|
154
|
+
* token, the other writes a fingerprint to mongo — so an unthrottled client can
|
|
155
|
+
* drive the database as fast as it can send. The window is generous enough that
|
|
156
|
+
* the demos and cypress runs never see it.
|
|
157
|
+
*/
|
|
158
|
+
export const verifyRateLimit = (): RequestHandler =>
|
|
159
|
+
rateLimit({
|
|
160
|
+
windowMs: 60_000,
|
|
161
|
+
limit: 600,
|
|
162
|
+
standardHeaders: true,
|
|
163
|
+
legacyHeaders: false,
|
|
114
164
|
});
|
|
115
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Adapt a handler written against the narrow request/response types above to
|
|
168
|
+
* the express signature. Express hands the same objects through untouched; the
|
|
169
|
+
* narrow types exist only so tests can pass plain objects.
|
|
170
|
+
*/
|
|
171
|
+
export const toRequestHandler = (
|
|
172
|
+
handler: (req: ApiRequest, res: ApiResponse, next: ApiNext) => Promise<void>,
|
|
173
|
+
): RequestHandler => {
|
|
174
|
+
const adapted: RequestHandler = (req, res, next): void => {
|
|
175
|
+
// Express ignores a returned promise, so a rejection here would surface as
|
|
176
|
+
// an unhandled rejection and the request would hang; hand it to the error
|
|
177
|
+
// middleware instead.
|
|
178
|
+
handler(
|
|
179
|
+
req as unknown as ApiRequest,
|
|
180
|
+
res as unknown as ApiResponse,
|
|
181
|
+
next,
|
|
182
|
+
).catch(next);
|
|
183
|
+
};
|
|
184
|
+
return adapted;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Returns a router connected to the database which can interact with the Proposo protocol
|
|
189
|
+
*
|
|
190
|
+
* @return {Router} - A middleware router that can interact with the Prosopo protocol
|
|
191
|
+
*/
|
|
192
|
+
export function prosopoRouter(deps: RouterDeps = defaultRouterDeps()): Router {
|
|
193
|
+
const router = express.Router();
|
|
194
|
+
|
|
195
|
+
router.post(
|
|
196
|
+
ClientApiPaths.VerifyImageCaptchaSolutionDapp,
|
|
197
|
+
verifyRateLimit(),
|
|
198
|
+
toRequestHandler(createVerifyHandler(deps)),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
router.get(
|
|
202
|
+
"/test",
|
|
203
|
+
verifyRateLimit(),
|
|
204
|
+
toRequestHandler(createTestHandler(deps)),
|
|
205
|
+
);
|
|
206
|
+
|
|
116
207
|
return router;
|
|
117
208
|
}
|
package/src/db.ts
CHANGED
|
@@ -19,11 +19,11 @@ import type { Tables } from "@prosopo/types-database";
|
|
|
19
19
|
import type mongoose from "mongoose";
|
|
20
20
|
import { Schema } from "mongoose";
|
|
21
21
|
|
|
22
|
-
enum TableNames {
|
|
22
|
+
export enum TableNames {
|
|
23
23
|
ja4 = "ja4",
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
type JA4Data = {
|
|
26
|
+
export type JA4Data = {
|
|
27
27
|
application?: string;
|
|
28
28
|
library?: string;
|
|
29
29
|
device?: string;
|
|
@@ -43,7 +43,19 @@ type JA4Data = {
|
|
|
43
43
|
ja4tscan_fingerprint?: string;
|
|
44
44
|
};
|
|
45
45
|
|
|
46
|
-
type JA4Record = JA4Data & mongoose.Document;
|
|
46
|
+
export type JA4Record = JA4Data & mongoose.Document;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The slice of the database the API depends on.
|
|
50
|
+
*
|
|
51
|
+
* Narrower than JA4Database so a caller — a test, or a different storage
|
|
52
|
+
* backend — can supply one without a mongo connection.
|
|
53
|
+
*/
|
|
54
|
+
export interface JA4Store {
|
|
55
|
+
connect: () => Promise<void>;
|
|
56
|
+
close: () => Promise<void>;
|
|
57
|
+
addOrUpdateJA4Record: (record: JA4Data) => Promise<JA4Record | null>;
|
|
58
|
+
}
|
|
47
59
|
|
|
48
60
|
const JA4Schema = new Schema({
|
|
49
61
|
application: String,
|
|
@@ -90,15 +102,30 @@ export class JA4Database extends MongoDatabase {
|
|
|
90
102
|
|
|
91
103
|
override async connect(): Promise<void> {
|
|
92
104
|
await super.connect();
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
105
|
+
const connection = this.connection;
|
|
106
|
+
if (connection === undefined) {
|
|
107
|
+
// super.connect() resolves only once the connection is open, so this
|
|
108
|
+
// cannot normally happen — but the models below would be read off
|
|
109
|
+
// undefined if it ever did, which is a worse error to debug.
|
|
110
|
+
throw new ProsopoDBError("DATABASE.CONNECTION_UNDEFINED", {
|
|
111
|
+
context: { failedFuncName: this.connect.name },
|
|
112
|
+
logger: this.logger,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
for (const { collectionName, modelName, schema } of DATA_TABLES) {
|
|
116
|
+
this.tables[collectionName] = connection.model(modelName, schema);
|
|
117
|
+
}
|
|
98
118
|
}
|
|
99
119
|
|
|
120
|
+
/**
|
|
121
|
+
* The registered models.
|
|
122
|
+
*
|
|
123
|
+
* The check used to be `!this.tables`, which the constructor makes
|
|
124
|
+
* impossible, so querying before connect() failed with a TypeError on
|
|
125
|
+
* undefined rather than the database error it is.
|
|
126
|
+
*/
|
|
100
127
|
getTables(): Tables<TableNames> {
|
|
101
|
-
if (
|
|
128
|
+
if (this.tables[TableNames.ja4] === undefined) {
|
|
102
129
|
throw new ProsopoDBError("DATABASE.TABLES_UNDEFINED", {
|
|
103
130
|
context: { failedFuncName: this.getTables.name },
|
|
104
131
|
logger: this.logger,
|
|
@@ -108,14 +135,14 @@ export class JA4Database extends MongoDatabase {
|
|
|
108
135
|
}
|
|
109
136
|
|
|
110
137
|
async getJA4Records(): Promise<JA4Record[]> {
|
|
111
|
-
return this.
|
|
138
|
+
return this.getTables().ja4.find<JA4Record>({});
|
|
112
139
|
}
|
|
113
140
|
|
|
114
141
|
async getJA4RecordByFingerprintAndUserAgent(
|
|
115
142
|
ja4Fingerprint: string,
|
|
116
143
|
userAgentString: string,
|
|
117
144
|
): Promise<JA4Record | null> {
|
|
118
|
-
return this.
|
|
145
|
+
return this.getTables().ja4.findOne<JA4Record>({
|
|
119
146
|
ja4_fingerprint: ja4Fingerprint,
|
|
120
147
|
user_agent_string: userAgentString,
|
|
121
148
|
});
|
|
@@ -134,7 +161,7 @@ export class JA4Database extends MongoDatabase {
|
|
|
134
161
|
await existingRecord.save();
|
|
135
162
|
return existingRecord;
|
|
136
163
|
}
|
|
137
|
-
const newRecord = new this.
|
|
164
|
+
const newRecord = new (this.getTables().ja4)(ja4Record);
|
|
138
165
|
await newRecord.save();
|
|
139
166
|
return newRecord;
|
|
140
167
|
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Copyright 2021-2026 Prosopo (UK) Ltd.
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
|
|
15
|
+
export * from "./api.js";
|
|
16
|
+
export * from "./db.js";
|
|
17
|
+
export * from "./isMain.js";
|
|
18
|
+
export * from "./verify.js";
|
package/src/isMain.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Copyright 2021-2026 Prosopo (UK) Ltd.
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
|
|
15
|
+
import { resolve } from "node:path";
|
|
16
|
+
import { argv } from "node:process";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Whether the module at moduleUrl is the one node was asked to run.
|
|
21
|
+
*
|
|
22
|
+
* Guards the module's side effects so importing it — for its exports, or from a
|
|
23
|
+
* test — does not start a server.
|
|
24
|
+
*/
|
|
25
|
+
export const isMain = (
|
|
26
|
+
moduleUrl: string,
|
|
27
|
+
entrypoint: string | undefined = argv[1],
|
|
28
|
+
): boolean => {
|
|
29
|
+
if (entrypoint === undefined) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
// argv[1] is whatever was typed, so `node ./dist/start.js` gives a relative
|
|
33
|
+
// path while the module url is always absolute. Resolve before comparing or
|
|
34
|
+
// the guard is false for every relative entrypoint and the server never
|
|
35
|
+
// starts.
|
|
36
|
+
return fileURLToPath(moduleUrl) === resolve(entrypoint);
|
|
37
|
+
};
|
package/src/start.ts
CHANGED
|
@@ -14,30 +14,104 @@
|
|
|
14
14
|
|
|
15
15
|
import { handleErrors } from "@prosopo/api-express-router";
|
|
16
16
|
import { i18nMiddleware } from "@prosopo/locale";
|
|
17
|
-
import { LogLevel, getLogger } from "@prosopo/logger";
|
|
17
|
+
import { LogLevel, type Logger, getLogger } from "@prosopo/logger";
|
|
18
18
|
import cors from "cors";
|
|
19
|
-
import express from "express";
|
|
20
|
-
import { prosopoRouter } from "./api.js";
|
|
21
|
-
|
|
19
|
+
import express, { type Express, type RequestHandler } from "express";
|
|
20
|
+
import { type RouterDeps, prosopoRouter } from "./api.js";
|
|
21
|
+
import { isMain } from "./isMain.js";
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
export const DEFAULT_API_PORT = 9229;
|
|
24
|
+
|
|
25
|
+
type Router = ReturnType<typeof prosopoRouter>;
|
|
26
|
+
|
|
27
|
+
export interface StartDeps {
|
|
28
|
+
createApp: () => Express;
|
|
29
|
+
i18n: () => Promise<RequestHandler>;
|
|
30
|
+
router: (deps?: RouterDeps) => Router;
|
|
31
|
+
logger: Logger;
|
|
32
|
+
port: number;
|
|
33
|
+
exit: (code: number) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const defaultStartDeps = (
|
|
37
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
38
|
+
): StartDeps => ({
|
|
39
|
+
createApp: express,
|
|
40
|
+
i18n: () => i18nMiddleware({}),
|
|
41
|
+
router: prosopoRouter,
|
|
42
|
+
logger: getLogger(LogLevel.enum.info, "prosopo:provider-mock:start.ts"),
|
|
43
|
+
port: readPort(env),
|
|
44
|
+
exit: (code: number): void => {
|
|
45
|
+
process.exit(code);
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The port to listen on.
|
|
51
|
+
*
|
|
52
|
+
* The port used to be the string "9229" with no way to change it, so the mock
|
|
53
|
+
* could not be run twice on one machine. A value that is not a usable port is
|
|
54
|
+
* ignored rather than passed to listen, where it would either throw or — for a
|
|
55
|
+
* number out of range — bind somewhere unexpected.
|
|
56
|
+
*/
|
|
57
|
+
export const readPort = (env: NodeJS.ProcessEnv): number => {
|
|
58
|
+
const raw = env.PROVIDER_MOCK_PORT;
|
|
59
|
+
if (raw === undefined || raw.trim() === "") {
|
|
60
|
+
return DEFAULT_API_PORT;
|
|
61
|
+
}
|
|
62
|
+
const port = Number(raw);
|
|
63
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
64
|
+
return DEFAULT_API_PORT;
|
|
65
|
+
}
|
|
66
|
+
return port;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const startApi = async (
|
|
70
|
+
deps: StartDeps = defaultStartDeps(),
|
|
71
|
+
): Promise<Express> => {
|
|
72
|
+
const apiApp = deps.createApp();
|
|
26
73
|
|
|
27
74
|
apiApp.use(cors());
|
|
28
75
|
apiApp.use(express.json());
|
|
29
|
-
apiApp.use(await
|
|
30
|
-
apiApp.use(
|
|
76
|
+
apiApp.use(await deps.i18n());
|
|
77
|
+
apiApp.use(deps.router());
|
|
31
78
|
apiApp.use(handleErrors);
|
|
32
79
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
80
|
+
// listen() reports a failed bind (EADDRINUSE and friends) on the server's
|
|
81
|
+
// error event, not by throwing, so returning straight after calling it left
|
|
82
|
+
// main()'s try/catch with nothing to catch. Settle on whichever event fires
|
|
83
|
+
// first instead.
|
|
84
|
+
const server = apiApp.listen(deps.port);
|
|
85
|
+
await new Promise<void>((resolvePromise, rejectPromise) => {
|
|
86
|
+
server.once("listening", () => {
|
|
87
|
+
server.removeListener("error", rejectPromise);
|
|
88
|
+
deps.logger.info(() => ({
|
|
89
|
+
msg: `Prosopo app listening at http://localhost:${deps.port}`,
|
|
90
|
+
}));
|
|
91
|
+
resolvePromise();
|
|
92
|
+
});
|
|
93
|
+
server.once("error", rejectPromise);
|
|
37
94
|
});
|
|
38
|
-
}
|
|
39
95
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
96
|
+
return apiApp;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export const main = async (
|
|
100
|
+
deps: StartDeps = defaultStartDeps(),
|
|
101
|
+
): Promise<void> => {
|
|
102
|
+
try {
|
|
103
|
+
await startApi(deps);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
deps.logger.error(() => ({
|
|
106
|
+
err: error instanceof Error ? error : new Error(String(error)),
|
|
107
|
+
msg: "Failed to start API",
|
|
108
|
+
}));
|
|
109
|
+
deps.exit(1);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
if (isMain(import.meta.url)) {
|
|
114
|
+
// Not awaited: a top-level await cannot be emitted in the cjs build, and
|
|
115
|
+
// main() already handles its own failures rather than rejecting.
|
|
116
|
+
void main();
|
|
117
|
+
}
|