@prosopo/api-express-router 3.2.0 → 3.2.1

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.
@@ -1,28 +0,0 @@
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 type { ApiEndpoint } from "@prosopo/api-route";
16
- import type { NextFunction, Request, Response } from "express";
17
- import type { ZodType } from "zod";
18
-
19
- interface ApiExpressEndpointAdapter {
20
- handleRequest(
21
- endpoint: ApiEndpoint<ZodType | undefined>,
22
- request: Request,
23
- response: Response,
24
- next: NextFunction,
25
- ): Promise<void>;
26
- }
27
-
28
- export type { ApiExpressEndpointAdapter };
@@ -1,33 +0,0 @@
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
- // We need the unused params to make express recognise this function as an error handler
16
-
17
- import { type ProsopoApiError, unwrapError } from "@prosopo/common";
18
- import type { NextFunction, Request, Response } from "express";
19
- import type { ZodError } from "zod";
20
-
21
- export const handleErrors = (
22
- err: ProsopoApiError | SyntaxError | ZodError,
23
- request: Request,
24
- response: Response,
25
- next: NextFunction,
26
- ) => {
27
- const { code, statusMessage, jsonError } = unwrapError(err, request.i18n);
28
- response.statusMessage = statusMessage;
29
- response.set("content-type", "application/json");
30
- response.status(code);
31
- response.send({ error: jsonError });
32
- response.end();
33
- };
package/src/index.ts DELETED
@@ -1,38 +0,0 @@
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 type { LogLevel } from "@prosopo/logger";
16
- import { ApiExpressRouterFactory } from "./apiExpressRouterFactory.js";
17
- import { ApiExpressDefaultEndpointAdapter } from "./endpointAdapter/apiExpressDefaultEndpointAdapter.js";
18
- import type { ApiExpressEndpointAdapter } from "./endpointAdapter/apiExpressEndpointAdapter.js";
19
-
20
- const apiExpressRouterFactory = new ApiExpressRouterFactory();
21
-
22
- const createApiExpressDefaultEndpointAdapter = (
23
- logLevel: LogLevel,
24
- errorStatusCode = 500,
25
- ): ApiExpressEndpointAdapter => {
26
- return new ApiExpressDefaultEndpointAdapter(logLevel, errorStatusCode);
27
- };
28
-
29
- export {
30
- apiExpressRouterFactory,
31
- createApiExpressDefaultEndpointAdapter,
32
- type ApiExpressEndpointAdapter,
33
- };
34
-
35
- export * from "./errorHandler.js";
36
- export * from "./middlewares/authMiddleware.js";
37
- export * from "./middlewares/jwtReplayGuard.js";
38
- export * from "./middlewares/requestLoggerMiddleware.js";
@@ -1,150 +0,0 @@
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 { hexToU8a } from "@polkadot/util";
16
- import { ProsopoApiError, ProsopoBaseError } from "@prosopo/common";
17
- import { type TranslationKey, isTranslationKey } from "@prosopo/locale";
18
- import type { ApiJsonError, KeyringPair } from "@prosopo/types";
19
- import type {
20
- JWT,
21
- JWTVerifyOptions,
22
- JWTVerifyResult,
23
- } from "@prosopo/util-crypto";
24
- import type { NextFunction, Request, Response } from "express";
25
- import type { JwtReplayGuard } from "./jwtReplayGuard.js";
26
-
27
- export type AuthMiddlewareOptions = {
28
- /** Claim checks (audience, maximum lifetime) applied to every token. */
29
- verify?: JWTVerifyOptions;
30
- /**
31
- * Makes a token that carries a `jti` single-use. Tokens without `jti` are
32
- * unaffected, so existing issuers keep working until they add one.
33
- */
34
- replayGuard?: JwtReplayGuard;
35
- };
36
-
37
- const verifyWith = (
38
- key: KeyringPair | undefined,
39
- jwt: JWT,
40
- options: AuthMiddlewareOptions,
41
- ): JWTVerifyResult | undefined => {
42
- if (!key) return undefined;
43
- const result = key.jwtVerify(jwt, options.verify);
44
- return result.isValid ? result : undefined;
45
- };
46
-
47
- const isReplay = (
48
- result: JWTVerifyResult,
49
- options: AuthMiddlewareOptions,
50
- ): boolean => {
51
- const payload = result.payload;
52
- if (!options.replayGuard || !payload || typeof payload.jti !== "string") {
53
- return false;
54
- }
55
- return !options.replayGuard.claim(
56
- `${payload.sub}:${payload.jti}`,
57
- payload.exp,
58
- );
59
- };
60
-
61
- export const authMiddleware = (
62
- pair: KeyringPair | undefined,
63
- authAccount?: KeyringPair | undefined,
64
- options: AuthMiddlewareOptions = {},
65
- ) => {
66
- return async (req: Request, res: Response, next: NextFunction) => {
67
- try {
68
- const jwt = extractJWT(req);
69
-
70
- const verified =
71
- verifyWith(authAccount, jwt, options) ?? verifyWith(pair, jwt, options);
72
-
73
- if (verified && !isReplay(verified, options)) {
74
- next();
75
- return;
76
- }
77
-
78
- unauthorized(req, res, "API.UNAUTHORIZED");
79
- return;
80
- } catch (err) {
81
- req.logger.error(() => ({ err, msg: "Auth Middleware Error" }));
82
- // The thrown value can carry request context, stack or config, so
83
- // only its translation key goes back to the caller.
84
- unauthorized(req, res, translationKeyOf(err));
85
- return;
86
- }
87
- };
88
- };
89
-
90
- const translationKeyOf = (err: unknown): TranslationKey =>
91
- err instanceof ProsopoBaseError &&
92
- err.translationKey !== undefined &&
93
- isTranslationKey(err.translationKey)
94
- ? err.translationKey
95
- : "API.UNAUTHORIZED";
96
-
97
- const unauthorized = (
98
- req: Request,
99
- res: Response,
100
- key: TranslationKey,
101
- ): void => {
102
- const body: { error: ApiJsonError } = {
103
- error: {
104
- code: 401,
105
- key,
106
- message: req.i18n ? req.i18n.t(key) : key,
107
- },
108
- };
109
- res.status(401).json(body);
110
- };
111
-
112
- const extractJWT = (req: Request) => {
113
- const authHeader = req.headers.Authorization || req.headers.authorization;
114
-
115
- if (!authHeader || typeof authHeader !== "string") {
116
- throw new ProsopoApiError("GENERAL.MISSING_AUTH_HEADER", {
117
- context: { error: "Missing Authorization header", code: 401 },
118
- });
119
- }
120
-
121
- const jwt = authHeader.replace("Bearer ", "");
122
-
123
- if (!jwt) {
124
- throw new ProsopoApiError("GENERAL.INVALID_JWT", {
125
- context: { error: "Missing JWT", code: 400 },
126
- });
127
- }
128
-
129
- return jwt as JWT;
130
- };
131
-
132
- export const verifySignature = (
133
- signature: string,
134
- message: string,
135
- pair: KeyringPair,
136
- ) => {
137
- const u8Sig = hexToU8a(signature);
138
-
139
- if (!pair.verify(message, u8Sig, pair.publicKey)) {
140
- throw new ProsopoApiError("GENERAL.INVALID_SIGNATURE", {
141
- context: {
142
- error: "Signature verification failed",
143
- code: 401,
144
- account: pair.address,
145
- message,
146
- signature,
147
- },
148
- });
149
- }
150
- };
@@ -1,56 +0,0 @@
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 interface JwtReplayGuard {
16
- /**
17
- * Record a token id. Returns false when the id was already recorded and has
18
- * not yet expired, i.e. the token is being replayed.
19
- */
20
- claim(id: string, expiresAtSeconds: number): boolean;
21
- }
22
-
23
- /**
24
- * Per-process set of token ids seen until their expiry. Only tokens signed by
25
- * an admin key reach it, so the size cap guards against a misbehaving issuer
26
- * rather than an attacker; when full the oldest entry is dropped.
27
- */
28
- export const createMemoryJwtReplayGuard = (
29
- maxEntries = 10_000,
30
- nowSeconds: () => number = () => Date.now() / 1000,
31
- ): JwtReplayGuard => {
32
- const seen = new Map<string, number>();
33
-
34
- const sweep = (now: number): void => {
35
- for (const [id, exp] of seen) {
36
- if (exp < now) seen.delete(id);
37
- }
38
- };
39
-
40
- return {
41
- claim(id: string, expiresAtSeconds: number): boolean {
42
- const now = nowSeconds();
43
- const previous = seen.get(id);
44
- if (previous !== undefined && previous >= now) return false;
45
- if (seen.size >= maxEntries) {
46
- sweep(now);
47
- if (seen.size >= maxEntries) {
48
- const oldest = seen.keys().next();
49
- if (!oldest.done) seen.delete(oldest.value);
50
- }
51
- }
52
- seen.set(id, expiresAtSeconds);
53
- return true;
54
- },
55
- };
56
- };
@@ -1,104 +0,0 @@
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 type { ProviderEnvironment } from "@prosopo/env";
16
- import { getLogger, parseLogLevel } from "@prosopo/logger";
17
- import type { NextFunction, Request, Response } from "express";
18
- import { v4 as uuidv4 } from "uuid";
19
-
20
- const getHeaderValue = (
21
- req: Request,
22
- headerName: string,
23
- ): string | undefined => {
24
- if (headerName in req.headers && req.headers[headerName]) {
25
- return req.headers[headerName].toString();
26
- }
27
- return undefined;
28
- };
29
-
30
- const HEALTH_CHECK_PATHS = new Set(["/healthz", "/health", "/readyz"]);
31
-
32
- export function requestLoggerMiddleware(env: ProviderEnvironment) {
33
- return (req: Request, res: Response, next: NextFunction) => {
34
- // Honour an inbound `x-request-id` (Caddy/upstream proxy assigns one and
35
- // we want the same value to link Caddy access logs to Node app logs).
36
- // Fall back to `e-<uuid>` when the upstream didn't set one.
37
- const requestId =
38
- (req.headers["x-request-id"] as string) || `e-${uuidv4()}`;
39
- const user = getHeaderValue(req, "prosopo-user");
40
- const siteKey = getHeaderValue(req, "prosopo-site-key");
41
- const sessionId = req.body?.sessionId ? req.body.sessionId : undefined;
42
-
43
- const logger = getLogger(
44
- parseLogLevel(env.config.logLevel),
45
- "provider:request",
46
- ).with({
47
- requestId,
48
- ...(user ? { user } : {}),
49
- ...(siteKey ? { siteKey } : {}),
50
- ...(sessionId ? { sessionId } : {}),
51
- });
52
-
53
- req.logger = logger;
54
- req.requestId = requestId;
55
-
56
- // Mirror the requestId back to the client so downstream systems can
57
- // correlate their own logs to ours.
58
- res.setHeader("x-request-id", requestId);
59
-
60
- // Skip request/response envelope logging for high-volume health probes
61
- // so they don't drown out useful captcha-endpoint entries.
62
- if (HEALTH_CHECK_PATHS.has(req.path)) {
63
- next();
64
- return;
65
- }
66
-
67
- // Emit a single request-received line so every endpoint has an entry
68
- // log in OpenObserve even when the handler itself doesn't log. Kept at
69
- // info level; drop to debug if the volume becomes a problem.
70
- const startNs = process.hrtime.bigint();
71
- logger.info(() => ({
72
- msg: "Request received",
73
- data: {
74
- method: req.method,
75
- path: req.path,
76
- },
77
- }));
78
-
79
- // Log a response-finished line. `finish` fires when the response was
80
- // sent successfully; `close` fires when the client disconnects before
81
- // the response was fully sent. Guard against both firing (once() would
82
- // only cover a single event source).
83
- let finished = false;
84
- const emitFinish = (outcome: "finish" | "close") => {
85
- if (finished) return;
86
- finished = true;
87
- const durationMs = Number(process.hrtime.bigint() - startNs) / 1e6;
88
- logger.info(() => ({
89
- msg: "Response sent",
90
- data: {
91
- method: req.method,
92
- path: req.path,
93
- status: res.statusCode,
94
- durationMs,
95
- outcome,
96
- },
97
- }));
98
- };
99
- res.on("finish", () => emitFinish("finish"));
100
- res.on("close", () => emitFinish("close"));
101
-
102
- next();
103
- };
104
- }
@@ -1,196 +0,0 @@
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 type { ApiEndpoint, ApiRoutesProvider } from "@prosopo/api-route";
16
- import type { LogLevel } from "@prosopo/logger";
17
- import type { KeyringPair } from "@prosopo/types";
18
- import type {
19
- NextFunction,
20
- Request,
21
- RequestHandler,
22
- Response,
23
- Router,
24
- } from "express";
25
- import { assertType, describe, expectTypeOf, test } from "vitest";
26
- import type { ZodType } from "zod";
27
- import type * as barrel from "../index.js";
28
- import {
29
- type ApiExpressEndpointAdapter,
30
- type AuthMiddlewareOptions,
31
- apiExpressRouterFactory,
32
- authMiddleware,
33
- createApiExpressDefaultEndpointAdapter,
34
- handleErrors,
35
- requestLoggerMiddleware,
36
- verifySignature,
37
- } from "../index.js";
38
-
39
- /**
40
- * These pin the package's public type surface. Consumers wire routers and
41
- * middleware from this barrel alone, so a widened parameter or a dropped export
42
- * is a break they would only find at their own call sites.
43
- */
44
-
45
- describe("the barrel's shape", () => {
46
- test("exports exactly the intended names", () => {
47
- expectTypeOf<keyof typeof barrel>().toEqualTypeOf<
48
- | "apiExpressRouterFactory"
49
- | "createApiExpressDefaultEndpointAdapter"
50
- | "handleErrors"
51
- | "authMiddleware"
52
- | "createMemoryJwtReplayGuard"
53
- | "verifySignature"
54
- | "requestLoggerMiddleware"
55
- >();
56
- });
57
- });
58
-
59
- describe("createApiExpressDefaultEndpointAdapter", () => {
60
- test("takes a log level and an optional status code", () => {
61
- expectTypeOf(
62
- createApiExpressDefaultEndpointAdapter,
63
- ).parameters.toEqualTypeOf<
64
- [logLevel: LogLevel, errorStatusCode?: number]
65
- >();
66
- });
67
-
68
- test("returns the interface, not the concrete class", () => {
69
- // Callers depend on the interface so an alternative adapter can be
70
- // substituted without a cast.
71
- expectTypeOf(
72
- createApiExpressDefaultEndpointAdapter,
73
- ).returns.toEqualTypeOf<ApiExpressEndpointAdapter>();
74
- });
75
-
76
- test("rejects a log level that is not one of the known levels", () => {
77
- // @ts-expect-error - an arbitrary string is not a LogLevel
78
- createApiExpressDefaultEndpointAdapter("chatty");
79
- });
80
-
81
- test("rejects a non-numeric status code", () => {
82
- // @ts-expect-error - the status code is a number
83
- createApiExpressDefaultEndpointAdapter("info", "500");
84
- });
85
- });
86
-
87
- describe("the router factory", () => {
88
- test("takes a routes provider and an adapter, and returns a Router", () => {
89
- expectTypeOf(apiExpressRouterFactory.createRouter).toEqualTypeOf<
90
- (
91
- apiRoutesProvider: ApiRoutesProvider,
92
- apiExpressEndpointAdapter: ApiExpressEndpointAdapter,
93
- ) => Router
94
- >();
95
- });
96
-
97
- test("rejects a bare routes object in place of a provider", () => {
98
- const adapter: ApiExpressEndpointAdapter = {
99
- handleRequest: async () => undefined,
100
- };
101
- // @ts-expect-error - the factory wants a provider, not the routes it returns
102
- apiExpressRouterFactory.createRouter({}, adapter);
103
- });
104
- });
105
-
106
- describe("the endpoint adapter interface", () => {
107
- test("takes the endpoint and the express trio and resolves to void", () => {
108
- expectTypeOf<ApiExpressEndpointAdapter["handleRequest"]>().toEqualTypeOf<
109
- (
110
- endpoint: ApiEndpoint<ZodType | undefined>,
111
- request: Request,
112
- response: Response,
113
- next: NextFunction,
114
- ) => Promise<void>
115
- >();
116
- });
117
-
118
- test("is satisfied by any object with a matching handleRequest", () => {
119
- // Structural, deliberately: a consumer can supply its own adapter
120
- // without importing or extending the default class.
121
- assertType<ApiExpressEndpointAdapter>({
122
- handleRequest: async (): Promise<void> => undefined,
123
- });
124
- });
125
- });
126
-
127
- describe("the middleware", () => {
128
- test("authMiddleware takes an optional pair, auth account and options", () => {
129
- expectTypeOf(authMiddleware).parameters.toEqualTypeOf<
130
- [
131
- pair: KeyringPair | undefined,
132
- authAccount?: KeyringPair | undefined,
133
- options?: AuthMiddlewareOptions,
134
- ]
135
- >();
136
- });
137
-
138
- test("authMiddleware returns an async express handler", () => {
139
- expectTypeOf(authMiddleware).returns.toEqualTypeOf<
140
- (req: Request, res: Response, next: NextFunction) => Promise<void>
141
- >();
142
- });
143
-
144
- test("authMiddleware still requires the first argument to be passed", () => {
145
- // It is `KeyringPair | undefined` rather than optional, so a provider
146
- // with no key has to say so explicitly instead of forgetting to wire it.
147
- // @ts-expect-error - the first parameter is required
148
- authMiddleware();
149
- });
150
-
151
- test("requestLoggerMiddleware returns a synchronous express handler", () => {
152
- expectTypeOf(requestLoggerMiddleware).returns.toEqualTypeOf<
153
- (req: Request, res: Response, next: NextFunction) => void
154
- >();
155
- });
156
-
157
- test("the handler each factory builds is usable as express middleware", () => {
158
- // The exports are factories, so it is what they return — not the
159
- // factories themselves — that gets mounted with app.use().
160
- assertType<RequestHandler>(
161
- requestLoggerMiddleware(
162
- {} as Parameters<typeof requestLoggerMiddleware>[0],
163
- ),
164
- );
165
- assertType<RequestHandler>(authMiddleware(undefined));
166
- });
167
-
168
- test("verifySignature takes a signature, a message and a pair, and returns void", () => {
169
- expectTypeOf(verifySignature).parameters.toEqualTypeOf<
170
- [signature: string, message: string, pair: KeyringPair]
171
- >();
172
- expectTypeOf(verifySignature).returns.toEqualTypeOf<void>();
173
- });
174
-
175
- test("verifySignature rejects a byte-array signature", () => {
176
- // The hex string is decoded internally; passing bytes would be decoded
177
- // a second time and silently verify the wrong value.
178
- // @ts-expect-error - the signature is a hex string
179
- verifySignature(new Uint8Array([1]), "msg", {} as KeyringPair);
180
- });
181
- });
182
-
183
- describe("the error handler", () => {
184
- test("takes an error first, which is what makes express treat it as one", () => {
185
- // A three-parameter handler would be mounted as ordinary middleware and
186
- // never see an error at all.
187
- expectTypeOf(handleErrors).parameters.toMatchTypeOf<
188
- [unknown, Request, Response, NextFunction]
189
- >();
190
- });
191
-
192
- test("rejects being mounted as an ordinary handler", () => {
193
- // @ts-expect-error - the first parameter is an error, not a request
194
- assertType<RequestHandler>(handleErrors);
195
- });
196
- });