@zimic/interceptor 0.17.0-canary.2 → 0.17.0-canary.4

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 (60) hide show
  1. package/dist/{chunk-TYHJPU5G.js → chunk-MXHLBRPB.js} +521 -61
  2. package/dist/chunk-MXHLBRPB.js.map +1 -0
  3. package/dist/{chunk-3SKHNQLL.mjs → chunk-OGL76CKO.mjs} +510 -60
  4. package/dist/chunk-OGL76CKO.mjs.map +1 -0
  5. package/dist/cli.js +141 -17
  6. package/dist/cli.js.map +1 -1
  7. package/dist/cli.mjs +137 -13
  8. package/dist/cli.mjs.map +1 -1
  9. package/dist/http.d.ts +23 -2
  10. package/dist/http.js +473 -270
  11. package/dist/http.js.map +1 -1
  12. package/dist/http.mjs +473 -270
  13. package/dist/http.mjs.map +1 -1
  14. package/dist/scripts/postinstall.js +6 -6
  15. package/dist/scripts/postinstall.js.map +1 -1
  16. package/dist/scripts/postinstall.mjs +5 -5
  17. package/dist/scripts/postinstall.mjs.map +1 -1
  18. package/dist/server.d.ts +16 -0
  19. package/dist/server.js +6 -6
  20. package/dist/server.mjs +1 -1
  21. package/package.json +12 -11
  22. package/src/cli/browser/init.ts +5 -6
  23. package/src/cli/cli.ts +140 -55
  24. package/src/cli/server/start.ts +22 -7
  25. package/src/cli/server/token/create.ts +33 -0
  26. package/src/cli/server/token/list.ts +23 -0
  27. package/src/cli/server/token/remove.ts +22 -0
  28. package/src/http/interceptor/HttpInterceptorClient.ts +49 -27
  29. package/src/http/interceptor/HttpInterceptorStore.ts +25 -9
  30. package/src/http/interceptor/LocalHttpInterceptor.ts +6 -3
  31. package/src/http/interceptor/RemoteHttpInterceptor.ts +41 -5
  32. package/src/http/interceptor/errors/RunningHttpInterceptorError.ts +1 -1
  33. package/src/http/interceptor/types/options.ts +15 -0
  34. package/src/http/interceptor/types/public.ts +11 -3
  35. package/src/http/interceptorWorker/HttpInterceptorWorker.ts +14 -16
  36. package/src/http/interceptorWorker/RemoteHttpInterceptorWorker.ts +17 -12
  37. package/src/http/interceptorWorker/types/options.ts +1 -0
  38. package/src/http/requestHandler/errors/TimesCheckError.ts +1 -1
  39. package/src/server/InterceptorServer.ts +52 -8
  40. package/src/server/constants.ts +1 -1
  41. package/src/server/errors/InvalidInterceptorTokenError.ts +13 -0
  42. package/src/server/errors/InvalidInterceptorTokenFileError.ts +13 -0
  43. package/src/server/errors/InvalidInterceptorTokenValueError.ts +13 -0
  44. package/src/server/types/options.ts +9 -0
  45. package/src/server/types/public.ts +9 -0
  46. package/src/server/types/schema.ts +4 -4
  47. package/src/server/utils/auth.ts +304 -0
  48. package/src/server/utils/fetch.ts +3 -1
  49. package/src/utils/data.ts +13 -0
  50. package/src/utils/files.ts +14 -0
  51. package/src/utils/{console.ts → logging.ts} +5 -7
  52. package/src/utils/webSocket.ts +57 -11
  53. package/src/webSocket/WebSocketClient.ts +11 -5
  54. package/src/webSocket/WebSocketHandler.ts +72 -51
  55. package/src/webSocket/WebSocketServer.ts +25 -4
  56. package/src/webSocket/constants.ts +2 -0
  57. package/src/webSocket/errors/UnauthorizedWebSocketConnectionError.ts +11 -0
  58. package/src/webSocket/types.ts +49 -52
  59. package/dist/chunk-3SKHNQLL.mjs.map +0 -1
  60. package/dist/chunk-TYHJPU5G.js.map +0 -1
