• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

boardgameio / boardgame.io / 28752139884

05 Jul 2026 07:24PM UTC coverage: 98.911% (-1.1%) from 100.0%
28752139884

Pull #1258

github

web-flow
Merge 52ed766ac into 999444878
Pull Request #1258: Issue 1088 leave game

1966 of 1995 branches covered (98.55%)

Branch coverage included in aggregate %.

190 of 214 new or added lines in 14 files covered. (88.79%)

2848 of 2872 relevant lines covered (99.16%)

26675.39 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.55
/src/server/api.ts
1
/*
2
 * Copyright 2018 The boardgame.io Authors
3
 *
4
 * Use of this source code is governed by a MIT-style
5
 * license that can be found in the LICENSE file or at
6
 * https://opensource.org/licenses/MIT.
7
 */
8

9
import type { CorsOptions } from 'cors';
10
import type Koa from 'koa';
11
import type Router from '@koa/router';
12
import koaBody from 'koa-body';
6✔
13
import { nanoid } from 'nanoid';
6✔
14
import cors from '@koa/cors';
6✔
15
import { createMatch, getFirstAvailablePlayerID, getNumPlayers } from './util';
6✔
16
import type { Auth } from './auth';
17
import type { Server, LobbyAPI, Game, StorageAPI } from '../types';
18
import { Master } from '../master/master';
6✔
19
import type { TransportAPI as MasterTransport } from '../master/master';
20

21
interface MatchQueue {
22
  add<T>(task: () => T | Promise<T>): Promise<T | Promise<T>>;
23
}
24

25
interface MatchTransport {
26
  createTransportAPI(matchID: string): MasterTransport;
27
  getMatchQueue(matchID: string): MatchQueue;
28
}
29

30
/**
31
 * Creates a new match.
32
 *
33
 * @param {object} db - The storage API.
34
 * @param {object} game - The game config object.
35
 * @param {number} numPlayers - The number of players.
36
 * @param {object} setupData - User-defined object that's available
37
 *                             during game setup.
38
 * @param {object } lobbyConfig - Configuration options for the lobby.
39
 * @param {boolean} unlisted - Whether the match should be excluded from public listing.
40
 */
41
const CreateMatch = async ({
6✔
42
  ctx,
43
  db,
44
  uuid,
45
  ...opts
46
}: {
47
  db: StorageAPI.Sync | StorageAPI.Async;
48
  ctx: Koa.BaseContext;
49
  uuid: () => string;
50
} & Parameters<typeof createMatch>[0]): Promise<string> => {
51
  const matchID = uuid();
52✔
52
  const match = createMatch(opts);
52✔
53

54
  if ('setupDataError' in match) {
52✔
55
    ctx.throw(400, match.setupDataError);
2✔
56
  } else {
57
    await db.createMatch(matchID, match);
50✔
58
    return matchID;
50✔
59
  }
60
};
61

62
/**
63
 * Create a metadata object without secret credentials to return to the client.
64
 *
65
 * @param {string} matchID - The identifier of the match the metadata belongs to.
66
 * @param {object} metadata - The match metadata object to strip credentials from.
67
 * @return - A metadata object without player credentials.
68
 */
69
const createClientMatchData = (
6✔
70
  matchID: string,
71
  metadata: Server.MatchData,
72
): LobbyAPI.Match => {
73
  return {
74✔
74
    ...metadata,
75
    matchID,
76
    players: Object.values(metadata.players).map((player) => {
77
      // strip away credentials
78
      const { credentials, ...strippedInfo } = player;
148✔
79
      return strippedInfo;
148✔
80
    }),
81
  };
82
};
83

84
/** Utility extracting `string` from a query if it is `string[]`. */
85
const unwrapQuery = (
6✔
86
  query: undefined | string | string[],
87
): string | undefined => (Array.isArray(query) ? query[0] : query);
102✔
88

