@20syldev/api 4.0.0 → 4.1.0

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 (45) hide show
  1. package/README.md +3 -3
  2. package/docs/changelog.md +333 -0
  3. package/package.json +5 -4
  4. package/src/app.ts +7 -3
  5. package/src/config/versions.ts +22 -10
  6. package/src/constants.ts +19 -0
  7. package/src/middleware/cors.ts +1 -1
  8. package/src/modules/v4/algorithms.ts +43 -1
  9. package/src/modules/v4/chat.ts +24 -1
  10. package/src/modules/v4/dice.ts +39 -0
  11. package/src/modules/v4/encode.ts +170 -0
  12. package/src/modules/v4/geo.ts +53 -0
  13. package/src/modules/v4/palette.ts +103 -0
  14. package/src/modules/v4/placeholder.ts +122 -0
  15. package/src/modules/v4/statistics.ts +55 -0
  16. package/src/modules/v4/text.ts +300 -0
  17. package/src/modules/v4/tic_tac_toe.ts +33 -1
  18. package/src/modules/v4/validate.ts +55 -0
  19. package/src/modules/v4.ts +2 -0
  20. package/src/routes/delete.ts +72 -0
  21. package/src/routes/get.ts +47 -0
  22. package/src/routes/index.ts +2 -2
  23. package/src/routes/patch.ts +45 -0
  24. package/src/utils/version.ts +5 -0
  25. package/tests/integration/api.test.ts +395 -0
  26. package/tests/tsconfig.json +3 -0
  27. package/tests/unit/algorithms.test.ts +120 -0
  28. package/tests/unit/color.test.ts +33 -0
  29. package/tests/unit/convert.test.ts +42 -0
  30. package/tests/unit/dice.test.ts +57 -0
  31. package/tests/unit/domain.test.ts +47 -0
  32. package/tests/unit/encode.test.ts +108 -0
  33. package/tests/unit/geo.test.ts +45 -0
  34. package/tests/unit/hash.test.ts +37 -0
  35. package/tests/unit/hyperplanning.test.ts +77 -0
  36. package/tests/unit/levenshtein.test.ts +34 -0
  37. package/tests/unit/palette.test.ts +53 -0
  38. package/tests/unit/personal.test.ts +65 -0
  39. package/tests/unit/statistics.test.ts +57 -0
  40. package/tests/unit/text.test.ts +107 -0
  41. package/tests/unit/time.test.ts +67 -0
  42. package/tests/unit/token.test.ts +61 -0
  43. package/tests/unit/username.test.ts +34 -0
  44. package/tests/unit/validate.test.ts +71 -0
  45. package/tsconfig.test.json +9 -0
