@ecosplay/e-brocante 1.0.2

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 (110) hide show
  1. package/AGENTS.md +141 -0
  2. package/CHANGELOG.md +76 -0
  3. package/LICENSE +64 -0
  4. package/README.md +246 -0
  5. package/dist/EBrocanteClient.d.ts +38 -0
  6. package/dist/EBrocanteClient.d.ts.map +1 -0
  7. package/dist/EBrocanteClient.js +99 -0
  8. package/dist/EBrocanteClient.js.map +1 -0
  9. package/dist/cache/CacheBackend.d.ts +20 -0
  10. package/dist/cache/CacheBackend.d.ts.map +1 -0
  11. package/dist/cache/CacheBackend.js +6 -0
  12. package/dist/cache/CacheBackend.js.map +1 -0
  13. package/dist/cache/LocalCache.d.ts +22 -0
  14. package/dist/cache/LocalCache.d.ts.map +1 -0
  15. package/dist/cache/LocalCache.js +53 -0
  16. package/dist/cache/LocalCache.js.map +1 -0
  17. package/dist/cache/NullCache.d.ts +17 -0
  18. package/dist/cache/NullCache.d.ts.map +1 -0
  19. package/dist/cache/NullCache.js +26 -0
  20. package/dist/cache/NullCache.js.map +1 -0
  21. package/dist/cache/RedisCache.d.ts +39 -0
  22. package/dist/cache/RedisCache.d.ts.map +1 -0
  23. package/dist/cache/RedisCache.js +104 -0
  24. package/dist/cache/RedisCache.js.map +1 -0
  25. package/dist/cache/RedisConfig.d.ts +17 -0
  26. package/dist/cache/RedisConfig.d.ts.map +1 -0
  27. package/dist/cache/RedisConfig.js +13 -0
  28. package/dist/cache/RedisConfig.js.map +1 -0
  29. package/dist/cache/stableHashKey.d.ts +11 -0
  30. package/dist/cache/stableHashKey.d.ts.map +1 -0
  31. package/dist/cache/stableHashKey.js +27 -0
  32. package/dist/cache/stableHashKey.js.map +1 -0
  33. package/dist/exceptions/index.d.ts +41 -0
  34. package/dist/exceptions/index.d.ts.map +1 -0
  35. package/dist/exceptions/index.js +62 -0
  36. package/dist/exceptions/index.js.map +1 -0
  37. package/dist/http/HttpClient.d.ts +41 -0
  38. package/dist/http/HttpClient.d.ts.map +1 -0
  39. package/dist/http/HttpClient.js +222 -0
  40. package/dist/http/HttpClient.js.map +1 -0
  41. package/dist/index.d.ts +22 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +17 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/internal/safeStrings.d.ts +52 -0
  46. package/dist/internal/safeStrings.d.ts.map +1 -0
  47. package/dist/internal/safeStrings.js +156 -0
  48. package/dist/internal/safeStrings.js.map +1 -0
  49. package/dist/logger/DebugWriter.d.ts +24 -0
  50. package/dist/logger/DebugWriter.d.ts.map +1 -0
  51. package/dist/logger/DebugWriter.js +62 -0
  52. package/dist/logger/DebugWriter.js.map +1 -0
  53. package/dist/logger/FileLogger.d.ts +26 -0
  54. package/dist/logger/FileLogger.d.ts.map +1 -0
  55. package/dist/logger/FileLogger.js +96 -0
  56. package/dist/logger/FileLogger.js.map +1 -0
  57. package/dist/logger/Logger.d.ts +12 -0
  58. package/dist/logger/Logger.d.ts.map +1 -0
  59. package/dist/logger/Logger.js +6 -0
  60. package/dist/logger/Logger.js.map +1 -0
  61. package/dist/logger/NullLogger.d.ts +13 -0
  62. package/dist/logger/NullLogger.d.ts.map +1 -0
  63. package/dist/logger/NullLogger.js +20 -0
  64. package/dist/logger/NullLogger.js.map +1 -0
  65. package/dist/models/Event.d.ts +37 -0
  66. package/dist/models/Event.d.ts.map +1 -0
  67. package/dist/models/Event.js +42 -0
  68. package/dist/models/Event.js.map +1 -0
  69. package/dist/models/Orga.d.ts +33 -0
  70. package/dist/models/Orga.d.ts.map +1 -0
  71. package/dist/models/Orga.js +39 -0
  72. package/dist/models/Orga.js.map +1 -0
  73. package/dist/models/PaginatedResult.d.ts +27 -0
  74. package/dist/models/PaginatedResult.d.ts.map +1 -0
  75. package/dist/models/PaginatedResult.js +46 -0
  76. package/dist/models/PaginatedResult.js.map +1 -0
  77. package/dist/resources/EventsResource.d.ts +20 -0
  78. package/dist/resources/EventsResource.d.ts.map +1 -0
  79. package/dist/resources/EventsResource.js +46 -0
  80. package/dist/resources/EventsResource.js.map +1 -0
  81. package/dist/resources/OrgaResource.d.ts +20 -0
  82. package/dist/resources/OrgaResource.d.ts.map +1 -0
  83. package/dist/resources/OrgaResource.js +51 -0
  84. package/dist/resources/OrgaResource.js.map +1 -0
  85. package/dist/types.d.ts +81 -0
  86. package/dist/types.d.ts.map +1 -0
  87. package/dist/types.js +8 -0
  88. package/dist/types.js.map +1 -0
  89. package/package.json +72 -0
  90. package/src/EBrocanteClient.ts +110 -0
  91. package/src/cache/CacheBackend.ts +20 -0
  92. package/src/cache/LocalCache.ts +64 -0
  93. package/src/cache/NullCache.ts +32 -0
  94. package/src/cache/RedisCache.ts +123 -0
  95. package/src/cache/RedisConfig.ts +23 -0
  96. package/src/cache/stableHashKey.ts +28 -0
  97. package/src/exceptions/index.ts +75 -0
  98. package/src/http/HttpClient.ts +266 -0
  99. package/src/index.ts +42 -0
  100. package/src/internal/safeStrings.ts +154 -0
  101. package/src/logger/DebugWriter.ts +61 -0
  102. package/src/logger/FileLogger.ts +106 -0
  103. package/src/logger/Logger.ts +13 -0
  104. package/src/logger/NullLogger.ts +22 -0
  105. package/src/models/Event.ts +92 -0
  106. package/src/models/Orga.ts +76 -0
  107. package/src/models/PaginatedResult.ts +62 -0
  108. package/src/resources/EventsResource.ts +48 -0
  109. package/src/resources/OrgaResource.ts +53 -0
  110. package/src/types.ts +86 -0