89
export const configureRouter = ({
6✔
90
  router,
91
  db,
92
  auth,
93
  games,
94
  uuid = () => nanoid(11),
2✔
95
  apiBodyLimit = '5mb',
112✔
96
  transport,
97
}: {
98
  router: Router<any, Server.AppCtx>;
99
  auth: Auth;
100
  games: Game[];
101
  uuid?: () => string;
102
  db: StorageAPI.Sync | StorageAPI.Async;
103
  apiBodyLimit?: string | number;
104
  transport?: MatchTransport;
105
}) => {
106
  const bodyParser = koaBody({
238✔
107
    jsonLimit: apiBodyLimit,
108
    formLimit: apiBodyLimit,
109
  });
110
  const noopTransportAPI: MasterTransport = {
238✔
111
    send: () => {},
112
    sendAll: () => {},
113
  };
114

115
  const getLeaveBody = (ctx: Koa.Context) => {
238✔
116
    const playerID = (ctx.request.body as any)?.playerID;
42✔
117
    const credentials = (ctx.request.body as any)?.credentials;
42✔
118

119
    if (playerID === undefined || playerID === null) {
42✔
120
      ctx.throw(403, 'playerID is required');
4✔
121
    }
122
    if (typeof playerID !== 'string') {
38✔
123
      ctx.throw(403, `playerID must be a string, got ${typeof playerID}`);
6✔
124
    }
125
    if (credentials !== undefined && typeof credentials !== 'string') {
32!
NEW
126
      ctx.throw(403, `credentials must be a string, got ${typeof credentials}`);
×
127
    }
128

129
    return { playerID, credentials };
32✔
130
  };
131

132
  const fetchMetadataForGame = async (
238✔
133
    ctx: Koa.Context,
134
    matchID: string,
135
    gameName: string,
136
  ) => {
137
    const { metadata } = await (db as StorageAPI.Async).fetch(matchID, {
38✔
138
      metadata: true,
139
    });
140

141
    if (!metadata) {
38✔
142
      ctx.throw(404, 'Match ' + matchID + ' not found');
2✔
143
    }
144
    if (metadata.gameName !== gameName) {
36✔
145
      ctx.throw(404, 'Match ' + matchID + ' not found');
6✔
146
    }
147

148
    return metadata;
30✔
149
  };
150

151
  const authenticatePlayer = async (
238✔
152
    ctx: Koa.Context,
153
    metadata: Server.MatchData,
154
    playerID: string,
155
    credentials: string | undefined,
156
  ) => {
157
    if (!metadata.players[playerID]) {
30✔
158
      ctx.throw(404, 'Player ' + playerID + ' not found');
2✔
159
    }
160
    const isAuthorized = await auth.authenticateCredentials({
28✔
161
      playerID,
162
      credentials,
163
      metadata,
164
    });
165
    if (!isAuthorized) {
28✔
166
      ctx.throw(403, 'Invalid credentials ' + credentials);
4✔
167
    }
168
  };
169

170
  const clearPlayerSlot = async ({
238✔
171
    ctx,
172
    matchID,
173
    gameName,
174
    playerID,
175
    credentials,
176
  }: {
177
    ctx: Koa.Context;
178
    matchID: string;
179
    gameName: string;
180
    playerID: string;
181
    credentials: string | undefined;
182
  }) => {
183
    const metadata = await fetchMetadataForGame(ctx, matchID, gameName);
28✔
184
    await authenticatePlayer(ctx, metadata, playerID, credentials);
22✔
185

186
    delete metadata.players[playerID].name;
16✔
187
    delete metadata.players[playerID].credentials;
16✔
188
    const hasPlayers = Object.values(metadata.players).some(({ name }) => name);
32✔
189
    await (hasPlayers ? db.setMetadata(matchID, metadata) : db.wipe(matchID));
16✔
190
  };
191

192
  const clearPlayerSlotFromRequest = async (ctx: Koa.Context) => {
238✔
193
    const matchID = ctx.params.id;
30✔
194
    const gameName = ctx.params.name;
30✔
195
    const { playerID, credentials } = getLeaveBody(ctx);
30✔
196

197
    await clearPlayerSlot({
22✔
198
      ctx,
199
      matchID,
200
      gameName,
201
      playerID,
202
      credentials,
203
    });
204
    ctx.body = {};
12✔
205
  };
206
  /**
207
   * List available games.
208
   *
209
   * @return - Array of game names as string.
210
   */
211
  router.get('/games', async (ctx) => {
238✔
212
    const body: LobbyAPI.GameList = games.map((game) => game.name);
12✔
213
    ctx.body = body;
12✔
214
  });
215

216
  /**
217
   * Create a new match of a given game.
218
   *
219
   * @param {string} name - The name of the game of the new match.
220
   * @param {number} numPlayers - The number of players.
221
   * @param {object} setupData - User-defined object that's available
222
   *                             during game setup.
223
   * @param {boolean} unlisted - Whether the match should be excluded from public listing.
224
   * @return - The ID of the created match.
225
   */
226
  router.post('/games/:name/create', bodyParser, async (ctx) => {
238✔
227
    // The name of the game (for example: tic-tac-toe).
228
    const gameName = ctx.params.name;
56✔
229
    // User-data to pass to the game setup function.
230
    const setupData = (ctx.request.body as any)?.setupData;
56✔
231
    // Whether the game should be excluded from public listing.
232
    const unlisted = (ctx.request.body as any)?.unlisted;
56✔
233
    // The number of players for this game instance.
234
    const numPlayers = Number.parseInt((ctx.request.body as any)?.numPlayers);
56✔
235

236
    const game = games.find((g) => g.name === gameName);
68✔
237
    if (!game) ctx.throw(404, 'Game ' + gameName + ' not found');
56✔
238

239
    if (
54✔
240
      (ctx.request.body as any)?.numPlayers !== undefined &&
91✔
241
      (Number.isNaN(numPlayers) ||
242
        (game.minPlayers && numPlayers < game.minPlayers) ||
243
        (game.maxPlayers && numPlayers > game.maxPlayers))
244
    ) {
245
      ctx.throw(400, 'Invalid numPlayers');
6✔
246
    }
247

248
    const matchID = await CreateMatch({
48✔
249
      ctx,
250
      db,
251
      game,
252
      numPlayers,
253
      setupData,
254
      uuid,
255
      unlisted,
256
    });
257

258
    const body: LobbyAPI.CreatedMatch = { matchID };
46✔
259
    ctx.body = body;
46✔
260
  });
261

262
  /**
263
   * List matches for a given game.
264
   *
265
   * This does not return matches that are marked as unlisted.
266
   *
267
   * @param {string} name - The name of the game.
268
   * @return - Array of match objects.
269
   */
270
  router.get('/games/:name', async (ctx) => {
238✔
271
    const gameName = ctx.params.name;
34✔
272
    const isGameoverString = unwrapQuery(ctx.query.isGameover);
34✔
273
    const updatedBeforeString = unwrapQuery(ctx.query.updatedBefore);
34✔
274
    const updatedAfterString = unwrapQuery(ctx.query.updatedAfter);
34✔
275

276
    let isGameover: boolean | undefined;
277
    if (isGameoverString) {
34✔
278
      if (isGameoverString.toLowerCase() === 'true') {
8✔
279
        isGameover = true;
4✔
280
      } else if (isGameoverString.toLowerCase() === 'false') {
4✔
281
        isGameover = false;
2✔
282
      }
283
    }
284
    let updatedBefore: number | undefined;
285
    if (updatedBeforeString) {
34✔
286
      const parsedNumber = Number.parseInt(updatedBeforeString, 10);
6✔
287
      if (parsedNumber > 0) {
6✔
288
        updatedBefore = parsedNumber;
4✔
289
      }
290
    }
291
    let updatedAfter: number | undefined;
292
    if (updatedAfterString) {
34✔
293
      const parsedNumber = Number.parseInt(updatedAfterString, 10);
6✔
294
      if (parsedNumber > 0) {
6✔
295
        updatedAfter = parsedNumber;
4✔
296
      }
297
    }
298
    const matchList = await db.listMatches({
34✔
299
      gameName,
300
      where: {
301
        isGameover,
302
        updatedAfter,
303
        updatedBefore,
304
      },
305
    });
306
    const matches = [];
34✔
307
    for (const matchID of matchList) {
34✔
308
      const { metadata } = await (db as StorageAPI.Async).fetch(matchID, {
102✔
309
        metadata: true,
310
      });
311
      if (!metadata.unlisted) {
102✔
312
        matches.push(createClientMatchData(matchID, metadata));
68✔
313
      }
314
    }
315
    const body: LobbyAPI.MatchList = { matches };
34✔
316
    ctx.body = body;
34✔
317
  });
318

319
  /**
320
   * Get data about a specific match.
321
   *
322
   * @param {string} name - The name of the game.
323
   * @param {string} id - The ID of the match.
324
   * @return - A match object.
325
   */
326
  router.get('/games/:name/:id', async (ctx) => {
238✔
327
    const matchID = ctx.params.id;
8✔
328
    const { metadata } = await (db as StorageAPI.Async).fetch(matchID, {
8✔
329
      metadata: true,
330
    });
331
    if (!metadata) {
8✔
332
      ctx.throw(404, 'Match ' + matchID + ' not found');
2✔
333
    }
334
    const body: LobbyAPI.Match = createClientMatchData(matchID, metadata);
6✔
335
    ctx.body = body;
6✔
336
  });
337

338
  /**
339
   * Join a given match.
340
   *
341
   * @param {string} name - The name of the game.
342
   * @param {string} id - The ID of the match.
343
   * @param {string} playerID - The ID of the player who joins. If not sent, will be assigned to the first index available.
344
   * @param {string} playerName - The name of the player who joins.
345
   * @param {object} data - The default data of the player in the match.
346
   * @return - Player ID and credentials to use when interacting in the joined match.
347
   */
348
  router.post('/games/:name/:id/join', bodyParser, async (ctx) => {
238✔
349
    let playerID = (ctx.request.body as any)?.playerID;
30✔
350
    const playerName = (ctx.request.body as any)?.playerName;
30✔
351
    const data = (ctx.request.body as any)?.data;
30✔
352
    const matchID = ctx.params.id;
30✔
353
    if (!playerName) {
30✔
354
      ctx.throw(403, 'playerName is required');
2✔
355
    }
356

357
    const { metadata } = await (db as StorageAPI.Async).fetch(matchID, {
28✔
358
      metadata: true,
359
    });
360
    if (!metadata) {
28✔
361
      ctx.throw(404, 'Match ' + matchID + ' not found');
2✔
362
    }
363

364
    if (playerID === undefined || playerID === null) {
26✔
365
      playerID = getFirstAvailablePlayerID(metadata.players);
12✔
366
      if (playerID === undefined) {
12✔
367
        const numPlayers = getNumPlayers(metadata.players);
2✔
368
        ctx.throw(
2✔
369
          409,
370
          `Match ${matchID} reached maximum number of players (${numPlayers})`,
371
        );
372
      }
373
    }
374

375
    if (!metadata.players[playerID]) {
24✔
376
      ctx.throw(404, 'Player ' + playerID + ' not found');
2✔
377
    }
378
    if (metadata.players[playerID].name) {
22✔
379
      ctx.throw(409, 'Player ' + playerID + ' not available');
2✔
380
    }
381

382
    if (data) {
20✔
383
      metadata.players[playerID].data = data;
2✔
384
    }
385
    metadata.players[playerID].name = playerName;
20✔
386
    const playerCredentials = await auth.generateCredentials(ctx);
20✔
387
    metadata.players[playerID].credentials = playerCredentials;
20✔
388

389
    await db.setMetadata(matchID, metadata);
20✔
390

391
    const body: LobbyAPI.JoinedMatch = { playerID, playerCredentials };
20✔
392
    ctx.body = body;
20✔
393
  });
394

395
  /**
396
   * Leave a given match.
397
   *
398
   * @param {string} name - The name of the game.
399
   * @param {string} id - The ID of the match.
400
   * @param {string} playerID - The ID of the player who leaves.
401
   * @param {string} credentials - The credentials of the player who leaves.
402
   * @return - Nothing.
403
   */
404
  router.post('/games/:name/:id/leave', bodyParser, async (ctx) => {
238✔
405
    console.warn(
24✔
406
      'This endpoint /leave is deprecated. Please use /leaveSlot instead.',
407
    );
408
    await clearPlayerSlotFromRequest(ctx);
24✔
409
  });
410

411
  router.post(
238✔
412
    '/games/:name/:id/leaveSlot',
413
    bodyParser,
414
    clearPlayerSlotFromRequest,
415
  );
416

417
  /**
418
   * Permanently leave a game through the normal game-state lifecycle.
419
   *
420
   * @param {string} name - The name of the game.
421
   * @param {string} id - The ID of the match.
422
   * @param {string} playerID - The ID of the player who leaves.
423
   * @param {string} credentials - The credentials of the player who leaves.
424
   * @return - Nothing.
425
   */
426
  router.post('/games/:name/:id/leaveGame', bodyParser, async (ctx) => {
238✔
427
    const gameName = ctx.params.name;
12✔
428
    const matchID = ctx.params.id;
12✔
429
    const { playerID, credentials } = getLeaveBody(ctx);
12✔
430
    const game = games.find((g) => g.name === gameName);
10✔
431

432
    if (!game) ctx.throw(404, 'Game ' + gameName + ' not found');
10!
433

434
    const metadata = await fetchMetadataForGame(ctx, matchID, gameName);
10✔
435
    await authenticatePlayer(ctx, metadata, playerID, credentials);
8✔
436

437
    const master = new Master(
8✔
438
      game,
439
      db,
440
      transport ? transport.createTransportAPI(matchID) : noopTransportAPI,
4!
441
      auth,
442
    );
443
    const leaveGame = () =>
8✔
444
      master.onPlayerLeave(matchID, playerID, credentials);
8✔
445
    const result = transport
8!
446
      ? await transport.getMatchQueue(matchID).add(leaveGame)
447
      : await leaveGame();
448

449
    if (result && result.error) {
8✔
450
      ctx.throw(result.error === 'unauthorized' ? 403 : 400, result.error);
2!
451
    }
452

453
    await clearPlayerSlot({
6✔
454
      ctx,
455
      matchID,
456
      gameName,
457
      playerID,
458
      credentials,
459
    });
460
    ctx.body = {};
4✔
461
  });
462

463
  /**
464
   * Start a new match based on another existing match.
465
   *
466
   * @param {string} name - The name of the game.
467
   * @param {string} id - The ID of the match.
468
   * @param {string} playerID - The ID of the player creating the match.
469
   * @param {string} credentials - The credentials of the player creating the match.
470
   * @param {boolean} unlisted - Whether the match should be excluded from public listing.
471
   * @return - The ID of the new match.
472
   */
473
  router.post('/games/:name/:id/playAgain', bodyParser, async (ctx) => {
238✔
474
    const gameName = ctx.params.name;
14✔
475
    const matchID = ctx.params.id;
14✔
476
    const playerID = (ctx.request.body as any)?.playerID;
14✔
477
    const credentials = (ctx.request.body as any)?.credentials;
14✔
478
    const unlisted = (ctx.request.body as any)?.unlisted;
14✔
479
    const { metadata } = await (db as StorageAPI.Async).fetch(matchID, {
14✔
480
      metadata: true,
481
    });
482

483
    if (playerID === undefined || playerID === null) {
14✔
484
      ctx.throw(403, 'playerID is required');
2✔
485
    }
486

487
    if (!metadata) {
12✔
488
      ctx.throw(404, 'Match ' + matchID + ' not found');
2✔
489
    }
490
    if (!metadata.players[playerID]) {
10✔
491
      ctx.throw(404, 'Player ' + playerID + ' not found');
2✔
492
    }
493
    const isAuthorized = await auth.authenticateCredentials({
8✔
494
      playerID,
495
      credentials,
496
      metadata,
497
    });
498
    if (!isAuthorized) {
8✔
499
      ctx.throw(403, 'Invalid credentials ' + credentials);
2✔
500
    }
501

502
    // Check if nextMatch is already set, if so, return that id.
503
    if (metadata.nextMatchID) {
6✔
504
      ctx.body = { nextMatchID: metadata.nextMatchID };
2✔
505
      return;
2✔
506
    }
507

508
    // User-data to pass to the game setup function.
509
    const setupData =
510
      (ctx.request.body as any)?.setupData || metadata.setupData;
4✔
511
    // The number of players for this game instance.
512
    const numPlayers =
513
      Number.parseInt((ctx.request.body as any)?.numPlayers) ||
4✔
514
      // eslint-disable-next-line unicorn/explicit-length-check
515
      Object.keys(metadata.players).length;
516

517
    const game = games.find((g) => g.name === gameName);
4✔
518
    const nextMatchID = await CreateMatch({
4✔
519
      ctx,
520
      db,
521
      game,
522
      numPlayers,
523
      setupData,
524
      uuid,
525
      unlisted,
526
    });
527
    metadata.nextMatchID = nextMatchID;
4✔
528

529
    await db.setMetadata(matchID, metadata);
4✔
530

531
    const body: LobbyAPI.NextMatch = { nextMatchID };
4✔
532
    ctx.body = body;
4✔
533
  });
534

535
  const updatePlayerMetadata = async (ctx: Koa.Context) => {
238✔
536
    const matchID = ctx.params.id;
50✔
537
    const playerID = (ctx.request.body as any)?.playerID;
50✔
538
    const credentials = (ctx.request.body as any)?.credentials;
50✔
539
    const newName = (ctx.request.body as any)?.newName;
50✔
540
    const data = (ctx.request.body as any)?.data;
50✔
541
    const { metadata } = await (db as StorageAPI.Async).fetch(matchID, {
50✔
542
      metadata: true,
543
    });
544
    if (playerID === undefined) {
50✔
545
      ctx.throw(403, 'playerID is required');
6✔
546
    }
547
    if (data === undefined && !newName) {
44✔
548
      ctx.throw(403, 'newName or data is required');
6✔
549
    }
550
    if (newName && typeof newName !== 'string') {
38✔
551
      ctx.throw(403, `newName must be a string, got ${typeof newName}`);
4✔
552
    }
553
    if (!metadata) {
34✔
554
      ctx.throw(404, 'Match ' + matchID + ' not found');
6✔
555
    }
556
    if (!metadata.players[playerID]) {
28✔
557
      ctx.throw(404, 'Player ' + playerID + ' not found');
6✔
558
    }
559
    const isAuthorized = await auth.authenticateCredentials({
22✔
560
      playerID,
561
      credentials,
562
      metadata,
563
    });
564
    if (!isAuthorized) {
22✔
565
      ctx.throw(403, 'Invalid credentials ' + credentials);
6✔
566
    }
567

568
    if (newName) {
16✔
569
      metadata.players[playerID].name = newName;
12✔
570
    }
571
    if (data) {
16✔
572
      metadata.players[playerID].data = data;
4✔
573
    }
574
    await db.setMetadata(matchID, metadata);
16✔
575
    ctx.body = {};
16✔
576
  };
577

578
  /**
579
   * Change the name of a player in a given match.
580
   *
581
   * @param {string} name - The name of the game.
582
   * @param {string} id - The ID of the match.
583
   * @param {string} playerID - The ID of the player.
584
   * @param {string} credentials - The credentials of the player.
585
   * @param {object} newName - The new name of the player in the match.
586
   * @return - Nothing.
587
   */
588
  router.post('/games/:name/:id/rename', bodyParser, async (ctx) => {
238✔
589
    console.warn(
18✔
590
      'This endpoint /rename is deprecated. Please use /update instead.',
591
    );
592
    await updatePlayerMetadata(ctx);
18✔
593
  });
594

595
  /**
596
   * Update the player's data for a given match.
597
   *
598
   * @param {string} name - The name of the game.
599
   * @param {string} id - The ID of the match.
600
   * @param {string} playerID - The ID of the player.
601
   * @param {string} credentials - The credentials of the player.
602
   * @param {object} newName - The new name of the player in the match.
603
   * @param {object} data - The new data of the player in the match.
604
   * @return - Nothing.
605
   */
606
  router.post('/games/:name/:id/update', bodyParser, updatePlayerMetadata);
238✔
607

608
  return router;
238✔
609
};
610