@@ -0,0 +1,300 @@
1
+ import { MAX_STRING_LENGTH } from '../../constants.js';
2
+
3
+ const LOREM_WORDS = [
4
+ 'lorem',
5
+ 'ipsum',
6
+ 'dolor',
7
+ 'sit',
8
+ 'amet',
9
+ 'consectetur',
10
+ 'adipiscing',
11
+ 'elit',
12
+ 'sed',
13
+ 'do',
14
+ 'eiusmod',
15
+ 'tempor',
16
+ 'incididunt',
17
+ 'ut',
18
+ 'labore',
19
+ 'et',
20
+ 'dolore',
21
+ 'magna',
22
+ 'aliqua',
23
+ 'enim',
24
+ 'ad',
25
+ 'minim',
26
+ 'veniam',
27
+ 'quis',
28
+ 'nostrud',
29
+ 'exercitation',
30
+ 'ullamco',
31
+ 'laboris',
32
+ 'nisi',
33
+ 'aliquip',
34
+ 'ex',
35
+ 'ea',
36
+ 'commodo',
37
+ 'consequat',
38
+ 'duis',
39
+ 'aute',
40
+ 'irure',
41
+ 'in',
42
+ 'reprehenderit',
43
+ 'voluptate',
44
+ 'velit',
45
+ 'esse',
46
+ 'cillum',
47
+ 'eu',
48
+ 'fugiat',
49
+ 'nulla',
50
+ 'pariatur',
51
+ 'excepteur',
52
+ 'sint',
53
+ 'occaecat',
54
+ 'cupidatat',
55
+ 'non',
56
+ 'proident',
57
+ 'sunt',
58
+ 'culpa',
59
+ 'qui',
60
+ 'officia',
61
+ 'deserunt',
62
+ 'mollit',
63
+ 'anim',
64
+ 'id',
65
+ 'est',
66
+ 'laborum',
67
+ ];
68
+
69
+ const FR_UNITS = [
70
+ 'zéro',
71
+ 'un',
72
+ 'deux',
73
+ 'trois',
74
+ 'quatre',
75
+ 'cinq',
76
+ 'six',
77
+ 'sept',
78
+ 'huit',
79
+ 'neuf',
80
+ 'dix',
81
+ 'onze',
82
+ 'douze',
83
+ 'treize',
84
+ 'quatorze',
85
+ 'quinze',
86
+ 'seize',
87
+ 'dix-sept',
88
+ 'dix-huit',
89
+ 'dix-neuf',
90
+ ];
91
+ const FR_TENS = [
92
+ '',
93
+ '',
94
+ 'vingt',
95
+ 'trente',
96
+ 'quarante',
97
+ 'cinquante',
98
+ 'soixante',
99
+ 'soixante',
100
+ 'quatre-vingt',
101
+ 'quatre-vingt',
102
+ ];
103
+
104
+ const EN_UNITS = [
105
+ 'zero',
106
+ 'one',
107
+ 'two',
108
+ 'three',
109
+ 'four',
110
+ 'five',
111
+ 'six',
112
+ 'seven',
113
+ 'eight',
114
+ 'nine',
115
+ 'ten',
116
+ 'eleven',
117
+ 'twelve',
118
+ 'thirteen',
119
+ 'fourteen',
120
+ 'fifteen',
121
+ 'sixteen',
122
+ 'seventeen',
123
+ 'eighteen',
124
+ 'nineteen',
125
+ ];
126
+ const EN_TENS = ['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety'];
127
+
128
+ function checkText(value: string): void {
129
+ if (!value) throw new Error('A value is required');
130
+ if (typeof value !== 'string') throw new Error('Value must be a string');
131
+ if (value.length > MAX_STRING_LENGTH)
132
+ throw new Error(`Value must be less than ${MAX_STRING_LENGTH} characters long`);
133
+ }
134
+
135
+ export interface TextStats {
136
+ characters: number;
137
+ charactersNoSpaces: number;
138
+ words: number;
139
+ sentences: number;
140
+ paragraphs: number;
141
+ readingTime: string;
142
+ mostFrequentChar: string;
143
+ }
144
+
145
+ export function stats(value: string): TextStats {
146
+ checkText(value);
147
+
148
+ const characters = value.length;
149
+ const charactersNoSpaces = value.replace(/\s/g, '').length;
150
+ const words = value.trim().split(/\s+/).filter(Boolean).length;
151
+ const sentences = value.split(/[.!?]+/).filter((s) => s.trim().length > 0).length;
152
+ const paragraphs = value.split(/\n\s*\n/).filter((p) => p.trim().length > 0).length || 1;
153
+
154
+ const minutes = words / 200;
155
+ const readingTime = minutes < 1 ? `${Math.ceil(minutes * 60)}s` : `${Math.round(minutes)}min`;
156
+
157
+ const counts = new Map<string, number>();
158
+ for (const c of value.replace(/\s/g, '').toLowerCase()) counts.set(c, (counts.get(c) ?? 0) + 1);
159
+ let mostFrequentChar = '';
160
+ let maxCount = 0;
161
+ for (const [char, count] of counts) {
162
+ if (count > maxCount) {
163
+ mostFrequentChar = char;
164
+ maxCount = count;
165
+ }
166
+ }
167
+
168
+ return { characters, charactersNoSpaces, words, sentences, paragraphs, readingTime, mostFrequentChar };
169
+ }
170
+
171
+ export function slug(value: string): string {
172
+ checkText(value);
173
+ return value
174
+ .normalize('NFD')
175
+ .replace(/[\u0300-\u036f]/g, '')
176
+ .toLowerCase()
177
+ .replace(/[^a-z0-9\s-]/g, '')
178
+ .trim()
179
+ .replace(/[\s-]+/g, '-');
180
+ }
181
+
182
+ export function lorem(type: string, count: string): string {
183
+ const n = Number(count) || 5;
184
+ if (n < 1 || n > 500) throw new Error('Count must be between 1 and 500');
185
+
186
+ const randomWord = (): string => LOREM_WORDS[Math.floor(Math.random() * LOREM_WORDS.length)]!;
187
+ const randomSentence = (): string => {
188
+ const length = 5 + Math.floor(Math.random() * 10);
189
+ const words = Array.from({ length }, randomWord);
190
+ words[0] = words[0]!.charAt(0).toUpperCase() + words[0]!.slice(1);
191
+ return words.join(' ') + '.';
192
+ };
193
+ const randomParagraph = (): string => {
194
+ const length = 3 + Math.floor(Math.random() * 5);
195
+ return Array.from({ length }, randomSentence).join(' ');
196
+ };
197
+
198
+ switch (type) {
199
+ case 'words':
200
+ return Array.from({ length: n }, randomWord).join(' ');
201
+ case 'sentences':
202
+ return Array.from({ length: n }, randomSentence).join(' ');
203
+ case 'paragraphs':
204
+ return Array.from({ length: n }, randomParagraph).join('\n\n');
205
+ default:
206
+ throw new Error('Type must be one of: words, sentences, paragraphs');
207
+ }
208
+ }
209
+
210
+ function frBelowHundred(n: number): string {
211
+ if (n < 20) return FR_UNITS[n]!;
212
+ const t = Math.floor(n / 10);
213
+ const u = n % 10;
214
+ if (t === 7 || t === 9) {
215
+ const base = FR_TENS[t]!;
216
+ const sub = 10 + u;
217
+ return base + (base.endsWith('-') ? '' : '-') + FR_UNITS[sub];
218
+ }
219
+ if (t === 8 && u === 0) return 'quatre-vingts';
220
+ if (u === 0) return FR_TENS[t]!;
221
+ if (u === 1 && t < 8) return FR_TENS[t] + ' et un';
222
+ return FR_TENS[t] + '-' + FR_UNITS[u];
223
+ }
224
+
225
+ function frBelowThousand(n: number): string {
226
+ if (n < 100) return frBelowHundred(n);
227
+ const h = Math.floor(n / 100);
228
+ const r = n % 100;
229
+ let result = '';
230
+ if (h === 1) result = 'cent';
231
+ else result = FR_UNITS[h] + ' cent' + (r === 0 ? 's' : '');
232
+ if (r > 0) result += ' ' + frBelowHundred(r);
233
+ return result;
234
+ }
235
+
236
+ function numberToFrench(n: number): string {
237
+ if (n === 0) return 'zéro';
238
+ if (n < 0) return 'moins ' + numberToFrench(-n);
239
+ if (n < 1000) return frBelowThousand(n);
240
+ if (n < 1_000_000) {
241
+ const t = Math.floor(n / 1000);
242
+ const r = n % 1000;
243
+ const thousands = t === 1 ? 'mille' : frBelowThousand(t) + ' mille';
244
+ return r === 0 ? thousands : thousands + ' ' + frBelowThousand(r);
245
+ }
246
+ if (n < 1_000_000_000) {
247
+ const m = Math.floor(n / 1_000_000);
248
+ const r = n % 1_000_000;
249
+ const millions = m === 1 ? 'un million' : frBelowThousand(m) + ' millions';
250
+ return r === 0 ? millions : millions + ' ' + numberToFrench(r);
251
+ }
252
+ throw new Error('Number must be less than 1 billion');
253
+ }
254
+
255
+ function enBelowHundred(n: number): string {
256
+ if (n < 20) return EN_UNITS[n]!;
257
+ const t = Math.floor(n / 10);
258
+ const u = n % 10;
259
+ return u === 0 ? EN_TENS[t]! : EN_TENS[t] + '-' + EN_UNITS[u];
260
+ }
261
+
262
+ function enBelowThousand(n: number): string {
263
+ if (n < 100) return enBelowHundred(n);
264
+ const h = Math.floor(n / 100);
265
+ const r = n % 100;
266
+ return r === 0 ? EN_UNITS[h] + ' hundred' : EN_UNITS[h] + ' hundred ' + enBelowHundred(r);
267
+ }
268
+
269
+ function numberToEnglish(n: number): string {
270
+ if (n === 0) return 'zero';
271
+ if (n < 0) return 'minus ' + numberToEnglish(-n);
272
+ if (n < 1000) return enBelowThousand(n);
273
+ if (n < 1_000_000) {
274
+ const t = Math.floor(n / 1000);
275
+ const r = n % 1000;
276
+ return r === 0 ? enBelowThousand(t) + ' thousand' : enBelowThousand(t) + ' thousand ' + enBelowThousand(r);
277
+ }
278
+ if (n < 1_000_000_000) {
279
+ const m = Math.floor(n / 1_000_000);
280
+ const r = n % 1_000_000;
281
+ return r === 0 ? enBelowThousand(m) + ' million' : enBelowThousand(m) + ' million ' + numberToEnglish(r);
282
+ }
283
+ throw new Error('Number must be less than 1 billion');
284
+ }
285
+
286
+ export function number(value: string, lang: string): string {
287
+ const n = Number(value);
288
+ if (isNaN(n)) throw new Error('Value must be a number');
289
+ if (!Number.isInteger(n)) throw new Error('Value must be an integer');
290
+ if (Math.abs(n) >= 1_000_000_000) throw new Error('Number must be less than 1 billion');
291
+
292
+ switch (lang) {
293
+ case 'fr':
294
+ return numberToFrench(n);
295
+ case 'en':
296
+ return numberToEnglish(n);
297
+ default:
298
+ throw new Error('Lang must be one of: fr, en');
299
+ }
300
+ }
@@ -44,11 +44,43 @@ export default function tic_tac_toe(action: string, params: TicTacToeParams): Re
44
44
  return playMove(params, games, sessions, u, now);
45
45
  } else if (action === 'fetch') {
46
46
  return fetchGame(params, games, u);
47
+ } else if (action === 'forfeit') {
48
+ return forfeitGame(params, games, sessions);
47
49
  } else {
48
- throw new Error('Invalid action. Use "play", "fetch", or "list"');
50
+ throw new Error('Invalid action. Use "play", "fetch", "list", or "forfeit"');
49
51
  }
50
52
  }
51
53
 
54
+ function forfeitGame(
55
+ params: TicTacToeParams,
56
+ games: Record<string, TicTacToeGame>,
57
+ sessions: Record<string, { user: string; last: number }>,
58
+ ): Record<string, unknown> {
59
+ const { game, session } = params;
60
+
61
+ if (!game) throw new Error('Please provide a valid game ID');
62
+ if (!session) throw new Error('Please provide a valid session ID');
63
+ if (!games[game]) throw new Error('Game not found');
64
+
65
+ const u = params.username!.toLowerCase();
66
+ if (sessions[u] && sessions[u].user !== session) {
67
+ throw new Error('Session ID mismatch');
68
+ }
69
+
70
+ const players = games[game]!.players;
71
+ if (!players.includes(u)) throw new Error('You are not a player in this game');
72
+
73
+ const winner = players.find((p) => p !== u) ?? null;
74
+ delete games[game];
75
+ delete sessions[u];
76
+
77
+ return {
78
+ message: `${params.username} forfeited the game.${winner ? ` ${winner} wins.` : ''}`,
79
+ winner,
80
+ loser: params.username,
81
+ };
82
+ }
83
+
52
84
  function playMove(
53
85
  params: TicTacToeParams,
54
86
  games: Record<string, TicTacToeGame>,
@@ -0,0 +1,55 @@
1
+ import { MAX_STRING_LENGTH } from '../../constants.js';
2
+
3
+ function checkValue(value: string): void {
4
+ if (!value) throw new Error('A value is required');
5
+ if (typeof value !== 'string') throw new Error('Value must be a string');
6
+ if (value.length > MAX_STRING_LENGTH)
7
+ throw new Error(`Value must be less than ${MAX_STRING_LENGTH} characters long`);
8
+ }
9
+
10
+ export function luhn(value: string): { valid: boolean; value: string } {
11
+ checkValue(value);
12
+ const digits = value.replace(/\s|-/g, '');
13
+ if (!/^\d+$/.test(digits)) throw new Error('Value must contain only digits, spaces or dashes');
14
+ if (digits.length < 12 || digits.length > 19) throw new Error('Card number must have between 12 and 19 digits');
15
+
16
+ let sum = 0;
17
+ let alt = false;
18
+ for (let i = digits.length - 1; i >= 0; i--) {
19
+ let n = parseInt(digits[i]!, 10);
20
+ if (alt) {
21
+ n *= 2;
22
+ if (n > 9) n -= 9;
23
+ }
24
+ sum += n;
25
+ alt = !alt;
26
+ }
27
+
28
+ return { valid: sum % 10 === 0, value: digits };
29
+ }
30
+
31
+ export function iban(value: string): { valid: boolean; value: string; country?: string } {
32
+ checkValue(value);
33
+ const cleaned = value.replace(/\s/g, '').toUpperCase();
34
+ if (!/^[A-Z]{2}\d{2}[A-Z0-9]+$/.test(cleaned)) throw new Error('Invalid IBAN format');
35
+ if (cleaned.length < 15 || cleaned.length > 34) throw new Error('IBAN length must be between 15 and 34 characters');
36
+
37
+ const rearranged = cleaned.slice(4) + cleaned.slice(0, 4);
38
+ const numeric = rearranged
39
+ .split('')
40
+ .map((c) => (c >= 'A' && c <= 'Z' ? (c.charCodeAt(0) - 55).toString() : c))
41
+ .join('');
42
+
43
+ let remainder = 0;
44
+ for (const char of numeric) {
45
+ remainder = (remainder * 10 + parseInt(char, 10)) % 97;
46
+ }
47
+
48
+ return { valid: remainder === 1, value: cleaned, country: cleaned.slice(0, 2) };
49
+ }
50
+
51
+ export function email(value: string): { valid: boolean; value: string } {
52
+ checkValue(value);
53
+ const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
54
+ return { valid: regex.test(value), value };
55
+ }
package/src/modules/v4.ts CHANGED
@@ -3,12 +3,14 @@ export { default as chat } from './v4/chat.js';
3
3
  export { default as captcha } from './v4/captcha.js';
4
4
  export { default as color } from './v4/color.js';
5
5
  export { default as convert } from './v4/convert.js';
6
+ export { default as dice } from './v4/dice.js';
6
7
  export { default as domain } from './v4/domain.js';
7
8
  export { default as hash } from './v4/hash.js';
8
9
  export { default as hyperplanning } from './v4/hyperplanning.js';
9
10
  export { default as levenshtein } from './v4/levenshtein.js';
10
11
  export { default as personal } from './v4/personal.js';
11
12
  export { default as qrcode } from './v4/qrcode.js';
13
+ export { default as statistics } from './v4/statistics.js';
12
14
  export { default as tic_tac_toe } from './v4/tic_tac_toe.js';
13
15
  export { default as time } from './v4/time.js';
14
16
  export { default as token } from './v4/token.js';
@@ -0,0 +1,72 @@
1
+ import { Router, type Request, type Response } from 'express';
2
+ import { chatStorage, ticTacToeStorage } from '../storage/index.js';
3
+ import { error } from '../utils/response.js';
4
+ import { supportsRest } from '../utils/version.js';
5
+
6
+ const router = Router();
7
+
8
+ // Forfeit a tic-tac-toe game (REST: DELETE /tic-tac-toe/:game)
9
+ router.delete('/:version/tic-tac-toe/:game', (req: Request, res: Response) => {
10
+ if (!supportsRest(req)) {
11
+ error(res, 405, 'DELETE is only supported in v4+.', `${req.version}/tic-tac-toe`);
12
+ return;
13
+ }
14
+
15
+ const game = req.params.game as string;
16
+ const { username, session } = (req.body as Record<string, string>) || {};
17
+
18
+ if (!username) {
19
+ error(res, 400, 'Please provide a username (?username={username})');
20
+ return;
21
+ }
22
+ if (!session) {
23
+ error(res, 400, 'Please provide a valid session ID (&session={ID})');
24
+ return;
25
+ }
26
+
27
+ try {
28
+ const result = req.module.tic_tac_toe('forfeit', {
29
+ username,
30
+ session,
31
+ game,
32
+ storage: ticTacToeStorage,
33
+ });
34
+ res.jsonResponse(result);
35
+ } catch (err) {
36
+ error(res, 400, (err as Error).message);
37
+ }
38
+ });
39
+
40
+ // Clear a private chat (REST: DELETE /chat/:token)
41
+ router.delete('/:version/chat/:token', (req: Request, res: Response) => {
42
+ if (!supportsRest(req)) {
43
+ error(res, 405, 'DELETE is only supported in v4+.', `${req.version}/chat`);
44
+ return;
45
+ }
46
+
47
+ const token = req.params.token as string;
48
+ const { username, session } = (req.body as Record<string, string>) || {};
49
+
50
+ if (!username) {
51
+ error(res, 400, 'Please provide a username (?username={username})');
52
+ return;
53
+ }
54
+ if (!session) {
55
+ error(res, 400, 'Please provide a valid session ID (&session={ID})');
56
+ return;
57
+ }
58
+
59
+ try {
60
+ const result = req.module.chat('clear', {
61
+ username,
62
+ token,
63
+ session,
64
+ storage: chatStorage,
65
+ });
66
+ res.jsonResponse(result);
67
+ } catch (err) {
68
+ error(res, 400, (err as Error).message);
69
+ }
70
+ });
71
+
72
+ export default router;
package/src/routes/get.ts CHANGED
@@ -18,6 +18,7 @@ router.get('/:version', (req: Request, res: Response) => {
18
18
 
19
19
  const endpoints = Object.keys(versionConfig.endpoints).reduce<Record<string, unknown>>((acc, method) => {
20
20
  const endpointList = versionConfig.endpoints[method as keyof typeof versionConfig.endpoints];
21
+ if (!endpointList) return acc;
21
22
  acc[method] = endpointList
22
23
  .filter(({ name }: { name: string }) => name !== 'website')
23
24
  .sort((a: { name: string }, b: { name: string }) => a.name.localeCompare(b.name))
@@ -149,6 +150,29 @@ router.get('/:version/domain', (req: Request, res: Response) => {
149
150
  }
150
151
  });
151
152
 
153
+ // RPG Dice roller
154
+ router.get('/:version/dice', (req: Request, res: Response) => {
155
+ const { roll } = req.query;
156
+ const { version } = req.params;
157
+
158
+ const dice = (req.module as { dice?: (r: string) => unknown }).dice;
159
+ if (!dice) {
160
+ error(res, 404, `Endpoint not available in ${version}.`, `${version}/dice`);
161
+ return;
162
+ }
163
+ if (!roll) {
164
+ error(res, 400, 'Please provide a roll notation (?roll=2d6+3)', `${version}/dice`);
165
+ return;
166
+ }
167
+
168
+ try {
169
+ const result = dice(roll as string);
170
+ res.jsonResponse(result);
171
+ } catch (err) {
172
+ error(res, 400, (err as Error).message, `${req.version}/dice`);
173
+ }
174
+ });
175
+
152
176
  // GET planning error
153
177
  router.get('/:version/hyperplanning', (_req: Request, res: Response) => {
154
178
  error(res, 405, 'This endpoint only supports POST requests.');
@@ -222,6 +246,29 @@ router.get('/:version/qrcode', async (req: Request, res: Response) => {
222
246
  }
223
247
  });
224
248
 
249
+ // Statistics on a list of numbers
250
+ router.get('/:version/statistics', (req: Request, res: Response) => {
251
+ const { values } = req.query;
252
+ const { version } = req.params;
253
+
254
+ const statistics = (req.module as { statistics?: (v: string) => unknown }).statistics;
255
+ if (!statistics) {
256
+ error(res, 404, `Endpoint not available in ${version}.`, `${version}/statistics`);
257
+ return;
258
+ }
259
+ if (!values) {
260
+ error(res, 400, 'Please provide a list of values (?values=1,2,3)', `${version}/statistics`);
261
+ return;
262
+ }
263
+
264
+ try {
265
+ const result = statistics(values as string);
266
+ res.jsonResponse(result);
267
+ } catch (err) {
268
+ error(res, 400, (err as Error).message, `${req.version}/statistics`);
269
+ }
270
+ });
271
+
225
272
  // GET tic-tac-toe errors
226
273
  router.get('/:version/tic-tac-toe', (_req: Request, res: Response) => {
227
274
  error(res, 405, 'This endpoint only supports POST requests.');
@@ -2,7 +2,7 @@ import { Router, type Request, type Response } from 'express';
2
2
  import { versions } from '../config/versions.js';
3
3
  import { ipLimits } from '../storage/index.js';
4
4
  import { logger } from '../middleware/logger.js';
5
- import { DOCS_URL, START_TIME } from '../constants.js';
5
+ import { APP_VERSION, DOCS_URL, START_TIME } from '../constants.js';
6
6
 
7
7
  const router = Router();
8
8
 
@@ -28,7 +28,7 @@ router.get('/health', (_req: Request, res: Response) => {
28
28
  res.jsonResponse({
29
29
  status: 'ok',
30
30
  uptime: Math.floor((Date.now() - START_TIME) / 1000),
31
- version: '4.0.0',
31
+ version: APP_VERSION,
32
32
  node: process.version,
33
33
  memory: {
34
34
  rss: `${(mem.rss / 1024 / 1024).toFixed(1)} MB`,
@@ -0,0 +1,45 @@
1
+ import { Router, type Request, type Response } from 'express';
2
+ import { ticTacToeStorage } from '../storage/index.js';
3
+ import { error } from '../utils/response.js';
4
+ import { supportsRest } from '../utils/version.js';
5
+
6
+ const router = Router();
7
+
8
+ // Play a tic-tac-toe move (REST: PATCH /tic-tac-toe/:game)
9
+ router.patch('/:version/tic-tac-toe/:game', (req: Request, res: Response) => {
10
+ if (!supportsRest(req)) {
11
+ error(res, 405, 'PATCH is only supported in v4+.', `${req.version}/tic-tac-toe`);
12
+ return;
13
+ }
14
+
15
+ const game = req.params.game as string;
16
+ const { username, move, session } = (req.body as Record<string, string>) || {};
17
+
18
+ if (!username) {
19
+ error(res, 400, 'Please provide a username (?username={username})');
20
+ return;
21
+ }
22
+ if (!move) {
23
+ error(res, 400, 'Please provide a valid move (&move={move})');
24
+ return;
25
+ }
26
+ if (!session) {
27
+ error(res, 400, 'Please provide a valid session ID (&session={ID})');
28
+ return;
29
+ }
30
+
31
+ try {
32
+ const result = req.module.tic_tac_toe('play', {
33
+ username,
34
+ move,
35
+ session,
36
+ game,
37
+ storage: ticTacToeStorage,
38
+ });
39
+ res.jsonResponse(result);
40
+ } catch (err) {
41
+ error(res, 400, (err as Error).message);
42
+ }
43
+ });
44
+
45
+ export default router;
@@ -0,0 +1,5 @@
1
+ import type { Request } from 'express';
2
+
3
+ const REST_VERSIONS = new Set(['v4']);
4
+
5
+ export const supportsRest = (req: Request): boolean => REST_VERSIONS.has(req.version);