@prosopo/provider-mock 2.8.141 → 2.8.144

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 (78) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +8 -6
  2. package/.turbo/turbo-build$colon$tsc.log +26 -26
  3. package/.turbo/turbo-build.log +9 -7
  4. package/CHANGELOG.md +62 -0
  5. package/Dockerfile +11 -0
  6. package/dist/api.d.ts +35 -2
  7. package/dist/api.d.ts.map +1 -1
  8. package/dist/api.js +97 -67
  9. package/dist/api.js.map +1 -1
  10. package/dist/cjs/api.cjs +105 -66
  11. package/dist/cjs/db.cjs +16 -7
  12. package/dist/cjs/isMain.cjs +16 -0
  13. package/dist/cjs/start.cjs +59 -16
  14. package/dist/cjs/verify.cjs +39 -0
  15. package/dist/db.d.ts +8 -4
  16. package/dist/db.d.ts.map +1 -1
  17. package/dist/db.js +16 -7
  18. package/dist/db.js.map +1 -1
  19. package/dist/index.d.ts +5 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +5 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/isMain.d.ts +2 -0
  24. package/dist/isMain.d.ts.map +1 -0
  25. package/dist/isMain.js +16 -0
  26. package/dist/isMain.js.map +1 -0
  27. package/dist/start.d.ts +17 -0
  28. package/dist/start.d.ts.map +1 -1
  29. package/dist/start.js +54 -17
  30. package/dist/start.js.map +1 -1
  31. package/dist/tests/db.unit.test.d.ts +2 -0
  32. package/dist/tests/db.unit.test.d.ts.map +1 -0
  33. package/dist/tests/db.unit.test.js +184 -0
  34. package/dist/tests/db.unit.test.js.map +1 -0
  35. package/dist/tests/fixtures.d.ts +33 -0
  36. package/dist/tests/fixtures.d.ts.map +1 -0
  37. package/dist/tests/fixtures.js +55 -0
  38. package/dist/tests/fixtures.js.map +1 -0
  39. package/dist/tests/handlers.unit.test.d.ts +2 -0
  40. package/dist/tests/handlers.unit.test.d.ts.map +1 -0
  41. package/dist/tests/handlers.unit.test.js +204 -0
  42. package/dist/tests/handlers.unit.test.js.map +1 -0
  43. package/dist/tests/providerMock.test-d.d.ts +2 -0
  44. package/dist/tests/providerMock.test-d.d.ts.map +1 -0
  45. package/dist/tests/providerMock.test-d.js +88 -0
  46. package/dist/tests/providerMock.test-d.js.map +1 -0
  47. package/dist/tests/start.unit.test.d.ts +2 -0
  48. package/dist/tests/start.unit.test.d.ts.map +1 -0
  49. package/dist/tests/start.unit.test.js +205 -0
  50. package/dist/tests/start.unit.test.js.map +1 -0
  51. package/dist/tests/verify.unit.test.d.ts +2 -0
  52. package/dist/tests/verify.unit.test.d.ts.map +1 -0
  53. package/dist/tests/verify.unit.test.js +63 -0
  54. package/dist/tests/verify.unit.test.js.map +1 -0
  55. package/dist/tests/wiring.unit.test.d.ts +2 -0
  56. package/dist/tests/wiring.unit.test.d.ts.map +1 -0
  57. package/dist/tests/wiring.unit.test.js +181 -0
  58. package/dist/tests/wiring.unit.test.js.map +1 -0
  59. package/dist/verify.d.ts +13 -0
  60. package/dist/verify.d.ts.map +1 -0
  61. package/dist/verify.js +34 -0
  62. package/dist/verify.js.map +1 -0
  63. package/package.json +19 -11
  64. package/src/api.ts +170 -79
  65. package/src/db.ts +39 -12
  66. package/src/index.ts +18 -0
  67. package/src/isMain.ts +37 -0
  68. package/src/start.ts +92 -18
  69. package/src/tests/db.unit.test.ts +287 -0
  70. package/src/tests/fixtures.ts +117 -0
  71. package/src/tests/handlers.unit.test.ts +325 -0
  72. package/src/tests/providerMock.test-d.ts +165 -0
  73. package/src/tests/start.unit.test.ts +291 -0
  74. package/src/tests/verify.unit.test.ts +143 -0
  75. package/src/tests/wiring.unit.test.ts +293 -0
  76. package/src/verify.ts +63 -0
  77. package/tsconfig.tsbuildinfo +1 -1
  78. 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.141",