@@ -0,0 +1,266 @@
1
+ /**
2
+ * @copyright 2026 Association E-Cosplay
3
+ * @license Proprietary — see LICENSE
4
+ */
5
+
6
+ import type { CacheBackend } from '../cache/CacheBackend.js';
7
+ import { stableHashKey } from '../cache/stableHashKey.js';
8
+ import {
9
+ AuthenticationError,
10
+ NetworkError,
11
+ NotFoundError,
12
+ RateLimitError,
13
+ ServerError,
14
+ ValidationError,
15
+ } from '../exceptions/index.js';
16
+ import { stringifyPrimitive, trimLeadingSlash, trimTrailingSlash } from '../internal/safeStrings.js';
17
+ import type { DebugWriter } from '../logger/DebugWriter.js';
18
+ import type { Logger } from '../logger/Logger.js';
19
+ import type { Mode, RequestOptions } from '../types.js';
20
+
21
+ export const SDK_VERSION = '1.0.2';
22
+
23
+ export interface HttpClientDeps {
24
+ apiKey: string;
25
+ brocEmail: string;
26
+ mode: Mode;
27
+ baseUrl: string;
28
+ timeoutMs: number;
29
+ cache: CacheBackend;
30
+ logger: Logger;
31
+ debugWriter: DebugWriter | null;
32
+ maxRetries: number;
33
+ fetchImpl: typeof fetch;
34
+ }
35
+
36
+ /** @internal */
37
+ export class HttpClient {
38
+ constructor(private readonly deps: HttpClientDeps) {}
39
+
40
+ get cache(): CacheBackend {
41
+ return this.deps.cache;
42
+ }
43
+
44
+ async get(
45
+ endpoint: string,
46
+ query: Record<string, unknown> = {},
47
+ opts: RequestOptions = {},
48
+ ): Promise<Record<string, unknown>> {
49
+ const path = this.buildPath(endpoint);
50
+ const cacheKey = this.makeCacheKey(endpoint, query);
51
+ const skipCache = opts.skipCache === true;
52
+
53
+ if (!skipCache) {
54
+ const cached = await this.deps.cache.get(cacheKey);
55
+ if (cached !== null && cached !== undefined) {
56
+ this.deps.logger.debug('cache hit', { endpoint, key: cacheKey });
57
+ this.deps.debugWriter?.log(`GET ${path}`, { cache: 'hit', key: cacheKey });
58
+ return cached as Record<string, unknown>;
59
+ }
60
+ }
61
+
62
+ const url = `${trimTrailingSlash(this.deps.baseUrl)}${path}${this.encodeQuery(query)}`;
63
+ const start = performance.now();
64
+ const response = await this.sendWithRetry(url);
65
+ const durationMs = Math.round(performance.now() - start);
66
+
67
+ const body = await response.text();
68
+ const payload = this.decode(body);
69
+
70
+ const reqId = response.headers.get('x-request-id');
71
+ this.deps.logger.info('api call', {
72
+ method: 'GET',
73
+ endpoint,
74
+ mode: this.deps.mode,
75
+ status: response.status,
76
+ duration_ms: durationMs,
77
+ cached: false,
78
+ request_id: reqId,
79
+ });
80
+ this.deps.debugWriter?.log(`GET ${path}${this.encodeQuery(query)} -> ${response.status}`, {
81
+ ms: durationMs,
82
+ cache: 'miss',
83
+ req: reqId,
84
+ });
85
+
86
+ if (!skipCache) {
87
+ await this.deps.cache.set(cacheKey, payload, opts.ttl);
88
+ this.deps.debugWriter?.log('cache.set', { key: cacheKey, ttl: opts.ttl ?? 'default' });
89
+ }
90
+
91
+ return payload;
92
+ }
93
+
94
+ makeCacheKey(endpoint: string, query: Record<string, unknown>): string {
95
+ return `${this.deps.mode}:${stableHashKey(endpoint, { ...query, _broc: this.deps.brocEmail })}`;
96
+ }
97
+
98
+ private buildPath(endpoint: string): string {
99
+ return `/api/${this.deps.mode}/${trimLeadingSlash(endpoint)}`;
100
+ }
101
+
102
+ private encodeQuery(query: Record<string, unknown>): string {
103
+ const parts: string[] = [];
104
+ for (const [k, v] of Object.entries(query)) {
105
+ if (v === null || v === undefined || v === '') continue;
106
+ const value = stringifyPrimitive(v);
107
+ parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(value)}`);
108
+ }
109
+ return parts.length === 0 ? '' : `?${parts.join('&')}`;
110
+ }
111
+
112
+ private async sendWithRetry(url: string): Promise<Response> {
113
+ let attempt = 0;
114
+ while (true) {
115
+ attempt++;
116
+ const response = await this.attemptOrRetryConnect(url, attempt);
117
+ if (response === null) continue;
118
+
119
+ if (response.status >= 200 && response.status < 300) {
120
+ return response;
121
+ }
122
+
123
+ if (this.shouldRetryStatus(response.status, attempt)) {
124
+ await this.applyRetryDelay(response, response.status, attempt);
125
+ continue;
126
+ }
127
+
128
+ throw await this.buildHttpError(response);
129
+ }
130
+ }
131
+
132
+ private async attemptOrRetryConnect(url: string, attempt: number): Promise<Response | null> {
133
+ const ac = new AbortController();
134
+ const timer = setTimeout(() => ac.abort(), this.deps.timeoutMs);
135
+ let response: Response;
136
+ try {
137
+ response = await this.deps.fetchImpl(url, {
138
+ method: 'GET',
139
+ headers: {
140
+ 'X-KEY': this.deps.apiKey,
141
+ 'X-BROC': this.deps.brocEmail,
142
+ Accept: 'application/json',
143
+ 'User-Agent': `e-brocante-js/${SDK_VERSION} (Node/Bun)`,
144
+ },
145
+ signal: ac.signal,
146
+ });
147
+ } catch (cause) {
148
+ clearTimeout(timer);
149
+ return this.handleConnectError(cause, attempt);
150
+ }
151
+ clearTimeout(timer);
152
+ return response;
153
+ }
154
+
155
+ private async handleConnectError(cause: unknown, attempt: number): Promise<null> {
156
+ const message = errorMessage(cause);
157
+ if (attempt > this.deps.maxRetries) {
158
+ this.deps.logger.error('connection failed (gave up)', { attempt, error: message });
159
+ throw new NetworkError(`Connection failed after ${this.deps.maxRetries} retries: ${message}`, {
160
+ cause,
161
+ });
162
+ }
163
+ this.deps.logger.warn('connection failed, retrying', { attempt, error: message });
164
+ await this.sleepBackoff(attempt);
165
+ return null;
166
+ }
167
+
168
+ private shouldRetryStatus(status: number, attempt: number): boolean {
169
+ return (status === 429 || status >= 500) && attempt <= this.deps.maxRetries;
170
+ }
171
+
172
+ private async applyRetryDelay(response: Response, status: number, attempt: number): Promise<void> {
173
+ const retryAfter = Number(response.headers.get('retry-after') ?? 0);
174
+ this.deps.logger.warn('retryable status, retrying', {
175
+ status,
176
+ attempt,
177
+ retry_after: retryAfter,
178
+ });
179
+ this.deps.debugWriter?.log('retry', { attempt, status, retry_after: retryAfter });
180
+ if (retryAfter > 0) {
181
+ await sleep(Math.min(retryAfter, 30) * 1000);
182
+ return;
183
+ }
184
+ await this.sleepBackoff(attempt);
185
+ }
186
+
187
+ private async buildHttpError(response: Response): Promise<Error> {
188
+ const body = await response.text();
189
+ const message = this.extractErrorMessage(body) ?? `HTTP ${response.status}`;
190
+ this.deps.logger.error('api error', { status: response.status, message });
191
+ this.deps.debugWriter?.log('error', { status: response.status, message });
192
+
193
+ const status = response.status;
194
+ if (status === 401 || status === 403) return new AuthenticationError(message, status);
195
+ if (status === 404 || status === 410) return new NotFoundError(message, status);
196
+ if (status === 422) return new ValidationError(message, status, body);
197
+ if (status === 429) {
198
+ const retryAfter = response.headers.get('retry-after');
199
+ return new RateLimitError(message, status, retryAfter ? Number(retryAfter) : 0);
200
+ }
201
+ return new ServerError(message, status);
202
+ }
203
+
204
+ private async sleepBackoff(attempt: number): Promise<void> {
205
+ // Exponential backoff with jitter (0.5s -> 1s -> 2s -> 4s …, capped at 30s).
206
+ const base = Math.min(0.5 * 2 ** (attempt - 1), 30);
207
+ const jitter = secureJitter() / 1000;
208
+ await sleep((base + jitter) * 1000);
209
+ }
210
+
211
+ private decode(body: string): Record<string, unknown> {
212
+ if (body === '') return {};
213
+ let decoded: unknown;
214
+ try {
215
+ decoded = JSON.parse(body);
216
+ } catch (cause) {
217
+ throw new ServerError(`Invalid JSON response: ${(cause as Error).message}`, 0, { cause });
218
+ }
219
+ if (decoded === null || typeof decoded !== 'object' || Array.isArray(decoded)) {
220
+ throw new ServerError('Unexpected JSON response (not an object)', 0);
221
+ }
222
+ return decoded as Record<string, unknown>;
223
+ }
224
+
225
+ private extractErrorMessage(body: string): string | null {
226
+ if (body === '') return null;
227
+ let decoded: unknown;
228
+ try {
229
+ decoded = JSON.parse(body);
230
+ } catch {
231
+ decoded = null;
232
+ }
233
+
234
+ let candidate: unknown = null;
235
+ if (decoded && typeof decoded === 'object') {
236
+ const d = decoded as Record<string, unknown>;
237
+ const errs = d.errors;
238
+ if (Array.isArray(errs) && errs[0] && typeof errs[0] === 'object') {
239
+ const first = errs[0] as Record<string, unknown>;
240
+ candidate = first.title;
241
+ }
242
+ if (typeof candidate !== 'string') {
243
+ candidate = d.message;
244
+ }
245
+ }
246
+ return typeof candidate === 'string' ? candidate : null;
247
+ }
248
+ }
249
+
250
+ function sleep(ms: number): Promise<void> {
251
+ return new Promise((resolve) => setTimeout(resolve, ms));
252
+ }
253
+
254
+ function errorMessage(cause: unknown): string {
255
+ return cause instanceof Error ? cause.message : String(cause);
256
+ }
257
+
258
+ function secureJitter(): number {
259
+ // 0..250 ms, derived from a CSPRNG to satisfy SonarQube S2245.
260
+ const buf = new Uint32Array(1);
261
+ crypto.getRandomValues(buf);
262
+ const raw = buf[0];
263
+ /* v8 ignore next — buf[0] is always populated by getRandomValues */
264
+ if (raw === undefined) return 0;
265
+ return Math.floor(raw % 251);
266
+ }
package/src/index.ts ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * e-brocante JavaScript / TypeScript SDK — public entry point
3
+ *
4
+ * @copyright 2026 Association E-Cosplay
5
+ * @license Proprietary — see LICENSE
6
+ */
7
+
8
+ export { EBrocanteClient } from './EBrocanteClient.js';
9
+
10
+ export type {
11
+ EBrocanteClientOptions,
12
+ EventFilters,
13
+ OrgaFilters,
14
+ Mode,
15
+ CacheType,
16
+ RequestOptions,
17
+ } from './types.js';
18
+
19
+ export type { CacheBackend } from './cache/CacheBackend.js';
20
+ export { LocalCache } from './cache/LocalCache.js';
21
+ export { NullCache } from './cache/NullCache.js';
22
+ export { RedisCache, type RedisLikeClient } from './cache/RedisCache.js';
23
+ export { type RedisConfig, DEFAULT_REDIS_CONFIG } from './cache/RedisConfig.js';
24
+
25
+ export type { Logger, LogLevel } from './logger/Logger.js';
26
+ export { NullLogger } from './logger/NullLogger.js';
27
+ export { FileLogger } from './logger/FileLogger.js';
28
+ export { DebugWriter } from './logger/DebugWriter.js';
29
+
30
+ export {
31
+ EBrocanteError,
32
+ AuthenticationError,
33
+ NotFoundError,
34
+ ValidationError,
35
+ RateLimitError,
36
+ ServerError,
37
+ NetworkError,
38
+ } from './exceptions/index.js';
39
+
40
+ export type { EventDto } from './models/Event.js';
41
+ export type { OrgaDto } from './models/Orga.js';
42
+ export { PaginatedResult, type PaginationMeta } from './models/PaginatedResult.js';
@@ -0,0 +1,154 @@
1
+ /**
2
+ * @copyright 2026 Association E-Cosplay
3
+ * @license Proprietary — see LICENSE
4
+ *
5
+ * Hand-rolled string helpers used in place of unbounded regular
6
+ * expressions so that no input can trigger super-linear runtime
7
+ * (SonarQube rule S5852 / ReDoS). Every loop here is strictly O(n).
8
+ *
9
+ * @internal
10
+ */
11
+
12
+ const SLASH = 0x2f;
13
+ const AT = 0x40;
14
+ const DOT = 0x2e;
15
+ const SPACE = 0x20;
16
+ const TAB = 0x09;
17
+ const LF = 0x0a;
18
+ const CR = 0x0d;
19
+ const MAX_EMAIL_LENGTH = 254;
20
+
21
+ /** Strip every leading `/` from a string. O(n). */
22
+ export function trimLeadingSlash(s: string): string {
23
+ let i = 0;
24
+ while (i < s.length && s.codePointAt(i) === SLASH) i++;
25
+ return i === 0 ? s : s.slice(i);
26
+ }
27
+
28
+ /** Strip every trailing `/` from a string. O(n). */
29
+ export function trimTrailingSlash(s: string): string {
30
+ let end = s.length;
31
+ while (end > 0 && s.codePointAt(end - 1) === SLASH) end--;
32
+ return end === s.length ? s : s.slice(0, end);
33
+ }
34
+
35
+ /** Strip leading AND trailing `/`. O(n). */
36
+ export function trimSlashes(s: string): string {
37
+ return trimTrailingSlash(trimLeadingSlash(s));
38
+ }
39
+
40
+ /** Replace every `/` with `.`. O(n). */
41
+ export function slashesToDots(s: string): string {
42
+ return s.replaceAll('/', '.');
43
+ }
44
+
45
+ /** Coerce an unknown value to a string, falling back when it isn't one. */
46
+ export function asString(v: unknown, fallback = ''): string {
47
+ return typeof v === 'string' ? v : fallback;
48
+ }
49
+
50
+ /** Coerce an unknown value to a finite number, falling back when it isn't. */
51
+ export function asNumber(v: unknown, fallback = 0): number {
52
+ if (typeof v === 'number' && Number.isFinite(v)) return v;
53
+ if (typeof v === 'string' && v !== '') {
54
+ const n = Number(v);
55
+ if (Number.isFinite(n)) return n;
56
+ }
57
+ return fallback;
58
+ }
59
+
60
+ /** Coerce an unknown value to a boolean (truthy primitives only). */
61
+ export function asBoolean(v: unknown): boolean {
62
+ return v === true || v === 'true' || v === 1 || v === '1';
63
+ }
64
+
65
+ /** Returns the string value when truthy, otherwise null. */
66
+ export function asNullableString(v: unknown): string | null {
67
+ return typeof v === 'string' && v !== '' ? v : null;
68
+ }
69
+
70
+ /** Parse an ISO-8601 string into a Date, returning null on garbage. */
71
+ export function asDate(v: unknown): Date | null {
72
+ if (typeof v !== 'string' || v === '') return null;
73
+ const d = new Date(v);
74
+ return Number.isNaN(d.getTime()) ? null : d;
75
+ }
76
+
77
+ /**
78
+ * Coerce an unknown value to a plain record. Returns an empty record
79
+ * on null, undefined, primitives, or arrays — never throws.
80
+ */
81
+ export function asRecord(v: unknown): Record<string, unknown> {
82
+ if (v === null || v === undefined) return {};
83
+ if (typeof v !== 'object') return {};
84
+ if (Array.isArray(v)) return {};
85
+ return v as Record<string, unknown>;
86
+ }
87
+
88
+ /**
89
+ * Coerce an unknown value to an integer, returning null when the input
90
+ * is empty, missing or not finite (vs. asNumber which falls back to 0).
91
+ */
92
+ export function asNullableInt(v: unknown): number | null {
93
+ if (v === null || v === undefined || v === '') return null;
94
+ const n = asNumber(v, Number.NaN);
95
+ return Number.isFinite(n) ? n : null;
96
+ }
97
+
98
+ /**
99
+ * Stringify a primitive value for use in URL query strings or cache keys.
100
+ * Refuses non-primitive values (objects, arrays, functions) to avoid the
101
+ * `[object Object]` foot-gun that `String(v)` produces for them.
102
+ */
103
+ export function stringifyPrimitive(v: unknown): string {
104
+ if (v === null || v === undefined) return '';
105
+ if (typeof v === 'string') return v;
106
+ if (typeof v === 'number' || typeof v === 'bigint') return String(v);
107
+ if (typeof v === 'boolean') return v ? 'true' : 'false';
108
+ throw new TypeError(`Cannot stringify ${typeof v} value as a query/cache parameter.`);
109
+ }
110
+
111
+ function isWhitespaceCode(code: number | undefined): boolean {
112
+ return code === SPACE || code === TAB || code === LF || code === CR;
113
+ }
114
+
115
+ /** Find the index of `@` in a single forward scan, returning -1 on failure
116
+ * (no `@`, multiple `@`, or any whitespace anywhere). */
117
+ function findSingleAt(value: string): number {
118
+ let at = -1;
119
+ for (let i = 0; i < value.length; i++) {
120
+ const c = value.codePointAt(i);
121
+ if (isWhitespaceCode(c)) return -1;
122
+ if (c === AT) {
123
+ if (at !== -1) return -1; // more than one '@'
124
+ at = i;
125
+ }
126
+ }
127
+ return at;
128
+ }
129
+
130
+ /** Validate the domain part has a valid dot (not at the boundary). */
131
+ function hasInteriorDot(domain: string): boolean {
132
+ for (let i = 0; i < domain.length; i++) {
133
+ if (domain.codePointAt(i) === DOT) {
134
+ // Reject leading dot, trailing dot.
135
+ if (i === 0 || i === domain.length - 1) return false;
136
+ return true;
137
+ }
138
+ }
139
+ return false;
140
+ }
141
+
142
+ /**
143
+ * Strict, ReDoS-safe email validator. Constant-time wrt structure
144
+ * (single forward scan + a couple of bounded slices). Intentionally
145
+ * conservative — accepts the same shape as `/^[^@\s]+@[^@\s]+\.[^@\s]+$/`
146
+ * but with explicit upper bounds.
147
+ */
148
+ export function isValidEmail(value: string): boolean {
149
+ const len = value.length;
150
+ if (len === 0 || len > MAX_EMAIL_LENGTH) return false;
151
+ const at = findSingleAt(value);
152
+ if (at < 1 || at >= len - 1) return false;
153
+ return hasInteriorDot(value.slice(at + 1));
154
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @copyright 2026 Association E-Cosplay
3
+ * @license Proprietary — see LICENSE
4
+ */
5
+
6
+ import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
7
+ import { dirname } from 'node:path';
8
+ import { EBrocanteError } from '../exceptions/index.js';
9
+
10
+ /**
11
+ * Plain-text debug writer. Appends ONE LINE per SDK action to a file
12
+ * (default: `./DEBUG.TXT`). Designed for `tail -f DEBUG.TXT` during
13
+ * development; not intended for production observability — use
14
+ * FileLogger or a custom Logger for that.
15
+ *
16
+ * Output format:
17
+ * [2026-05-10 19:30:01.123] GET /api/prod/events?city=Paris -> 200 ms=45 cache=miss req=req_xxx
18
+ * [2026-05-10 19:30:01.456] cache.set key=events.list:abc ttl=300
19
+ * [2026-05-10 19:30:05.789] GET /api/prod/events?city=Paris cache=hit
20
+ * [2026-05-10 19:31:10.001] retry attempt=2 status=429 retry_after=3
21
+ * [2026-05-10 19:32:00.555] error status=401 message="HTTP 401"
22
+ */
23
+ export class DebugWriter {
24
+ constructor(public readonly file: string = 'DEBUG.TXT') {
25
+ const dir = dirname(file);
26
+ if (dir && dir !== '.' && !existsSync(dir)) {
27
+ /* v8 ignore start — fs error mid-process (full disk, EROFS) */
28
+ try {
29
+ mkdirSync(dir, { recursive: true, mode: 0o755 });
30
+ } catch (cause) {
31
+ throw new EBrocanteError(`Cannot create debug directory: ${dir}`, { cause });
32
+ }
33
+ /* v8 ignore stop */
34
+ }
35
+ }
36
+
37
+ log(action: string, fields: Record<string, unknown> = {}): void {
38
+ const parts: string[] = [];
39
+ for (const [k, v] of Object.entries(fields)) {
40
+ if (v === null || v === undefined || v === '') continue;
41
+ parts.push(`${k}=${this.stringify(v)}`);
42
+ }
43
+
44
+ const ts = new Date().toISOString().replace('T', ' ').replace('Z', '').slice(0, 23);
45
+
46
+ const line = parts.length > 0 ? `[${ts}] ${action} ${parts.join(' ')}` : `[${ts}] ${action}`;
47
+ try {
48
+ appendFileSync(this.file, `${line}\n`, { mode: 0o644 });
49
+ /* v8 ignore start — best-effort; only triggers on fs errors */
50
+ } catch {
51
+ // swallowed
52
+ }
53
+ /* v8 ignore stop */
54
+ }
55
+
56
+ private stringify(v: unknown): string {
57
+ if (typeof v === 'boolean') return v ? 'true' : 'false';
58
+ const s = String(v);
59
+ return s.includes(' ') ? `"${s}"` : s;
60
+ }
61
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * @copyright 2026 Association E-Cosplay
3
+ * @license Proprietary — see LICENSE
4
+ */
5
+
6
+ import { appendFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { EBrocanteError } from '../exceptions/index.js';
9
+ import type { LogLevel, Logger } from './Logger.js';
10
+
11
+ const LEVEL_PRIORITIES: Record<LogLevel, number> = {
12
+ debug: 0,
13
+ info: 1,
14
+ warn: 2,
15
+ error: 3,
16
+ };
17
+
18
+ /**
19
+ * File logger that appends one JSON-line per record to a date-stamped
20
+ * file in the configured directory. One file per UTC day:
21
+ * `ebrocante-YYYY-MM-DD.log`. Suitable for log shippers (Vector,
22
+ * Filebeat, Fluent Bit) and `tail -f` during local development.
23
+ *
24
+ * Sensitive context keys are automatically redacted (`api_key`,
25
+ * `X-KEY` and `Authorization` headers).
26
+ */
27
+ export class FileLogger implements Logger {
28
+ private readonly minLevel: number;
29
+
30
+ constructor(
31
+ private readonly directory: string,
32
+ minLevel: LogLevel = 'info',
33
+ ) {
34
+ if (!directory || directory.trim() === '') {
35
+ throw new EBrocanteError('FileLogger directory cannot be empty');
36
+ }
37
+ if (!existsSync(directory)) {
38
+ /* v8 ignore start — fs error mid-process (full disk, EROFS) */
39
+ try {
40
+ mkdirSync(directory, { recursive: true, mode: 0o755 });
41
+ } catch (cause) {
42
+ throw new EBrocanteError(`Cannot create log directory: ${directory}`, { cause });
43
+ }
44
+ /* v8 ignore stop */
45
+ }
46
+ const stat = statSync(directory);
47
+ if (!stat.isDirectory()) {
48
+ throw new EBrocanteError(`Log path is not a directory: ${directory}`);
49
+ }
50
+ this.minLevel = LEVEL_PRIORITIES[minLevel];
51
+ }
52
+
53
+ debug(message: string, context: Record<string, unknown> = {}): void {
54
+ this.write('debug', message, context);
55
+ }
56
+
57
+ info(message: string, context: Record<string, unknown> = {}): void {
58
+ this.write('info', message, context);
59
+ }
60
+
61
+ warn(message: string, context: Record<string, unknown> = {}): void {
62
+ this.write('warn', message, context);
63
+ }
64
+
65
+ error(message: string, context: Record<string, unknown> = {}): void {
66
+ this.write('error', message, context);
67
+ }
68
+
69
+ private write(level: LogLevel, message: string, context: Record<string, unknown>): void {
70
+ if (LEVEL_PRIORITIES[level] < this.minLevel) return;
71
+
72
+ const entry = {
73
+ ts: new Date().toISOString(),
74
+ level,
75
+ message,
76
+ context: this.sanitize(context),
77
+ pid: process.pid,
78
+ };
79
+
80
+ const file = join(this.directory, `ebrocante-${entry.ts.slice(0, 10)}.log`);
81
+ try {
82
+ appendFileSync(file, `${JSON.stringify(entry)}\n`, { mode: 0o644 });
83
+ /* v8 ignore start — best-effort; only triggers on fs errors */
84
+ } catch {
85
+ // swallowed
86
+ }
87
+ /* v8 ignore stop */
88
+ }
89
+
90
+ private sanitize(context: Record<string, unknown>): Record<string, unknown> {
91
+ const out: Record<string, unknown> = { ...context };
92
+ if (out.headers && typeof out.headers === 'object') {
93
+ const headers: Record<string, unknown> = { ...(out.headers as Record<string, unknown>) };
94
+ for (const k of Object.keys(headers)) {
95
+ if (k.toLowerCase() === 'x-key' || k.toLowerCase() === 'authorization') {
96
+ headers[k] = '***redacted***';
97
+ }
98
+ }
99
+ out.headers = headers;
100
+ }
101
+ if (typeof out.api_key === 'string') {
102
+ out.api_key = `${out.api_key.slice(0, 12)}***`;
103
+ }
104
+ return out;
105
+ }
106
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @copyright 2026 Association E-Cosplay
3
+ * @license Proprietary — see LICENSE
4
+ */
5
+
6
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
7
+
8
+ export interface Logger {
9
+ debug(message: string, context?: Record<string, unknown>): void;
10
+ info(message: string, context?: Record<string, unknown>): void;
11
+ warn(message: string, context?: Record<string, unknown>): void;
12
+ error(message: string, context?: Record<string, unknown>): void;
13
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @copyright 2026 Association E-Cosplay
3
+ * @license Proprietary — see LICENSE
4
+ */
5
+
6
+ import type { Logger } from './Logger.js';
7
+
8
+ /** No-op logger used by default when no log destination is configured. */
9
+ export class NullLogger implements Logger {
10
+ debug(_message: string, _context?: Record<string, unknown>): void {
11
+ /* intentional no-op */
12
+ }
13
+ info(_message: string, _context?: Record<string, unknown>): void {
14
+ /* intentional no-op */
15
+ }
16
+ warn(_message: string, _context?: Record<string, unknown>): void {
17
+ /* intentional no-op */
18
+ }
19
+ error(_message: string, _context?: Record<string, unknown>): void {
20
+ /* intentional no-op */
21
+ }
22
+ }