611
export const configureApp = (
6✔
612
  app: Server.App,
613
  router: Router<any, Server.AppCtx>,
614
  origins: CorsOptions['origin'],
615
): void => {
616
  app.use(
238✔
617
    cors({
618
      // Set Access-Control-Allow-Origin header for allowed origins.
619
      origin: (ctx) => {
620
        const origin = ctx.get('Origin');
250✔
621
        return isOriginAllowed(origin, origins) ? origin : '';
250✔
622
      },
623
    }),
624
  );
625

626
  // If API_SECRET is set, then require that requests set an
627
  // api-secret header that is set to the same value.
628
  app.use(async (ctx, next) => {
238✔
629
    if (
250✔
630
      !!process.env.API_SECRET &&
127✔
631
      ctx.request.headers['api-secret'] !== process.env.API_SECRET
632
    ) {
633
      ctx.throw(403, 'Invalid API secret');
2✔
634
    }
635

636
    await next();
248✔
637
  });
638

639
  app.use(router.routes()).use(router.allowedMethods());
238✔
640
};
641

642
/**
643
 * Check if a request’s origin header is allowed for CORS.
644
 * Adapted from `cors` package: https://github.com/expressjs/cors
645
 * @param origin Request origin to test.
646
 * @param allowedOrigin Origin(s) that are allowed to connect via CORS.
647
 * @returns `true` if the origin matched at least one of the allowed origins.
648
 */
649
function isOriginAllowed(
650
  origin: string,
651
  allowedOrigin: CorsOptions['origin'],
652
): boolean {
653
  if (Array.isArray(allowedOrigin)) {
256✔
654
    for (const entry of allowedOrigin) {
4✔
655
      if (isOriginAllowed(origin, entry)) {
6✔
656
        return true;
2✔
657
      }
658
    }
659
    return false;
2✔
660
  } else if (typeof allowedOrigin === 'string') {
252✔
661
    return origin === allowedOrigin;
6✔
662
  } else if (allowedOrigin instanceof RegExp) {
246✔
663
    return allowedOrigin.test(origin);
4✔
664
  } else {
665
    return !!allowedOrigin;
242✔
666
  }
667
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc