diff --git a/bridge/package-lock.json b/bridge/package-lock.json index 10021c7..2e597a8 100644 --- a/bridge/package-lock.json +++ b/bridge/package-lock.json @@ -9,14 +9,15 @@ "version": "0.1.0", "license": "AGPL-3.0-or-later", "dependencies": { - "@hono/node-server": "^1.13.0", - "decimal.js": "^10.4.3", - "dotenv": "^16.4.5", - "hono": "^4.6.0", - "ioredis": "^5.4.1", - "ofetch": "^1.4.0", - "pino": "^9.5.0", - "zod": "^3.23.8" + "@hono/node-server": "^1.19.14", + "decimal.js": "^10.6.0", + "dotenv": "^16.6.1", + "hono": "^4.12.18", + "ioredis": "^5.10.1", + "ofetch": "^1.5.1", + "pino": "^9.14.0", + "pino-pretty": "^13.1.3", + "zod": "^3.25.76" }, "devDependencies": { "@biomejs/biome": "^1.9.0", @@ -2148,6 +2149,12 @@ "dev": true, "license": "MIT" }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "license": "MIT" + }, "node_modules/compress-commons": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", @@ -2229,6 +2236,15 @@ "node": ">= 8" } }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2415,7 +2431,6 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -2530,6 +2545,12 @@ "node": ">=12.0.0" } }, + "node_modules/fast-copy": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.3.tgz", + "integrity": "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==", + "license": "MIT" + }, "node_modules/fast-fifo": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", @@ -2537,6 +2558,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -2684,6 +2711,12 @@ "node": ">=8" } }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "license": "MIT" + }, "node_modules/hono": { "version": "4.12.18", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", @@ -2859,6 +2892,15 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/lazystream": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", @@ -3006,6 +3048,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -3112,7 +3163,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -3207,6 +3257,39 @@ "split2": "^4.0.0" } }, + "node_modules/pino-pretty": { + "version": "13.1.3", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz", + "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^4.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^4.0.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^5.0.2" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-pretty/node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, "node_modules/pino-std-serializers": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", @@ -3341,7 +3424,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "dev": true, "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -3560,6 +3642,22 @@ "dev": true, "license": "MIT" }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -3837,6 +3935,18 @@ "node": ">=8" } }, + "node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4800,7 +4910,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/y18n": { diff --git a/bridge/package.json b/bridge/package.json index dbae6e9..6b1ee49 100644 --- a/bridge/package.json +++ b/bridge/package.json @@ -22,14 +22,15 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { - "@hono/node-server": "^1.13.0", - "decimal.js": "^10.4.3", - "dotenv": "^16.4.5", - "hono": "^4.6.0", - "ioredis": "^5.4.1", - "ofetch": "^1.4.0", - "pino": "^9.5.0", - "zod": "^3.23.8" + "@hono/node-server": "^1.19.14", + "decimal.js": "^10.6.0", + "dotenv": "^16.6.1", + "hono": "^4.12.18", + "ioredis": "^5.10.1", + "ofetch": "^1.5.1", + "pino": "^9.14.0", + "pino-pretty": "^13.1.3", + "zod": "^3.25.76" }, "devDependencies": { "@biomejs/biome": "^1.9.0", diff --git a/bridge/src/adapters/baserow-client.ts b/bridge/src/adapters/baserow-client.ts new file mode 100644 index 0000000..e5bb595 --- /dev/null +++ b/bridge/src/adapters/baserow-client.ts @@ -0,0 +1,151 @@ +/** + * BaserowClient — wrapper typé de l'API Baserow. + * Auth via API token (BASEROW_API_TOKEN), pagination, retry sur erreurs reseau. + */ + +import { ofetch } from 'ofetch'; +import type { Logger } from 'pino'; +import { errors } from '../lib/errors.js'; + +export interface BaserowRow { + id: number; + order: string; + [key: string]: unknown; +} + +export interface BaserowPaginatedResponse { + count: number; + next: string | null; + previous: string | null; + results: T[]; +} + +export interface BaserowListOptions { + page?: number; + size?: number; + search?: string; + filter?: Record; + orderBy?: string; + userFieldNames?: boolean; +} + +export class BaserowClient { + private readonly baseUrl: string; + private readonly token: string; + private readonly logger: Logger; + + constructor(opts: { baseUrl: string; token: string; logger: Logger }) { + this.baseUrl = opts.baseUrl.replace(/\/$/, ''); + this.token = opts.token; + this.logger = opts.logger.child({ adapter: 'baserow' }); + } + + private fetch( + path: string, + init?: { method?: string; body?: string; headers?: Record }, + ): Promise { + const url = `${this.baseUrl}${path}`; + return ofetch(url, { + method: init?.method, + body: init?.body, + headers: { + Authorization: `Token ${this.token}`, + 'Content-Type': 'application/json', + ...(init?.headers ?? {}), + }, + retry: 2, + retryDelay: 200, + timeout: 10_000, + onResponseError: ({ response }) => { + this.logger.error({ status: response.status, url, body: response._data }, 'baserow error'); + }, + }).catch((err: unknown) => { + const error = err as { response?: { status?: number } }; + if (error.response?.status === 401) throw errors.authInvalid(); + if (error.response?.status === 404) throw errors.notFound('Baserow row', path); + if (!error.response) throw errors.baserowDown(); + throw err; + }); + } + + /** + * Liste les rows d'une table avec pagination. + * Retourne automatiquement les noms de fields (`user_field_names=true`) pour eviter le mapping field_id. + */ + async listRows( + tableId: number, + opts: BaserowListOptions = {}, + ): Promise { + const params: Record = { + user_field_names: opts.userFieldNames ?? true, + size: opts.size ?? 100, + page: opts.page ?? 1, + }; + if (opts.search) params.search = opts.search; + if (opts.orderBy) params.order_by = opts.orderBy; + if (opts.filter) { + for (const [key, val] of Object.entries(opts.filter)) { + params[`filter__${key}__contains`] = String(val); + } + } + const queryRecord: Record = {}; + for (const [k, v] of Object.entries(params)) { + queryRecord[k] = String(v); + } + const query = new URLSearchParams(queryRecord).toString(); + return this.fetch(`/api/database/rows/table/${tableId}/?${query}`); + } + + async getRow(tableId: number, rowId: number, userFieldNames = true): Promise { + return this.fetch( + `/api/database/rows/table/${tableId}/${rowId}/?user_field_names=${userFieldNames}`, + ); + } + + async createRow( + tableId: number, + data: Record, + userFieldNames = true, + ): Promise { + return this.fetch( + `/api/database/rows/table/${tableId}/?user_field_names=${userFieldNames}`, + { method: 'POST', body: JSON.stringify(data) }, + ); + } + + async updateRow( + tableId: number, + rowId: number, + data: Record, + userFieldNames = true, + ): Promise { + return this.fetch( + `/api/database/rows/table/${tableId}/${rowId}/?user_field_names=${userFieldNames}`, + { method: 'PATCH', body: JSON.stringify(data) }, + ); + } + + async deleteRow(tableId: number, rowId: number): Promise { + await this.fetch(`/api/database/rows/table/${tableId}/${rowId}/`, { method: 'DELETE' }); + } + + /** + * Resoud le mapping table_name → table_id pour la database. + * Utilise par le bridge au boot pour eviter de coder les ids en dur. + */ + async resolveTableIds(databaseId: number): Promise> { + const tables = await this.fetch>( + `/api/database/tables/database/${databaseId}/`, + ); + return Object.fromEntries(tables.map((t) => [t.name, t.id])); + } + + async healthCheck(): Promise { + try { + await ofetch(`${this.baseUrl}/api/_health/`, { timeout: 3000 }); + return true; + } catch { + return false; + } + } +} diff --git a/bridge/src/adapters/docmost-client.ts b/bridge/src/adapters/docmost-client.ts new file mode 100644 index 0000000..323e356 --- /dev/null +++ b/bridge/src/adapters/docmost-client.ts @@ -0,0 +1,220 @@ +/** + * DocmostClient — wrapper de l'API interne Docmost (endpoints privés AGPL-legal). + * Auth par session cookie (POST /api/auth/login → cookie Set-Cookie). + */ + +import { ofetch } from 'ofetch'; +import type { Logger } from 'pino'; +import { errors } from '../lib/errors.js'; + +export interface DocmostUser { + id: string; + name: string; + email: string; +} + +export interface DocmostWorkspace { + id: string; + name: string; + defaultSpaceId: string; +} + +export interface DocmostSpace { + id: string; + name: string; + slug: string; + visibility: 'open' | 'private'; + workspaceId: string; +} + +export interface DocmostPage { + id: string; + title: string; + spaceId: string; + parentPageId: string | null; + position: number | null; + content?: unknown; + slugId?: string; +} + +export interface DocmostShare { + id: string; + pageId: string; + url?: string; + expiresAt?: string; + password?: string | null; +} + +interface DocmostEnvelope { + data: T; + success?: boolean; + status?: number; +} + +export class DocmostClient { + private readonly baseUrl: string; + private readonly logger: Logger; + private cookie: string | null = null; + private readonly email: string; + private readonly password: string; + + constructor(opts: { baseUrl: string; email: string; password: string; logger: Logger }) { + this.baseUrl = opts.baseUrl.replace(/\/$/, ''); + this.logger = opts.logger.child({ adapter: 'docmost' }); + this.email = opts.email; + this.password = opts.password; + } + + /** + * Auto-login + persist session cookie. Re-auth si 401. + */ + private async ensureAuth(): Promise { + if (this.cookie) return; + await this.login(); + } + + private async login(): Promise { + const url = `${this.baseUrl}/api/auth/login`; + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: this.email, password: this.password }), + }); + if (!response.ok) { + this.logger.error({ status: response.status }, 'docmost login failed'); + throw errors.authInvalid(); + } + const setCookie = response.headers.get('set-cookie'); + if (setCookie) { + this.cookie = setCookie.split(';')[0] ?? null; + } + this.logger.debug('docmost login ok'); + } + + private async post(path: string, body: Record = {}): Promise { + await this.ensureAuth(); + const url = `${this.baseUrl}${path}`; + try { + const response = await ofetch | T>(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(this.cookie ? { Cookie: this.cookie } : {}), + }, + body, + retry: 1, + timeout: 10_000, + }); + if (response && typeof response === 'object' && 'data' in response) { + return (response as DocmostEnvelope).data; + } + return response as T; + } catch (err: unknown) { + const error = err as { response?: { status?: number } }; + if (error.response?.status === 401) { + // Re-auth + retry once + this.cookie = null; + await this.ensureAuth(); + return this.post(path, body); + } + if (error.response?.status === 404) throw errors.notFound('Docmost resource', path); + if (!error.response) throw errors.docmostDown(); + throw err; + } + } + + // --- Workspace --- + async getWorkspaceInfo(): Promise { + return this.post('/api/workspace/info'); + } + + // --- Spaces --- + async listSpaces(page = 1, limit = 100): Promise { + const result = await this.post<{ items: DocmostSpace[] }>('/api/spaces/', { + page, + limit, + }); + return result.items ?? []; + } + + async createSpace(input: { + name: string; + description?: string; + slug: string; + visibility?: 'open' | 'private'; + }): Promise { + return this.post('/api/spaces/create', { + name: input.name, + description: input.description ?? '', + slug: input.slug, + visibility: input.visibility ?? 'open', + }); + } + + async addSpaceMember(spaceId: string, userEmails: string[], role = 'writer'): Promise { + await this.post('/api/spaces/members/add', { spaceId, userEmails, role }); + } + + // --- Pages --- + async createPage(input: { + spaceId: string; + title: string; + content?: string; + format?: 'markdown' | 'json' | 'html'; + parentPageId?: string; + }): Promise { + return this.post('/api/pages/create', { + spaceId: input.spaceId, + title: input.title, + ...(input.content ? { content: input.content } : {}), + format: input.format ?? 'markdown', + ...(input.parentPageId ? { parentPageId: input.parentPageId } : {}), + }); + } + + async getPageInfo(pageId: string): Promise { + return this.post('/api/pages/info', { pageId }); + } + + async updatePage(input: { + pageId: string; + title?: string; + content?: string; + }): Promise { + return this.post('/api/pages/update', input); + } + + async deletePage(pageId: string): Promise { + await this.post('/api/pages/delete', { pageId }); + } + + // --- Shares --- + async createShare(input: { + pageId: string; + includeSubPages?: boolean; + expiresAt?: string; + password?: string; + }): Promise { + return this.post('/api/shares/create', { + pageId: input.pageId, + includeSubPages: input.includeSubPages ?? false, + ...(input.expiresAt ? { expiresAt: input.expiresAt } : {}), + ...(input.password ? { password: input.password } : {}), + }); + } + + async deleteShare(shareId: string): Promise { + await this.post('/api/shares/delete', { shareId }); + } + + async healthCheck(): Promise { + try { + const response = await ofetch<{ status: string }>(`${this.baseUrl}/api/health`, { + timeout: 3000, + }); + return response.status === 'ok'; + } catch { + return false; + } + } +} diff --git a/bridge/src/adapters/redis-cache.ts b/bridge/src/adapters/redis-cache.ts new file mode 100644 index 0000000..55653dc --- /dev/null +++ b/bridge/src/adapters/redis-cache.ts @@ -0,0 +1,119 @@ +/** + * RedisCache — cache-aside pattern. + * Utilise pour : cache rows Baserow, idempotence webhooks, rate limiting. + */ + +import IORedis from 'ioredis'; +import type { Redis } from 'ioredis'; +import type { Logger } from 'pino'; + +export class RedisCache { + private readonly client: Redis; + private readonly logger: Logger; + + constructor(opts: { url: string; logger: Logger }) { + this.logger = opts.logger.child({ adapter: 'redis' }); + this.client = new IORedis(opts.url, { + lazyConnect: false, + maxRetriesPerRequest: 3, + enableOfflineQueue: false, + }); + this.client.on('error', (err: Error) => { + this.logger.error({ err: err.message }, 'redis error'); + }); + } + + /** + * Get + JSON parse. + */ + async get(key: string): Promise { + const raw = await this.client.get(key); + if (raw === null) return null; + try { + return JSON.parse(raw) as T; + } catch { + this.logger.warn({ key }, 'cache value not JSON, deleting'); + await this.client.del(key); + return null; + } + } + + /** + * Set with TTL (seconds). + */ + async set(key: string, value: T, ttlSeconds = 300): Promise { + await this.client.set(key, JSON.stringify(value), 'EX', ttlSeconds); + } + + async del(key: string | string[]): Promise { + const keys = Array.isArray(key) ? key : [key]; + if (keys.length > 0) { + await this.client.del(...keys); + } + } + + /** + * Invalidation par pattern (ex: 'bridge:personne:*'). + * Utilise SCAN pour ne pas bloquer Redis. + */ + async invalidatePattern(pattern: string): Promise { + let cursor = '0'; + let count = 0; + do { + const [next, keys] = await this.client.scan(cursor, 'MATCH', pattern, 'COUNT', 100); + cursor = next; + if (keys.length > 0) { + await this.client.del(...keys); + count += keys.length; + } + } while (cursor !== '0'); + return count; + } + + /** + * Idempotence webhook : retourne true si event_id deja vu, false sinon. + */ + async checkAndStoreEventId(eventId: string, ttlSeconds = 86400): Promise { + const result = await this.client.set( + `bridge:webhook:event:${eventId}`, + '1', + 'EX', + ttlSeconds, + 'NX', + ); + // result === 'OK' → key was set (event nouveau) + // result === null → key existait deja (duplicate) + return result === null; + } + + /** + * Rate limit sliding window basique. + * Retourne true si la requete passe, false si limit atteint. + */ + async checkRateLimit(key: string, maxRequests: number, windowSeconds: number): Promise { + const now = Date.now(); + const windowKey = `bridge:rate:${key}`; + const multi = this.client.multi(); + multi.zadd(windowKey, now, `${now}`); + multi.zremrangebyscore(windowKey, 0, now - windowSeconds * 1000); + multi.zcard(windowKey); + multi.expire(windowKey, windowSeconds); + const results = await multi.exec(); + if (!results) return true; + const count = results[2]?.[1] as number; + return count <= maxRequests; + } + + async healthCheck(): Promise { + try { + const pong = await this.client.ping(); + return pong === 'PONG'; + } catch { + return false; + } + } + + async close(): Promise { + await this.client.quit(); + } +} diff --git a/bridge/src/lib/errors.ts b/bridge/src/lib/errors.ts new file mode 100644 index 0000000..f2f732a --- /dev/null +++ b/bridge/src/lib/errors.ts @@ -0,0 +1,59 @@ +/** + * Erreurs metier typees pour le bridge. + * Chaque erreur a un code stable + statut HTTP + details serializables. + */ + +export type ErrorCode = + | 'AUTH_REQUIRED' + | 'AUTH_INVALID' + | 'FORBIDDEN_SCOPE' + | 'NOT_FOUND' + | 'VALIDATION_ERROR' + | 'RG_VIOLATION' + | 'CONFLICT' + | 'RATE_LIMITED' + | 'BASEROW_UNAVAILABLE' + | 'DOCMOST_UNAVAILABLE' + | 'INTERNAL'; + +export class BridgeError extends Error { + constructor( + public readonly code: ErrorCode, + public readonly status: number, + message: string, + public readonly details?: Record, + ) { + super(message); + this.name = 'BridgeError'; + } + + toJSON() { + return { + error: { + code: this.code, + message: this.message, + ...(this.details ? { details: this.details } : {}), + }, + }; + } +} + +export const errors = { + authRequired: () => new BridgeError('AUTH_REQUIRED', 401, 'Token absent'), + authInvalid: () => new BridgeError('AUTH_INVALID', 401, 'Token invalide'), + forbidden: (scope: string) => + new BridgeError('FORBIDDEN_SCOPE', 403, `Scope requis : ${scope}`, { scope }), + notFound: (entity: string, id: string | number) => + new BridgeError('NOT_FOUND', 404, `${entity} introuvable`, { entity, id }), + validation: (issues: unknown) => + new BridgeError('VALIDATION_ERROR', 400, 'Body invalide', { issues }), + rgViolation: (rule: string, message: string, details?: Record) => + new BridgeError('RG_VIOLATION', 422, message, { rule, ...details }), + conflict: (message: string, details?: Record) => + new BridgeError('CONFLICT', 409, message, details), + rateLimited: (retryAfter: number) => + new BridgeError('RATE_LIMITED', 429, 'Too many requests', { retry_after: retryAfter }), + baserowDown: () => new BridgeError('BASEROW_UNAVAILABLE', 502, 'Baserow API unreachable'), + docmostDown: () => new BridgeError('DOCMOST_UNAVAILABLE', 502, 'Docmost API unreachable'), + internal: (message: string) => new BridgeError('INTERNAL', 500, message), +};