3
+ "version": "2.8.144",
4
4
  "author": "PROSOPO LIMITED <info@prosopo.io>",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -22,21 +22,28 @@
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.55",
30
- "@prosopo/common": "3.1.49",
31
- "@prosopo/logger": "2.0.5",
32
- "@prosopo/database": "4.0.1",
33
- "@prosopo/dotenv": "3.0.51",
34
- "@prosopo/locale": "3.2.9",
35
- "@prosopo/provider": "5.0.1",
36
- "@prosopo/types": "5.0.1",
37
- "@prosopo/types-database": "5.0.1",
35
+ "@prosopo/api-express-router": "3.1.58",
36
+ "@prosopo/common": "3.1.50",
37
+ "@prosopo/logger": "2.0.6",
38
+ "@prosopo/database": "4.0.4",
39
+ "@prosopo/dotenv": "3.0.52",
40
+ "@prosopo/locale": "3.3.0",
41
+ "@prosopo/provider": "5.0.4",
42
+ "@prosopo/types": "5.0.3",
43
+ "@prosopo/types-database": "5.0.3",
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": {
@@ -45,6 +52,7 @@
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 { JA4Database } from "./db.js";
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
- * Returns a router connected to the database which can interact with the Proposo protocol
78
+ * Verify a solution, as the real provider would.
29
79
  *
30
- * @return {Router} - A middleware router that can interact with the Prosopo protocol
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 function prosopoRouter(): Router {
33
- const router = express.Router();
34
- const db = new JA4Database(
35
- process.env.MONGO_URL || "mongodb://localhost:27017",
36
- process.env.MONGO_DBNAME || "client",
37
- process.env.MONGO_AUTH_SOURCE || "admin",
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
- * Verifies a user's solution as being approved or not
42
- *
43
- * @param {string} userAccount - Dapp User id
44
- * @param {string} commitmentId - The captcha solution to look up
45
- */
46
- router.post(
47
- ClientApiPaths.VerifyImageCaptchaSolutionDapp,
48
- async (req, res, next) => {
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
- const { token } = body;
62
- const { user, dapp, commitmentId } = decodeProcaptchaOutput(token);
63
- const testCommitmentId = "0x123456789test";
64
- const testAccount = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY";
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
- } catch (err) {
87
- return next(
88
- new ProsopoApiError("API.UNKNOWN", {
89
- context: { error: err, code: 500 },
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
- console.error("Error parsing ClientHello:", e);
112
- return res.status(500).send("Error parsing ClientHello.");
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
- DATA_TABLES.map(({ collectionName, modelName, schema }) => {
94
- if (this.connection) {
95
- this.tables[collectionName] = this.connection.model(modelName, schema);
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 (!this.tables) {
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.tables.ja4.find<JA4Record>({});
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.tables.ja4.findOne<JA4Record>({
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.tables.ja4(ja4Record);
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
- const logger = getLogger(LogLevel.enum.info, "prosopo:provider-mock:start.ts");
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
- async function startApi() {
24
- const apiApp = express();
25
- const apiPort = "9229";
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 i18nMiddleware({}));
30
- apiApp.use(prosopoRouter());
76
+ apiApp.use(await deps.i18n());
77
+ apiApp.use(deps.router());
31
78
  apiApp.use(handleErrors);
32
79
 
33
- apiApp.listen(apiPort, () => {
34
- logger.info(() => ({
35
- msg: `Prosopo app listening at http://localhost:${apiPort}`,
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
- startApi().catch((error) => {
41
- logger.error(() => ({ err: error, msg: "Failed to start API" }));
42
- process.exit(1);
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
+ }