@@ -10,8 +10,8 @@ import { removeArrayIndex } from '@/utils/arrays';
10
10
  import { deserializeResponse, SerializedHttpRequest, serializeRequest } from '@/utils/fetch';
11
11
  import { getHttpServerPort, startHttpServer, stopHttpServer } from '@/utils/http';
12
12
  import { WebSocketMessageAbortError } from '@/utils/webSocket';
13
- import { WebSocket } from '@/webSocket/types';
14
- import WebSocketServer from '@/webSocket/WebSocketServer';
13
+ import { WebSocketEventMessage } from '@/webSocket/types';
14
+ import WebSocketServer, { WebSocketServerAuthenticate } from '@/webSocket/WebSocketServer';
15
15
 
16
16
  import {
17
17
  DEFAULT_ACCESS_CONTROL_HEADERS,
@@ -24,6 +24,7 @@ import RunningInterceptorServerError from './errors/RunningInterceptorServerErro
24
24
  import { InterceptorServerOptions } from './types/options';
25
25
  import { InterceptorServer as PublicInterceptorServer } from './types/public';
26
26
  import { HttpHandlerCommit, InterceptorServerWebSocketSchema } from './types/schema';
27
+ import { validateInterceptorToken } from './utils/auth';
27
28
  import { getFetchAPI } from './utils/fetch';
28
29
 
29
30
  interface HttpHandler {
@@ -42,6 +43,7 @@ class InterceptorServer implements PublicInterceptorServer {
42
43
  _hostname: string;
43
44
  _port: number | undefined;
44
45
  logUnhandledRequests: boolean;
46
+ tokensDirectory?: string;
45
47
 
46
48
  private httpHandlerGroups: {
47
49
  [Method in HttpMethod]: HttpHandler[];
@@ -61,6 +63,7 @@ class InterceptorServer implements PublicInterceptorServer {
61
63
  this._hostname = options.hostname ?? DEFAULT_HOSTNAME;
62
64
  this._port = options.port;
63
65
  this.logUnhandledRequests = options.logUnhandledRequests ?? DEFAULT_LOG_UNHANDLED_REQUESTS;
66
+ this.tokensDirectory = options.tokensDirectory;
64
67
  }
65
68
 
66
69
  get hostname() {
@@ -120,10 +123,48 @@ class InterceptorServer implements PublicInterceptorServer {
120
123
 
121
124
  this.webSocketServer = new WebSocketServer({
122
125
  httpServer: this.httpServer,
126
+ authenticate: this.authenticateWebSocketConnection,
123
127
  });
128
+
124
129
  this.startWebSocketServer();
125
130
  }
126
131
 
132
+ private authenticateWebSocketConnection: WebSocketServerAuthenticate = async (_socket, request) => {
133
+ if (!this.tokensDirectory) {
134
+ return { isValid: true };
135
+ }
136
+
137
+ const tokenValue = this.getWebSocketRequestTokenValue(request);
138
+
139
+ if (!tokenValue) {
140
+ return { isValid: false, message: 'An interceptor token is required, but none was provided.' };
141
+ }
142
+
143
+ try {
144
+ await validateInterceptorToken(tokenValue, { tokensDirectory: this.tokensDirectory });
145
+ return { isValid: true };
146
+ } catch (error) {
147
+ console.error(error);
148
+ return { isValid: false, message: 'The interceptor token is not valid.' };
149
+ }
150
+ };
151
+
152
+ private getWebSocketRequestTokenValue(request: IncomingMessage) {
153
+ const protocols = request.headers['sec-websocket-protocol'] ?? '';
154
+ const parametersAsString = decodeURIComponent(protocols).split(', ');
155
+
156
+ for (const parameterAsString of parametersAsString) {
157
+ const tokenValueMatch = /^token=(?<tokenValue>.+?)$/.exec(parameterAsString);
158
+ const tokenValue = tokenValueMatch?.groups?.tokenValue;
159
+
160
+ if (tokenValue) {
161
+ return tokenValue;
162
+ }
163
+ }
164
+
165
+ return undefined;
166
+ }
167
+
127
168
  private async startHttpServer() {
128
169
  await startHttpServer(this.httpServerOrThrow, {
129
170
  hostname: this.hostname,
@@ -136,22 +177,25 @@ class InterceptorServer implements PublicInterceptorServer {
136
177
 
137
178
  private startWebSocketServer() {
138
179
  this.webSocketServerOrThrow.start();
139
- this.webSocketServerOrThrow.onEvent('interceptors/workers/use/commit', this.commitWorker);
140
- this.webSocketServerOrThrow.onEvent('interceptors/workers/use/reset', this.resetWorker);
180
+
181
+ this.webSocketServerOrThrow.onEvent('interceptors/workers/commit', this.commitWorker);
182
+ this.webSocketServerOrThrow.onEvent('interceptors/workers/reset', this.resetWorker);
141
183
  }
142
184
 
143
185
  private commitWorker = (
144
- message: WebSocket.ServiceEventMessage<InterceptorServerWebSocketSchema, 'interceptors/workers/use/commit'>,
186
+ message: WebSocketEventMessage<InterceptorServerWebSocketSchema, 'interceptors/workers/commit'>,
145
187
  socket: Socket,
146
188
  ) => {
147
189
  const commit = message.data;
190
+
148
191
  this.registerHttpHandler(commit, socket);
149
192
  this.registerWorkerSocketIfUnknown(socket);
193
+
150
194
  return {};
151
195
  };
152
196
 
153
197
  private resetWorker = (
154
- message: WebSocket.ServiceEventMessage<InterceptorServerWebSocketSchema, 'interceptors/workers/use/reset'>,
198
+ message: WebSocketEventMessage<InterceptorServerWebSocketSchema, 'interceptors/workers/reset'>,
155
199
  socket: Socket,
156
200
  ) => {
157
201
  this.removeHttpHandlersBySocket(socket);
@@ -226,8 +270,8 @@ class InterceptorServer implements PublicInterceptorServer {
226
270
  }
227
271
 
228
272
  private async stopWebSocketServer() {
229
- this.webSocketServerOrThrow.offEvent('interceptors/workers/use/commit', this.commitWorker);
230
- this.webSocketServerOrThrow.offEvent('interceptors/workers/use/reset', this.resetWorker);
273
+ this.webSocketServerOrThrow.offEvent('interceptors/workers/commit', this.commitWorker);
274
+ this.webSocketServerOrThrow.offEvent('interceptors/workers/reset', this.resetWorker);
231
275
 
232
276
  await this.webSocketServerOrThrow.stop();
233
277
 
@@ -22,7 +22,7 @@ export const DEFAULT_ACCESS_CONTROL_HEADERS = Object.freeze({
22
22
  'access-control-allow-methods': ALLOWED_ACCESS_CONTROL_HTTP_METHODS,
23
23
  'access-control-allow-headers': '*',
24
24
  'access-control-expose-headers': '*',
25
- 'access-control-max-age': process.env.SERVER_ACCESS_CONTROL_MAX_AGE,
25
+ 'access-control-max-age': process.env.INTERCEPTOR_SERVER_ACCESS_CONTROL_MAX_AGE,
26
26
  }) satisfies AccessControlHeaders;
27
27
 
28
28
  /** The default status code for the preflight request. */
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Error thrown when an interceptor token is invalid.
3
+ *
4
+ * @see {@link https://github.com/zimicjs/zimic/wiki/cli‐zimic‐interceptor‐server#authentication Interceptor server authentication}
5
+ */
6
+ class InvalidInterceptorTokenError extends Error {
7
+ constructor(tokenId: string) {
8
+ super(`Invalid interceptor token: ${tokenId}`);
9
+ this.name = 'InvalidInterceptorTokenError';
10
+ }
11
+ }
12
+
13
+ export default InvalidInterceptorTokenError;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Error thrown when an interceptor token file is invalid.
3
+ *
4
+ * @see {@link https://github.com/zimicjs/zimic/wiki/cli‐zimic‐interceptor‐server#authentication Interceptor server authentication}
5
+ */
6
+ class InvalidInterceptorTokenFileError extends Error {
7
+ constructor(tokenFilePath: string, validationErrorMessage: string) {
8
+ super(`Invalid interceptor token file ${tokenFilePath}: ${validationErrorMessage}`);
9
+ this.name = 'InvalidInterceptorTokenFileError';
10
+ }
11
+ }
12
+
13
+ export default InvalidInterceptorTokenFileError;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Error thrown when an interceptor token value is invalid.
3
+ *
4
+ * @see {@link https://github.com/zimicjs/zimic/wiki/cli‐zimic‐interceptor‐server#authentication Interceptor server authentication}
5
+ */
6
+ class InvalidInterceptorTokenValueError extends Error {
7
+ constructor(tokenValue: string) {
8
+ super(`Invalid interceptor token value: ${tokenValue}`);
9
+ this.name = 'InvalidInterceptorTokenValueError';
10
+ }
11
+ }
12
+
13
+ export default InvalidInterceptorTokenValueError;
@@ -21,4 +21,13 @@ export interface InterceptorServerOptions {
21
21
  * @default true
22
22
  */
23
23
  logUnhandledRequests?: boolean;
24
+
25
+ /**
26
+ * The directory where the authorized interceptor authentication tokens are saved. If provided, only remote
27
+ * interceptors bearing a valid token will be accepted. This option is essential if you are exposing your interceptor
28
+ * server publicly. For local development and testing, though, `--tokens-dir` is optional.
29
+ *
30
+ * @default undefined
31
+ */
32
+ tokensDirectory?: string;
24
33
  }
@@ -29,6 +29,15 @@ export interface InterceptorServer {
29
29
  */
30
30
  logUnhandledRequests: boolean;
31
31
 
32
+ /**
33
+ * The directory where the authorized interceptor authentication tokens are saved. If provided, only remote
34
+ * interceptors bearing a valid token will be accepted. This option is essential if you are exposing your interceptor
35
+ * server publicly. For local development and testing, though, `--tokens-dir` is optional.
36
+ *
37
+ * @default undefined
38
+ */
39
+ tokensDirectory?: string;
40
+
32
41
  /**
33
42
  * Whether the server is running.
34
43
  *
@@ -1,7 +1,7 @@
1
1
  import { HttpMethod } from '@zimic/http';
2
2
 
3
3
  import { SerializedHttpRequest, SerializedResponse } from '@/utils/fetch';
4
- import { WebSocket } from '@/webSocket/types';
4
+ import { WebSocketSchema } from '@/webSocket/types';
5
5
 
6
6
  export interface HttpHandlerCommit {
7
7
  id: string;
@@ -9,13 +9,13 @@ export interface HttpHandlerCommit {
9
9
  method: HttpMethod;
10
10
  }
11
11
 
12
- export type InterceptorServerWebSocketSchema = WebSocket.ServiceSchema<{
13
- 'interceptors/workers/use/commit': {
12
+ export type InterceptorServerWebSocketSchema = WebSocketSchema<{
13
+ 'interceptors/workers/commit': {
14
14
  event: HttpHandlerCommit;
15
15
  reply: {};
16
16
  };
17
17
 
18
- 'interceptors/workers/use/reset': {
18
+ 'interceptors/workers/reset': {
19
19
  event?: HttpHandlerCommit[];
20
20
  reply: {};
21
21
  };
@@ -0,0 +1,304 @@
1
+ import crypto from 'crypto';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import color from 'picocolors';
6
+ import util from 'util';
7
+ import { z } from 'zod';
8
+
9
+ import {
10
+ BASE64URL_REGEX,
11
+ convertHexLengthToBase64urlLength,
12
+ convertHexLengthToByteLength,
13
+ HEX_REGEX,
14
+ } from '@/utils/data';
15
+ import { pathExists } from '@/utils/files';
16
+ import { logger } from '@/utils/logging';
17
+
18
+ import InvalidInterceptorTokenError from '../errors/InvalidInterceptorTokenError';
19
+ import InvalidInterceptorTokenFileError from '../errors/InvalidInterceptorTokenFileError';
20
+ import InvalidInterceptorTokenValueError from '../errors/InvalidInterceptorTokenValueError';
21
+
22
+ export const DEFAULT_INTERCEPTOR_TOKENS_DIRECTORY = path.join(
23
+ '.zimic',
24
+ 'interceptor',
25
+ 'server',
26
+ `tokens${process.env.VITEST_POOL_ID}`,
27
+ );
28
+
29
+ export const INTERCEPTOR_TOKEN_ID_HEX_LENGTH = 32;
30
+
31
+ export const INTERCEPTOR_TOKEN_SECRET_HEX_LENGTH = 64;
32
+ export const INTERCEPTOR_TOKEN_VALUE_HEX_LENGTH = INTERCEPTOR_TOKEN_ID_HEX_LENGTH + INTERCEPTOR_TOKEN_SECRET_HEX_LENGTH;
33
+ export const INTERCEPTOR_TOKEN_VALUE_BASE64URL_LENGTH = convertHexLengthToBase64urlLength(
34
+ INTERCEPTOR_TOKEN_VALUE_HEX_LENGTH,
35
+ );
36
+
37
+ export const INTERCEPTOR_TOKEN_SALT_HEX_LENGTH = 64;
38
+ export const INTERCEPTOR_TOKEN_HASH_ITERATIONS = Number(process.env.INTERCEPTOR_TOKEN_HASH_ITERATIONS);
39
+ export const INTERCEPTOR_TOKEN_HASH_HEX_LENGTH = 128;
40
+ export const INTERCEPTOR_TOKEN_HASH_ALGORITHM = 'sha512';
41
+
42
+ const pbkdf2 = util.promisify(crypto.pbkdf2);
43
+
44
+ async function hashInterceptorToken(plainToken: string, salt: string) {
45
+ const hashBuffer = await pbkdf2(
46
+ plainToken,
47
+ salt,
48
+ INTERCEPTOR_TOKEN_HASH_ITERATIONS,
49
+ convertHexLengthToByteLength(INTERCEPTOR_TOKEN_HASH_HEX_LENGTH),
50
+ INTERCEPTOR_TOKEN_HASH_ALGORITHM,
51
+ );
52
+
53
+ const hash = hashBuffer.toString('hex');
54
+ return hash;
55
+ }
56
+
57
+ interface InterceptorTokenSecret {
58
+ hash: string;
59
+ salt: string;
60
+ value: string;
61
+ }
62
+
63
+ export interface InterceptorToken {
64
+ id: string;
65
+ name?: string;
66
+ secret: InterceptorTokenSecret;
67
+ value: string;
68
+ createdAt: Date;
69
+ }
70
+
71
+ export function createInterceptorTokenId() {
72
+ return crypto.randomUUID().replace(/[^a-z0-9]/g, '');
73
+ }
74
+
75
+ export function isValidInterceptorTokenId(tokenId: string) {
76
+ return tokenId.length === INTERCEPTOR_TOKEN_ID_HEX_LENGTH && HEX_REGEX.test(tokenId);
77
+ }
78
+
79
+ function isValidInterceptorTokenValue(tokenValue: string) {
80
+ return tokenValue.length === INTERCEPTOR_TOKEN_VALUE_BASE64URL_LENGTH && BASE64URL_REGEX.test(tokenValue);
81
+ }
82
+
83
+ export async function createInterceptorTokensDirectory(tokensDirectory: string) {
84
+ try {
85
+ const parentTokensDirectory = path.dirname(tokensDirectory);
86
+ await fs.promises.mkdir(parentTokensDirectory, { recursive: true });
87
+ await fs.promises.mkdir(tokensDirectory, { mode: 0o700, recursive: true });
88
+ await fs.promises.appendFile(path.join(tokensDirectory, '.gitignore'), `*${os.EOL}`, { encoding: 'utf-8' });
89
+ } catch (error) {
90
+ logger.error(
91
+ `${color.red(color.bold('✖'))} Failed to create the tokens directory: ${color.magenta(tokensDirectory)}`,
92
+ );
93
+ throw error;
94
+ }
95
+ }
96
+
97
+ const interceptorTokenFileContentSchema = z.object({
98
+ version: z.literal(1),
99
+ token: z.object({
100
+ id: z.string().length(INTERCEPTOR_TOKEN_ID_HEX_LENGTH).regex(HEX_REGEX),
101
+ name: z.string().optional(),
102
+ secret: z.object({
103
+ hash: z.string().length(INTERCEPTOR_TOKEN_HASH_HEX_LENGTH).regex(HEX_REGEX),
104
+ salt: z.string().length(INTERCEPTOR_TOKEN_SALT_HEX_LENGTH).regex(HEX_REGEX),
105
+ }),
106
+ createdAt: z
107
+ .string()
108
+ .datetime()
109
+ .transform((value) => new Date(value)),
110
+ }),
111
+ });
112
+
113
+ export type InterceptorTokenFileContent = z.infer<typeof interceptorTokenFileContentSchema>;
114
+
115
+ export namespace InterceptorTokenFileContent {
116
+ export type Input = z.input<typeof interceptorTokenFileContentSchema>;
117
+ }
118
+
119
+ export type PersistedInterceptorToken = InterceptorTokenFileContent['token'];
120
+
121
+ namespace PersistedInterceptorToken {
122
+ export type Input = InterceptorTokenFileContent.Input['token'];
123
+ }
124
+
125
+ export async function saveInterceptorTokenToFile(tokensDirectory: string, token: InterceptorToken) {
126
+ const tokeFilePath = path.join(tokensDirectory, token.id);
127
+
128
+ const persistedToken: PersistedInterceptorToken.Input = {
129
+ id: token.id,
130
+ name: token.name,
131
+ secret: {
132
+ hash: token.secret.hash,
133
+ salt: token.secret.salt,
134
+ },
135
+ createdAt: token.createdAt.toISOString(),
136
+ };
137
+
138
+ const tokenFileContent = interceptorTokenFileContentSchema.parse({
139
+ version: 1,
140
+ token: persistedToken,
141
+ } satisfies InterceptorTokenFileContent.Input);
142
+
143
+ await fs.promises.writeFile(tokeFilePath, JSON.stringify(tokenFileContent, null, 2), {
144
+ mode: 0o600,
145
+ encoding: 'utf-8',
146
+ });
147
+
148
+ return tokeFilePath;
149
+ }
150
+
151
+ export async function readInterceptorTokenFromFile(
152
+ tokenId: InterceptorToken['id'],
153
+ options: { tokensDirectory: string },
154
+ ): Promise<PersistedInterceptorToken | null> {
155
+ if (!isValidInterceptorTokenId(tokenId)) {
156
+ throw new InvalidInterceptorTokenError(tokenId);
157
+ }
158
+
159
+ const tokenFilePath = path.join(options.tokensDirectory, tokenId);
160
+ const tokenFileExists = await pathExists(tokenFilePath);
161
+
162
+ if (!tokenFileExists) {
163
+ return null;
164
+ }
165
+
166
+ const tokenFileContentAsString = await fs.promises.readFile(tokenFilePath, { encoding: 'utf-8' });
167
+
168
+ const validation = interceptorTokenFileContentSchema.safeParse(JSON.parse(tokenFileContentAsString) as unknown);
169
+
170
+ if (!validation.success) {
171
+ throw new InvalidInterceptorTokenFileError(tokenFilePath, validation.error.message);
172
+ }
173
+
174
+ return validation.data.token;
175
+ }
176
+
177
+ export async function createInterceptorToken(
178
+ options: { name?: string; tokensDirectory?: string } = {},
179
+ ): Promise<InterceptorToken> {
180
+ const { name, tokensDirectory = DEFAULT_INTERCEPTOR_TOKENS_DIRECTORY } = options;
181
+
182
+ const tokensDirectoryExists = await pathExists(tokensDirectory);
183
+
184
+ if (!tokensDirectoryExists) {
185
+ await createInterceptorTokensDirectory(tokensDirectory);
186
+ }
187
+
188
+ const tokenId = createInterceptorTokenId();
189
+
190
+ /* istanbul ignore if -- @preserve
191
+ * This should never happen, but let's check that the token identifier is valid after generated. */
192
+ if (!isValidInterceptorTokenId(tokenId)) {
193
+ throw new InvalidInterceptorTokenError(tokenId);
194
+ }
195
+
196
+ const tokenSecretSizeInBytes = convertHexLengthToByteLength(INTERCEPTOR_TOKEN_SECRET_HEX_LENGTH);
197
+ const tokenSecret = crypto.randomBytes(tokenSecretSizeInBytes).toString('hex');
198
+
199
+ const tokenSecretSaltSizeInBytes = convertHexLengthToByteLength(INTERCEPTOR_TOKEN_SALT_HEX_LENGTH);
200
+ const tokenSecretSalt = crypto.randomBytes(tokenSecretSaltSizeInBytes).toString('hex');
201
+ const tokenSecretHash = await hashInterceptorToken(tokenSecret, tokenSecretSalt);
202
+
203
+ const tokenValue = Buffer.from(`${tokenId}${tokenSecret}`, 'hex').toString('base64url');
204
+
205
+ /* istanbul ignore if -- @preserve
206
+ * This should never happen, but let's check that the token value is valid after generated. */
207
+ if (!isValidInterceptorTokenValue(tokenValue)) {
208
+ throw new InvalidInterceptorTokenValueError(tokenValue);
209
+ }
210
+
211
+ const token: InterceptorToken = {
212
+ id: tokenId,
213
+ name,
214
+ secret: {
215
+ hash: tokenSecretHash,
216
+ salt: tokenSecretSalt,
217
+ value: tokenSecret,
218
+ },
219
+ value: tokenValue,
220
+ createdAt: new Date(),
221
+ };
222
+
223
+ await saveInterceptorTokenToFile(tokensDirectory, token);
224
+
225
+ return token;
226
+ }
227
+
228
+ export async function listInterceptorTokens(options: { tokensDirectory?: string } = {}) {
229
+ const { tokensDirectory = DEFAULT_INTERCEPTOR_TOKENS_DIRECTORY } = options;
230
+
231
+ const tokensDirectoryExists = await pathExists(tokensDirectory);
232
+
233
+ if (!tokensDirectoryExists) {
234
+ return [];
235
+ }
236
+
237
+ const files = await fs.promises.readdir(tokensDirectory);
238
+
239
+ const tokenReadPromises = files.map(async (file) => {
240
+ if (!isValidInterceptorTokenId(file)) {
241
+ return null;
242
+ }
243
+
244
+ const tokenId = file;
245
+ const token = await readInterceptorTokenFromFile(tokenId, { tokensDirectory });
246
+ return token;
247
+ });
248
+
249
+ const tokenCandidates = await Promise.allSettled(tokenReadPromises);
250
+
251
+ const tokens: PersistedInterceptorToken[] = [];
252
+
253
+ for (const tokenCandidate of tokenCandidates) {
254
+ if (tokenCandidate.status === 'rejected') {
255
+ console.error(tokenCandidate.reason);
256
+ } else if (tokenCandidate.value !== null) {
257
+ tokens.push(tokenCandidate.value);
258
+ }
259
+ }
260
+
261
+ tokens.sort((token, otherToken) => token.createdAt.getTime() - otherToken.createdAt.getTime());
262
+
263
+ return tokens;
264
+ }
265
+
266
+ export async function validateInterceptorToken(tokenValue: string, options: { tokensDirectory: string }) {
267
+ if (!isValidInterceptorTokenValue(tokenValue)) {
268
+ throw new InvalidInterceptorTokenValueError(tokenValue);
269
+ }
270
+
271
+ const decodedTokenValue = Buffer.from(tokenValue, 'base64url').toString('hex');
272
+
273
+ const tokenId = decodedTokenValue.slice(0, INTERCEPTOR_TOKEN_ID_HEX_LENGTH);
274
+ const tokenSecret = decodedTokenValue.slice(
275
+ INTERCEPTOR_TOKEN_ID_HEX_LENGTH,
276
+ INTERCEPTOR_TOKEN_ID_HEX_LENGTH + INTERCEPTOR_TOKEN_VALUE_HEX_LENGTH,
277
+ );
278
+
279
+ const tokenFromFile = await readInterceptorTokenFromFile(tokenId, options);
280
+
281
+ if (!tokenFromFile) {
282
+ throw new InvalidInterceptorTokenValueError(tokenValue);
283
+ }
284
+
285
+ const tokenSecretHash = await hashInterceptorToken(tokenSecret, tokenFromFile.secret.salt);
286
+
287
+ if (tokenSecretHash !== tokenFromFile.secret.hash) {
288
+ throw new InvalidInterceptorTokenValueError(tokenValue);
289
+ }
290
+ }
291
+
292
+ export async function removeInterceptorToken(tokenId: string, options: { tokensDirectory?: string } = {}) {
293
+ const { tokensDirectory = DEFAULT_INTERCEPTOR_TOKENS_DIRECTORY } = options;
294
+
295
+ /* istanbul ignore if -- @preserve
296
+ * At this point, we should have a valid tokenId. This is just a sanity check. */
297
+ if (!isValidInterceptorTokenId(tokenId)) {
298
+ throw new InvalidInterceptorTokenError(tokenId);
299
+ }
300
+
301
+ const tokenFilePath = path.join(tokensDirectory, tokenId);
302
+
303
+ await fs.promises.rm(tokenFilePath, { force: true });
304
+ }
@@ -3,6 +3,8 @@ import { FetchAPI } from '@whatwg-node/server';
3
3
  import { importFile } from '@/utils/files';
4
4
 
5
5
  export async function getFetchAPI(): Promise<FetchAPI> {
6
+ const File = await importFile();
7
+
6
8
  return {
7
9
  fetch,
8
10
  Request,
@@ -17,7 +19,7 @@ export async function getFetchAPI(): Promise<FetchAPI> {
17
19
  TextDecoderStream,
18
20
  TextEncoderStream,
19
21
  Blob,
20
- File: await importFile(),
22
+ File,
21
23
  crypto: globalThis.crypto,
22
24
  btoa,
23
25
  TextEncoder,
package/src/utils/data.ts CHANGED
@@ -26,3 +26,16 @@ export function convertBase64ToArrayBuffer(base64Value: string) {
26
26
  return Buffer.from(base64Value, 'base64');
27
27
  }
28
28
  }
29
+
30
+ export const HEX_REGEX = /^[a-z0-9]+$/;
31
+
32
+ export function convertHexLengthToByteLength(hexLength: number) {
33
+ return Math.ceil(hexLength / 2); // 1 byte = 2 hex characters
34
+ }
35
+
36
+ export const BASE64URL_REGEX = /^[a-zA-Z0-9-_]+$/;
37
+
38
+ export function convertHexLengthToBase64urlLength(hexLength: number) {
39
+ const byteLength = convertHexLengthToByteLength(hexLength);
40
+ return Math.ceil((byteLength * 4) / 3); // 1 byte = 4/3 base64url characters
41
+ }
@@ -1,4 +1,5 @@
1
1
  import createCachedDynamicImport from '@zimic/utils/import/createCachedDynamicImport';
2
+ import type fs from 'fs';
2
3
 
3
4
  export const importFile = createCachedDynamicImport(
4
5
  /* istanbul ignore next -- @preserve
@@ -11,3 +12,16 @@ export function isGlobalFileAvailable() {
11
12
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
12
13
  return globalThis.File !== undefined;
13
14
  }
15
+
16
+ export const importFilesystem = createCachedDynamicImport<typeof fs>(() => import('fs'));
17
+
18
+ export async function pathExists(path: string) {
19
+ const fs = await importFilesystem();
20
+
21
+ try {
22
+ await fs.promises.access(path);
23
+ return true;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
@@ -1,10 +1,15 @@
1
1
  import { HttpFormData, HttpHeaders, HttpSearchParams } from '@zimic/http';
2
2
  import createCachedDynamicImport from '@zimic/utils/import/createCachedDynamicImport';
3
+ import Logger from '@zimic/utils/logging/Logger';
3
4
  import color from 'picocolors';
4
5
 
5
6
  import { isClientSide } from './environment';
6
7
  import { isGlobalFileAvailable } from './files';
7
8
 
9
+ export const logger = new Logger({
10
+ prefix: color.cyan('[@zimic/interceptor]'),
11
+ });
12
+
8
13
  function stringifyJSONToLog(value: unknown): string {
9
14
  return JSON.stringify(
10
15
  value,
@@ -79,10 +84,3 @@ export async function formatValueToLog(value: unknown, options: { colors?: boole
79
84
  sorted: true,
80
85
  });
81
86
  }
82
-
83
- export function logWithPrefix(messageOrMessages: unknown, options: { method?: 'log' | 'warn' | 'error' } = {}) {
84
- const { method = 'log' } = options;
85
-
86
- const messages = Array.isArray(messageOrMessages) ? messageOrMessages : [messageOrMessages];
87
- console[method](color.cyan('[@zimic/interceptor]'), ...messages);
88
- }