feat(bridge): add Baserow user JWT auto-login for metadata endpoints — Patch 031
Service account pattern resolves 401 PERMISSION_DENIED on Baserow metadata endpoints (/api/database/views/table/:id/, /api/database/tables/:id/) which reject DB tokens. A dedicated Baserow user account logs in via token-auth, JWT cached in memory with mutex-protected refresh before expiry. Fallback graceful: if BASEROW_USER_EMAIL/PASSWORD absent, CRUD rows still work, metadata endpoints return 500 BASEROW_USER_AUTH_NOT_CONFIGURED. 417 tests pass (was 392, +25). 0 TS errors. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
30b148694c
commit
445dda260a
11 changed files with 1276 additions and 7 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
# SESSION RESUME — formation-hub Acadenice (last update post Patch 017 — 0 TS errors, 0 test failures)
|
# SESSION RESUME — formation-hub Acadenice (last update Patch 031 — Baserow user JWT auto-login)
|
||||||
|
|
||||||
## RECAP SESSION 2026-05-07 (lecture obligatoire post-/compact)
|
## RECAP SESSION 2026-05-07 (lecture obligatoire post-/compact)
|
||||||
|
|
||||||
|
|
@ -29,6 +29,8 @@ Bridge state : 380/380 tests verts (+44 R3.1.a +6 retry/fake-redis, +11 SSE rout
|
||||||
|
|
||||||
**Fork DocAdenice (`docmost/`, gitignored, branche `acadenice/main`, local-only)** :
|
**Fork DocAdenice (`docmost/`, gitignored, branche `acadenice/main`, local-only)** :
|
||||||
```
|
```
|
||||||
|
b53ab50 feat(acadedoc): add AcadeDoc branding, Brevo SMTP preset, UI customization — R4.4
|
||||||
|
d0b7577 feat(acadenice): add timeline view (Gantt) for databases — R4.1
|
||||||
4cf0408 fix(acadenice): resolve test suite failures across R3 sub-blocks (Patch 017)
|
4cf0408 fix(acadenice): resolve test suite failures across R3 sub-blocks (Patch 017)
|
||||||
- 17 server specs converted from vitest to Jest (vi -> jest globals)
|
- 17 server specs converted from vitest to Jest (vi -> jest globals)
|
||||||
- jest.mock stubs for ESM-only prosemirror/html and collaboration modules
|
- jest.mock stubs for ESM-only prosemirror/html and collaboration modules
|
||||||
|
|
@ -67,14 +69,16 @@ efa2644 rebrand DocAdenice (titres + emails, identifiants techniques KEEP)
|
||||||
- Bridge consume le claim direct (pas de mapping)
|
- Bridge consume le claim direct (pas de mapping)
|
||||||
- 3 modes auth Bearer cohabitent
|
- 3 modes auth Bearer cohabitent
|
||||||
|
|
||||||
### Catalogue 30 permissions atomiques (en code TS, fork) — mis a jour R3.8
|
### Catalogue 34 permissions atomiques (en code TS, fork) — mis a jour R4.3
|
||||||
```
|
```
|
||||||
pages:read|write|delete|share, space:read|create|write|delete|invite,
|
pages:read|write|delete|share, space:read|create|write|delete|invite,
|
||||||
tables:list|create|write|delete, rows:read|write|delete,
|
tables:list|create|write|delete, rows:read|write|delete,
|
||||||
attachments:upload|delete, users:invite|write|delete, roles:manage,
|
attachments:upload|delete, users:invite|write|delete, roles:manage,
|
||||||
slash_commands:manage (R3.3),
|
slash_commands:manage (R3.3),
|
||||||
templates:read|create|manage (R3.6),
|
templates:read|create|manage (R3.6),
|
||||||
comments:read|write|resolve|moderate (R3.8 - nouveaux),
|
comments:read|write|resolve|moderate (R3.8),
|
||||||
|
sync_blocks:create|edit|delete (R4.2),
|
||||||
|
clipper:use (R4.3 - nouveau),
|
||||||
admin:*
|
admin:*
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -101,11 +105,89 @@ Owner=`admin:*`, Admin=tout sauf `*:delete` et `roles:manage`, Editor, Member, G
|
||||||
- **R3.8** comments inline — **LIVRE** `be951a2` (30 tests, Patch 016). REST resolve facade for page comments (PageCommentResolveService + CollaborationGateway yjs sync). New acadenice_row_comment table + RowCommentService + REST CRUD + React RowCommentsPanel in RowDetailModal tabs. 4 new permissions comments:read|write|resolve|moderate (30 total). i18n FR+EN.
|
- **R3.8** comments inline — **LIVRE** `be951a2` (30 tests, Patch 016). REST resolve facade for page comments (PageCommentResolveService + CollaborationGateway yjs sync). New acadenice_row_comment table + RowCommentService + REST CRUD + React RowCommentsPanel in RowDetailModal tabs. 4 new permissions comments:read|write|resolve|moderate (30 total). i18n FR+EN.
|
||||||
- **R3 ENTIEREMENT TERMINE**
|
- **R3 ENTIEREMENT TERMINE**
|
||||||
|
|
||||||
|
### R4.3 — Web Clipper extension navigateur — LIVRE
|
||||||
|
|
||||||
|
Commit : `23a8526` (co-commit avec R4.2 sync-blocks par pre-commit hook)
|
||||||
|
32 fichiers clipper, 59 tests verts (36 server Jest + 23 extension Vitest)
|
||||||
|
|
||||||
|
**Backend** :
|
||||||
|
- `apps/server/src/core/acadenice/clipper/` — ClipperModule, ClipperController, ClipperService, ClipperTokenService
|
||||||
|
- POST /api/acadenice/clipper/import — auth X-Clipper-Token, body Zod (url, title, html_selection?, target_workspace_id, target_space_id, target_parent_page_id?)
|
||||||
|
- HTML -> ProseMirror via `htmlToJson` (Tiptap), source attribution paragraph prepended
|
||||||
|
- Token CRUD JWT : POST/GET/DELETE /api/acadenice/clipper/tokens
|
||||||
|
- ClipperTokenService : bcrypt hash stocke en DB, validate() scan lineaire (< 100 tokens), last_used_at bump async
|
||||||
|
- Migration 20260509T120000 : table `acadenice_clipper_token` (id, user_id, workspace_id, token_hash, label, last_used_at, expires_at)
|
||||||
|
- Permission `clipper:use` ajoutee au catalogue (Admin/Editor/Member)
|
||||||
|
- 36 specs Jest : controller (8), service (5), token.service (9), dto (14)
|
||||||
|
|
||||||
|
**Extension** : `apps/extension-clipper/` workspace package `@docadenice/extension-clipper`
|
||||||
|
- MV3 Chrome + Firefox, Vite + @crxjs/vite-plugin, TypeScript strict
|
||||||
|
- Popup vanilla TS : onglet Clip (title, space, parent, selection badge, bouton Clip) + onglet Settings (URL, token, workspace/space IDs)
|
||||||
|
- Content script : `GET_PAGE_DATA` message handler, DOMPurify sanitization, canonical URL detection
|
||||||
|
- Background service worker : context menu "Clip to DocAdenice", chrome.action.openPopup() + fallback tab
|
||||||
|
- Helpers testables : `html-extractor.ts`, `api-client.ts`, `storage.ts`
|
||||||
|
- i18n EN/FR : 27 cles, detection auto navigator.language
|
||||||
|
- 23 tests Vitest : api-client (11), html-extractor (9), i18n (3)
|
||||||
|
- README.md : install dev, load Chrome/Firefox, zip production, generate token, securite
|
||||||
|
|
||||||
|
**Client** :
|
||||||
|
- `apps/client/src/features/acadenice/clipper/` : clipper-client.ts, clipper-query.ts (useClipperTokens/useCreateClipperToken/useRevokeClipperToken), clipper-tokens-page.tsx
|
||||||
|
- Route `/settings/clipper-tokens`, sidebar entry "Clipper tokens" avec IconScissors
|
||||||
|
- 1 test client : clipper-client.test.ts (3 specs)
|
||||||
|
|
||||||
|
**Install extension** :
|
||||||
|
```bash
|
||||||
|
npx --yes pnpm@10.4.0 --filter @docadenice/extension-clipper build
|
||||||
|
# Chrome : charger apps/extension-clipper/dist/
|
||||||
|
# Firefox : about:debugging -> Load Temporary Add-on -> dist/manifest.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Generer un token** : Settings > Clipper tokens > Generate token -> coller dans extension Settings
|
||||||
|
|
||||||
|
### R4.4 — AcadeDoc branding + Brevo SMTP + UI customization (2026-05-08)
|
||||||
|
|
||||||
|
Commit `b53ab50`, 27 fichiers, 1012 insertions.
|
||||||
|
- BRAND_NAME env var pilote le nom visible (titre HTML, PWA manifest, emails, header)
|
||||||
|
- BRAND_PRIMARY_COLOR / BRAND_ACCENT_COLOR : theming Mantine v7 via brand-theme.ts (10 shades par couleur, generation hex pure sans dep externe)
|
||||||
|
- /settings/branding : page admin pour les couleurs + lien vers General pour le logo
|
||||||
|
- POST /workspace/branding/update : endpoint admin-only avec validation hex (DTO regex)
|
||||||
|
- Migration 20260509T140000 : primary_color + accent_color dans workspaces
|
||||||
|
- transactional/README.md : guide ops Brevo complet (generation SMTP key, STARTTLS port 587, limites free 300/j, swaks/curl test)
|
||||||
|
- Tests : 11 client (brand-theme) + 13 server (workspace-branding) = 24 nouveaux, tous verts
|
||||||
|
- 0 TS errors client + server
|
||||||
|
|
||||||
### Mode Loop full autonome (decision 2026-05-08)
|
### Mode Loop full autonome (decision 2026-05-08)
|
||||||
Loop autonome R3.1.d -> R3.8 termine. Patch 017 fix typecheck post-install pnpm. Etat final : 0 TS error client + server, 313 tests client + 210 tests server + 380 tests bridge tous verts.
|
Loop autonome R3.1.d -> R3.8 termine. Patch 017 fix typecheck post-install pnpm. Etat final : 0 TS error client + server, 313 tests client + 210 tests server + 380 tests bridge tous verts.
|
||||||
|
|
||||||
## R4 progress
|
## R4 progress
|
||||||
|
|
||||||
|
### R4.5 — EE Settings replacement: Audit log, API keys, OIDC status — LIVRE
|
||||||
|
|
||||||
|
Commit : `5b512e6`, 37 fichiers, 2382 insertions, 0 TS errors client + server
|
||||||
|
Tests server : 36 verts (14 audit-log, 16 api-keys, 5 oidc-status) via Jest
|
||||||
|
Client typecheck : 0 erreurs. Client tests : 363 pass (1 pre-existing failure non liee clipper jest.mock/vitest)
|
||||||
|
|
||||||
|
**Probleme resolu** : le sidebar Settings affichait Security & SSO, API keys, Audit log comme desactives (EE feature gates) pointant vers des pages EE non chargees. Ces entrees sont desormais rewirees vers des pages open source sans feature gate.
|
||||||
|
|
||||||
|
**Server** :
|
||||||
|
- AcadeniceAuditLogModule : GET /api/acadenice/audit-log (admin/owner, Kysely, pagination offset + filtres userId/action/since/until). Table native `audit`.
|
||||||
|
- AcadeniceApiKeysModule : GET/POST/DELETE /api/acadenice/api-keys (JWT, bcrypt BCRYPT_ROUNDS=10, prefix acdk_, 64 random hex bytes). Table propre `acadenice_api_key` (migration 20260510T100000). Token retourne une seule fois, hash stocke uniquement.
|
||||||
|
- AcadeniceSecurityModule : GET /api/acadenice/security/oidc-status (admin/owner). Retourne {enabled, providerName, issuer, scopes, redirectUri, loginUrl}. Le clientSecret n'est pas inclus dans la reponse (non expose par design du controleur).
|
||||||
|
- permissions-catalog.ts : audit_log:read ajoute.
|
||||||
|
- core.module.ts : 3 nouveaux modules registres.
|
||||||
|
|
||||||
|
**Client** :
|
||||||
|
- features/acadenice/audit-log/ : types, service (GET /acadenice/audit-log), query, table component (Badge event, tooltip details JSONB tronque), page (filtres Select+TextInput+DatePickerInput, pagination offset, aria-labels).
|
||||||
|
- features/acadenice/api-keys/ : types, service (GET/POST/DELETE), queries (useCreate + useRevoke), 3 modals (create avec duree 30j/90j/1an/no-expiry, token one-time copy, revoke confirm), page liste.
|
||||||
|
- features/acadenice/oidc-status/ : types, service, query, SecurityPage (statut badge, infos issuer/scopes/redirectUri/loginUrl, reference toutes vars OIDC_*, section API keys best practices).
|
||||||
|
- App.tsx : 3 nouvelles routes settings/acadenice/audit-log, settings/acadenice/api-keys, settings/acadenice/security.
|
||||||
|
- settings-sidebar.tsx : rewired Security & SSO -> /settings/acadenice/security (role:admin, sans feature gate), API keys -> /settings/acadenice/api-keys (sans feature gate), Audit log -> /settings/acadenice/audit-log (role:admin, selfhosted, sans feature gate). API management garde son feature gate EE.
|
||||||
|
- settings-queries.tsx : prefetchAcadeniceAuditLogs + prefetchAcadeniceApiKeys ajoutes.
|
||||||
|
|
||||||
|
**Docs** : docs/security.md cree — OIDC (Authentik/Keycloak/Auth0 examples), API keys (rotation, RGPD), Audit log (events logges, retention SQL, RBAC).
|
||||||
|
|
||||||
|
**Note Kysely** : DbInterface utilise cles camelCase (workspaceId, actorId, createdAt). Les where clauses sur la table audit utilisent ces cles. Colonnes aliasees du join (actorEmail, actorName) castees via any car absentes du type statique.
|
||||||
|
|
||||||
### R4.1 — Timeline view (Gantt) — LIVRE
|
### R4.1 — Timeline view (Gantt) — LIVRE
|
||||||
|
|
||||||
Commit : TBD (voir git log -1 dans docmost/)
|
Commit : TBD (voir git log -1 dans docmost/)
|
||||||
|
|
@ -142,6 +224,89 @@ Total bridge apres R4.1 : 392 tests verts
|
||||||
- Fallback end = start+1j si endCol absent
|
- Fallback end = start+1j si endCol absent
|
||||||
- Toutes hooks declarees avant early returns (React rules-of-hooks respectees)
|
- Toutes hooks declarees avant early returns (React rules-of-hooks respectees)
|
||||||
|
|
||||||
|
### R4.2 — Sync blocks (Notion-style) — LIVRE
|
||||||
|
|
||||||
|
Commit : `23a8526`
|
||||||
|
Tests : 32 server Jest (controller 13, service 14, repo 5 pure-fn, broadcast 3) + 18 client Vitest (extension 5, node-view 10, insert 3) — tous verts
|
||||||
|
Permissions ajoutees au catalogue RBAC : `sync_blocks:create`, `sync_blocks:edit`, `sync_blocks:delete` (assignees Admin/Editor/Member)
|
||||||
|
0 TS errors client + server
|
||||||
|
|
||||||
|
**Architecture** :
|
||||||
|
- Table `acadenice_sync_block` (migration 20260509T100000) : id UUID, workspace_id FK, content JSONB, yjs_state BYTEA, created_by FK users RESTRICT
|
||||||
|
- Hocuspocus `PersistenceExtension` etendue : prefix `sync-block-*` route vers `loadSyncBlockDoc`/`storeSyncBlockDoc` (separate de la persistence pages)
|
||||||
|
- Cycle detection BFS depth 5 via `extractMasterIdsFromProseMirror` + `assertNoCycle` dans `SyncBlocksService` (`ConflictException('CYCLIC_SYNC_BLOCK')`)
|
||||||
|
- SSE broadcast : `SyncBlockBroadcastService` wraps EventEmitter2, hook client `useSyncBlockRealtime` reconnecte avec backoff expo 1s->30s
|
||||||
|
- Collab client : `HocuspocusProvider` pointe sur doc `sync-block-{masterId}`, overlay Mantine Modal
|
||||||
|
|
||||||
|
**Fichiers crees (server)** :
|
||||||
|
- `apps/server/src/database/migrations/20260509T100000-create-acadenice-sync-block.ts`
|
||||||
|
- `apps/server/src/core/acadenice/sync-blocks/dto/sync-block.dto.ts`
|
||||||
|
- `apps/server/src/core/acadenice/sync-blocks/repos/sync-block.repo.ts`
|
||||||
|
- `apps/server/src/core/acadenice/sync-blocks/services/sync-blocks.service.ts`
|
||||||
|
- `apps/server/src/core/acadenice/sync-blocks/services/sync-block-broadcast.service.ts`
|
||||||
|
- `apps/server/src/core/acadenice/sync-blocks/controllers/sync-blocks.controller.ts`
|
||||||
|
- `apps/server/src/core/acadenice/sync-blocks/sync-blocks.module.ts`
|
||||||
|
- `apps/server/src/core/acadenice/sync-blocks/spec/` (4 fichiers spec)
|
||||||
|
|
||||||
|
**Fichiers crees (client)** :
|
||||||
|
- `apps/client/src/features/acadenice/sync-blocks/extension/sync-block-extension.ts`
|
||||||
|
- `apps/client/src/features/acadenice/sync-blocks/components/sync-block-node-view.tsx`
|
||||||
|
- `apps/client/src/features/acadenice/sync-blocks/hooks/use-sync-block-realtime.ts`
|
||||||
|
- `apps/client/src/features/acadenice/sync-blocks/services/sync-blocks-client.ts`
|
||||||
|
- `apps/client/src/features/acadenice/sync-blocks/slash-command/insert-sync-block.ts`
|
||||||
|
- `apps/client/src/features/acadenice/sync-blocks/__tests__/` (3 fichiers test)
|
||||||
|
|
||||||
|
**Fichiers modifies** :
|
||||||
|
- `apps/server/src/collaboration/extensions/persistence.extension.ts` — routing sync-block-* docs
|
||||||
|
- `apps/server/src/collaboration/collaboration.module.ts` — providers SyncBlockRepo + SyncBlockBroadcastService
|
||||||
|
- `apps/server/src/core/acadenice/rbac/permissions-catalog.ts` — 3 perms sync_blocks
|
||||||
|
- `apps/server/src/core/acadenice/rbac/services/seed.service.ts` — assignation roles
|
||||||
|
- `apps/server/src/core/core.module.ts` — import AcadeniceSyncBlocksModule
|
||||||
|
- `apps/client/src/features/editor/extensions/extensions.ts` — SyncBlockExtension dans mainExtensions
|
||||||
|
- `apps/client/src/features/editor/components/slash-menu/menu-items.ts` — slash command /sync-block
|
||||||
|
|
||||||
|
**Note** : endpoint SSE `/api/acadenice/sync-blocks/:id/events` marque "planned R4.2.b" — hook client se reconnecte gracieusement, invalidation via Hocuspocus store broadcast fonctionnelle.
|
||||||
|
|
||||||
|
### Patch 031 — Baserow user JWT auto-login (2026-05-08)
|
||||||
|
|
||||||
|
Commit : (voir `git log -1 bridge/`)
|
||||||
|
Tests bridge apres Patch 031 : 417 verts (was 392 — +25 tests)
|
||||||
|
Typecheck : 0 erreurs
|
||||||
|
|
||||||
|
**Probleme resolu** : `GET /api/v1/views/table/:id` retournait 401 PERMISSION_DENIED de
|
||||||
|
Baserow car le DB token (BASEROW_API_TOKEN) ne peut pas appeler les endpoints metadata.
|
||||||
|
|
||||||
|
**Solution** : service account pattern. Un compte Baserow dedie se connecte via
|
||||||
|
`POST /api/user/token-auth/`, JWT conserve en memoire, refresh auto avant expiry.
|
||||||
|
|
||||||
|
**Fichiers crees** :
|
||||||
|
- `bridge/src/lib/baserow-jwt-manager.ts` : BaserowJwtManagerImpl (lazy-init, cache, mutex,
|
||||||
|
refresh fallback), BaserowJwtManagerDisabled (creds absentes → 503), factory `createBaserowJwtManager`
|
||||||
|
- `bridge/tests/lib/baserow-jwt-manager.test.ts` : 18 tests unitaires (mock fetch)
|
||||||
|
- `bridge/tests/routes/views-r4-jwt.test.ts` : 7 tests routes views avec JWT manager stub
|
||||||
|
- `bridge/docs/baserow-auth.md` : doc ops (endpoints Baserow, creation user service, vars env, verification)
|
||||||
|
|
||||||
|
**Fichiers modifies** :
|
||||||
|
- `bridge/src/lib/config.ts` : 3 nouvelles vars (`baserowUserEmail`, `baserowUserPassword`, `baserowJwtRefreshMargin`)
|
||||||
|
- `bridge/src/lib/container.ts` : champ `baserowJwt: BaserowJwtManager`, init au boot, log enabled/disabled
|
||||||
|
- `bridge/src/adapters/baserow-client.ts` : 2 nouvelles methodes `listViewsWithJwt` + `getTableWithJwt` (header `JWT <token>`)
|
||||||
|
- `bridge/src/repos/baserow-views-repo.ts` : `jwtManager` injectable, `listViewsResolved()` route via JWT si dispo, sinon DB token, 503 si unconfigured
|
||||||
|
- `bridge/tests/helpers/test-app.ts` : `baserowJwt` injectable dans test container
|
||||||
|
|
||||||
|
**Vars d'env a ajouter en prod** :
|
||||||
|
```
|
||||||
|
BASEROW_USER_EMAIL=bridge-svc@interne.local
|
||||||
|
BASEROW_USER_PASSWORD=<generated>
|
||||||
|
# optionnel
|
||||||
|
BASEROW_JWT_REFRESH_MARGIN=60
|
||||||
|
```
|
||||||
|
|
||||||
|
**Deploiement** :
|
||||||
|
1. Creer user Baserow via `/admin/users/` (Is staff: non, Member sur le workspace)
|
||||||
|
2. Ajouter les 2 vars en prod (Docker secrets / `.env`)
|
||||||
|
3. Redemarrer le bridge — log "Baserow user JWT manager enabled" confirme
|
||||||
|
4. Verifier : `curl -H "Authorization: Bearer brg_<token>" http://localhost:4000/api/v1/views/table/personne`
|
||||||
|
|
||||||
### Questions ouvertes a trancher post-/compact (2026-05-08)
|
### Questions ouvertes a trancher post-/compact (2026-05-08)
|
||||||
|
|
||||||
**Q1 — Strategie test E2E AI-driven (Claude Code / Stagehand / Computer Use)**
|
**Q1 — Strategie test E2E AI-driven (Claude Code / Stagehand / Computer Use)**
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,23 @@ NODE_ENV=development
|
||||||
PORT=4000
|
PORT=4000
|
||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
# Baserow API
|
# Baserow API — DB token (CRUD rows)
|
||||||
BASEROW_API_URL=http://baserow:80/api
|
BASEROW_API_URL=http://baserow:80/api
|
||||||
BASEROW_API_TOKEN=
|
BASEROW_API_TOKEN=
|
||||||
|
|
||||||
|
# Baserow service account — user JWT (endpoints metadata : views, tables detail)
|
||||||
|
# Le DB token (BASEROW_API_TOKEN) renvoie 401 PERMISSION_DENIED sur les endpoints
|
||||||
|
# comme GET /api/database/views/table/:id/. Un compte Baserow dedie resout ce probleme.
|
||||||
|
#
|
||||||
|
# Creer le compte dans l'interface Baserow : /admin/users/ > Add user.
|
||||||
|
# Privileges requis : acces en lecture a la database concernee (pas besoin d'admin).
|
||||||
|
# Exemple : email=bridge-svc@interne.local, mot de passe fort genere.
|
||||||
|
# Si absent, les routes /api/v1/views/* renvoient 500 BASEROW_USER_AUTH_NOT_CONFIGURED.
|
||||||
|
#
|
||||||
|
# BASEROW_USER_EMAIL=bridge-svc@interne.local
|
||||||
|
# BASEROW_USER_PASSWORD=generated-strong-password-here
|
||||||
|
# BASEROW_JWT_REFRESH_MARGIN=60 # secondes avant expiry ou le bridge refresh (defaut 60)
|
||||||
|
|
||||||
# Docmost API (optionnel — pas utilise par le bridge generique R1)
|
# Docmost API (optionnel — pas utilise par le bridge generique R1)
|
||||||
# DOCMOST_API_URL=http://docmost:3000/api
|
# DOCMOST_API_URL=http://docmost:3000/api
|
||||||
# DOCMOST_API_TOKEN=
|
# DOCMOST_API_TOKEN=
|
||||||
|
|
|
||||||
143
bridge/docs/baserow-auth.md
Normal file
143
bridge/docs/baserow-auth.md
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
# Baserow Authentication — Bridge Service
|
||||||
|
|
||||||
|
## Pourquoi deux types de tokens
|
||||||
|
|
||||||
|
Baserow expose deux mecanismes d'authentification aux comportements differents :
|
||||||
|
|
||||||
|
| Type | Header | Droits | Usages |
|
||||||
|
|------|--------|--------|--------|
|
||||||
|
| DB token | `Token brg_*` | CRUD rows uniquement | `listRows`, `createRow`, `updateRow`, `deleteRow` |
|
||||||
|
| User JWT | `JWT eyJ...` | Toutes les routes API | Metadata : `listViews`, `getTable`, `resolveTableIds` |
|
||||||
|
|
||||||
|
Le DB token (BASEROW_API_TOKEN) est suffisant pour 90% des cas mais Baserow renvoie
|
||||||
|
`401 PERMISSION_DENIED` sur les endpoints metadata. Ce n'est pas un bug : c'est la
|
||||||
|
conception Baserow qui distingue DB tokens (CRUD rows) des user sessions (lecture
|
||||||
|
full-API).
|
||||||
|
|
||||||
|
Le bridge utilise donc les deux :
|
||||||
|
- **DB token** : routes `/api/database/rows/table/:id/` — toujours actif
|
||||||
|
- **User JWT** : routes `/api/database/views/table/:id/`, `/api/database/tables/:id/` — actif si `BASEROW_USER_EMAIL` + `BASEROW_USER_PASSWORD` sont configures
|
||||||
|
|
||||||
|
## API Baserow — endpoints JWT
|
||||||
|
|
||||||
|
Source : docs.baserow.io + github.com/code-watch/baserow/blob/master/docs/getting-started/api.md
|
||||||
|
|
||||||
|
### Login
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/user/token-auth/
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{ "username": "email@example.com", "password": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
Reponse :
|
||||||
|
```json
|
||||||
|
{ "token": "eyJ..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
Le JWT est valide 60 minutes (valeur par defaut Baserow, configurable cote serveur).
|
||||||
|
|
||||||
|
### Refresh
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/user/token-refresh/
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{ "token": "eyJ..." (token courant) }
|
||||||
|
```
|
||||||
|
|
||||||
|
Reponse :
|
||||||
|
```json
|
||||||
|
{ "token": "eyJ..." (nouveau token) }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Header d'autorisation
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: JWT eyJ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Note : le prefixe est `JWT`, pas `Bearer` ni `Token`.
|
||||||
|
|
||||||
|
## Creer le compte service Baserow
|
||||||
|
|
||||||
|
1. Ouvrir `http://<baserow-host>/admin/users/`
|
||||||
|
2. Cliquer "Add user"
|
||||||
|
3. Remplir :
|
||||||
|
- Email : `bridge-svc@interne.local` (ou equivalent interne)
|
||||||
|
- Password : generer un mot de passe fort (min 16 chars, stocker dans Vault ou secret manager)
|
||||||
|
- Is active : oui
|
||||||
|
- Is staff : non (pas besoin d'admin)
|
||||||
|
4. Ajouter ce user aux groupes (workspaces) ou il doit lire les tables
|
||||||
|
- Permission "Member" suffit pour lire views + tables
|
||||||
|
5. Verifier : `curl -X POST http://baserow/api/user/token-auth/ -d '{"username":"bridge-svc@...","password":"..."}'`
|
||||||
|
|
||||||
|
## Variables d'environnement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Requis pour activer le JWT manager
|
||||||
|
BASEROW_USER_EMAIL=bridge-svc@interne.local
|
||||||
|
BASEROW_USER_PASSWORD=generated-strong-password
|
||||||
|
|
||||||
|
# Optionnel — refresh le JWT N secondes avant son expiration (defaut 60)
|
||||||
|
BASEROW_JWT_REFRESH_MARGIN=60
|
||||||
|
```
|
||||||
|
|
||||||
|
Si ces variables sont absentes, le bridge continue a fonctionner pour les CRUD rows.
|
||||||
|
Les routes views renvoient `500 BASEROW_USER_AUTH_NOT_CONFIGURED`.
|
||||||
|
|
||||||
|
## Verification que ca marche
|
||||||
|
|
||||||
|
### Via le health endpoint
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:4000/api/health
|
||||||
|
# { "status": "ok", "service": "bridge", "version": "0.1.0" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Via la route views
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s \
|
||||||
|
-H "Authorization: Bearer brg_<votre-token>" \
|
||||||
|
"http://localhost:4000/api/v1/views/table/personne"
|
||||||
|
# { "data": [...], "total": N }
|
||||||
|
```
|
||||||
|
|
||||||
|
Avant Patch 031, cette route retournait 401 de Baserow.
|
||||||
|
Apres Patch 031 avec JWT configure, elle retourne les vues de la table.
|
||||||
|
|
||||||
|
### Verifier les logs au boot
|
||||||
|
|
||||||
|
```
|
||||||
|
INFO: Baserow user JWT manager enabled (email: bridge-svc@interne.local)
|
||||||
|
```
|
||||||
|
|
||||||
|
Si vous voyez a la place :
|
||||||
|
```
|
||||||
|
INFO: Baserow user JWT manager disabled — metadata endpoints will return 503 if called
|
||||||
|
```
|
||||||
|
c'est que BASEROW_USER_EMAIL ou BASEROW_USER_PASSWORD est absent.
|
||||||
|
|
||||||
|
## Architecture interne
|
||||||
|
|
||||||
|
```
|
||||||
|
getToken() appel ─► BaserowJwtManagerImpl
|
||||||
|
├─ token cache frais ? → retour immediat
|
||||||
|
├─ token bientot expire ? → POST /api/user/token-refresh/
|
||||||
|
│ └─ echec ? → fallback POST /api/user/token-auth/
|
||||||
|
└─ pas de token ? → POST /api/user/token-auth/
|
||||||
|
|
||||||
|
Mutex : si 10 appels simultanes → 1 seul login/refresh, les 9 autres attendent.
|
||||||
|
```
|
||||||
|
|
||||||
|
`BaserowClient.listViewsWithJwt(tableId, jwt)` utilise `Authorization: JWT <jwt>` vers Baserow.
|
||||||
|
|
||||||
|
## Bonnes pratiques securite
|
||||||
|
|
||||||
|
- Le password est remplace par `***` dans les logs (voir `baserow-jwt-manager.ts`, appels `this.logger.error`)
|
||||||
|
- Le JWT reste en memoire uniquement (pas de Redis, pas de fichier disque)
|
||||||
|
- Le JWT n'est pas retransmis dans les reponses HTTP du bridge vers les clients
|
||||||
|
- En cas de rotation du mot de passe Baserow : redemarrer le bridge (re-login au premier appel)
|
||||||
|
- Stocker BASEROW_USER_PASSWORD dans un secret manager (Vault, Docker secrets, k8s Secret)
|
||||||
|
|
@ -70,6 +70,72 @@ export class BaserowClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch variant using a user JWT instead of the DB token.
|
||||||
|
* Required for metadata endpoints: GET /api/database/views/table/:id/,
|
||||||
|
* GET /api/database/tables/:id/, etc.
|
||||||
|
* Authorization format: "JWT <token>" (Baserow docs, CLAIM L3).
|
||||||
|
*/
|
||||||
|
private fetchWithUserJwt<T>(
|
||||||
|
path: string,
|
||||||
|
userJwt: string,
|
||||||
|
init?: { method?: string; body?: string },
|
||||||
|
): Promise<T> {
|
||||||
|
const url = `${this.baseUrl}${path}`;
|
||||||
|
return ofetch<T>(url, {
|
||||||
|
method: init?.method,
|
||||||
|
body: init?.body,
|
||||||
|
headers: {
|
||||||
|
Authorization: `JWT ${userJwt}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
retry: 1,
|
||||||
|
retryDelay: 200,
|
||||||
|
timeout: 10_000,
|
||||||
|
onResponseError: ({ response }) => {
|
||||||
|
this.logger.error(
|
||||||
|
{ status: response.status, url, body: response._data },
|
||||||
|
'baserow user jwt 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 resource', path);
|
||||||
|
if (!error.response) throw errors.baserowDown();
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste les vues d'une table avec un JWT user.
|
||||||
|
* Remplace `listViews` qui utilise le DB token (401 sur cet endpoint).
|
||||||
|
*/
|
||||||
|
async listViewsWithJwt(
|
||||||
|
tableId: number,
|
||||||
|
userJwt: string,
|
||||||
|
): Promise<
|
||||||
|
Array<{ id: number; name: string; type: string; table_id: number } & Record<string, unknown>>
|
||||||
|
> {
|
||||||
|
return this.fetchWithUserJwt<
|
||||||
|
Array<{ id: number; name: string; type: string; table_id: number } & Record<string, unknown>>
|
||||||
|
>(`/api/database/views/table/${tableId}/`, userJwt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recupere les metadata d'une table avec un JWT user.
|
||||||
|
* Le DB token renvoie 401 sur cet endpoint.
|
||||||
|
*/
|
||||||
|
async getTableWithJwt(
|
||||||
|
tableId: number,
|
||||||
|
userJwt: string,
|
||||||
|
): Promise<{ id: number; name: string; order: number; database_id: number }> {
|
||||||
|
return this.fetchWithUserJwt<{ id: number; name: string; order: number; database_id: number }>(
|
||||||
|
`/api/database/tables/${tableId}/`,
|
||||||
|
userJwt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liste les rows d'une table avec pagination.
|
* Liste les rows d'une table avec pagination.
|
||||||
* Retourne automatiquement les noms de fields (`user_field_names=true`) pour eviter le mapping field_id.
|
* Retourne automatiquement les noms de fields (`user_field_names=true`) pour eviter le mapping field_id.
|
||||||
|
|
|
||||||
285
bridge/src/lib/baserow-jwt-manager.ts
Normal file
285
bridge/src/lib/baserow-jwt-manager.ts
Normal file
|
|
@ -0,0 +1,285 @@
|
||||||
|
/**
|
||||||
|
* BaserowJwtManager — auto-login service account pattern.
|
||||||
|
*
|
||||||
|
* Pourquoi ce module existe :
|
||||||
|
* Le DB token Baserow (Token brg_*) suffit pour les CRUD rows mais Baserow
|
||||||
|
* renvoie 401 PERMISSION_DENIED sur les endpoints metadata (views, tables
|
||||||
|
* detail). Ces endpoints necessitent un JWT user obtenu via token-auth.
|
||||||
|
*
|
||||||
|
* Pattern :
|
||||||
|
* - Lazy init : pas de login au boot, premier getToken() declenche le login.
|
||||||
|
* - Cache memoire : le JWT est conserve jusqu'a `exp - refreshMarginSeconds`.
|
||||||
|
* - Refresh : POST /api/user/token-refresh/ avec le token courant avant expiry.
|
||||||
|
* - Mutex : un seul refresh concurrent (lock promesse partage).
|
||||||
|
* - Fallback : si creds absentes, isEnabled() = false ; getToken() leve une
|
||||||
|
* erreur BASEROW_USER_AUTH_NOT_CONFIGURED que le caller transforme en 503.
|
||||||
|
*
|
||||||
|
* Sources :
|
||||||
|
* - baserow.io/docs/apis/rest-api : POST /api/user/token-auth/ (CLAIM L3)
|
||||||
|
* - github.com/code-watch/baserow/blob/master/docs/getting-started/api.md : JWT 60min (CLAIM L3)
|
||||||
|
* - community.baserow.io/t/jwt-token-authentication/4138 : email not username (CLAIM L3)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Logger } from 'pino';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public interface
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface BaserowJwtManager {
|
||||||
|
/** Returns a valid JWT. Refreshes if close to expiry. */
|
||||||
|
getToken(): Promise<string>;
|
||||||
|
/** True if user credentials are configured. */
|
||||||
|
isEnabled(): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface TokenAuthResponse {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DecodedJwtPayload {
|
||||||
|
exp?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode the payload of a JWT without verifying signature.
|
||||||
|
* Used only to read `exp` so we know when to refresh.
|
||||||
|
*/
|
||||||
|
function decodeJwtPayload(token: string): DecodedJwtPayload {
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3) return {};
|
||||||
|
try {
|
||||||
|
const raw = parts[1];
|
||||||
|
if (!raw) return {};
|
||||||
|
// atob is available in Node 22 globalThis. Buffer fallback for safety.
|
||||||
|
const decoded =
|
||||||
|
typeof atob === 'function'
|
||||||
|
? atob(raw.replace(/-/g, '+').replace(/_/g, '/'))
|
||||||
|
: Buffer.from(raw, 'base64url').toString('utf8');
|
||||||
|
return JSON.parse(decoded) as DecodedJwtPayload;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Implementation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class BaserowJwtManagerImpl implements BaserowJwtManager {
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
private readonly email: string;
|
||||||
|
private readonly password: string;
|
||||||
|
private readonly refreshMarginSeconds: number;
|
||||||
|
private readonly logger: Logger;
|
||||||
|
|
||||||
|
private cachedToken: string | null = null;
|
||||||
|
private tokenExp: number | null = null; // unix timestamp seconds
|
||||||
|
// Lock: prevents concurrent logins/refreshes under burst conditions.
|
||||||
|
private inflightRefresh: Promise<string> | null = null;
|
||||||
|
|
||||||
|
constructor(opts: {
|
||||||
|
baseUrl: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
refreshMarginSeconds: number;
|
||||||
|
logger: Logger;
|
||||||
|
}) {
|
||||||
|
this.baseUrl = opts.baseUrl.replace(/\/$/, '');
|
||||||
|
this.email = opts.email;
|
||||||
|
this.password = opts.password;
|
||||||
|
this.refreshMarginSeconds = opts.refreshMarginSeconds;
|
||||||
|
this.logger = opts.logger.child({ service: 'baserow-jwt-manager' });
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getToken(): Promise<string> {
|
||||||
|
// If we have a cached token that is still fresh, return it immediately.
|
||||||
|
if (this.cachedToken !== null && this.isTokenFresh()) {
|
||||||
|
return this.cachedToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedup concurrent refresh: if a refresh is already in flight, await it.
|
||||||
|
if (this.inflightRefresh !== null) {
|
||||||
|
return this.inflightRefresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.inflightRefresh = this.acquireToken().finally(() => {
|
||||||
|
this.inflightRefresh = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.inflightRefresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Private
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private isTokenFresh(): boolean {
|
||||||
|
if (this.tokenExp === null) return false;
|
||||||
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||||
|
return this.tokenExp - this.refreshMarginSeconds > nowSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private storeToken(token: string): void {
|
||||||
|
this.cachedToken = token;
|
||||||
|
const payload = decodeJwtPayload(token);
|
||||||
|
if (typeof payload.exp === 'number') {
|
||||||
|
this.tokenExp = payload.exp;
|
||||||
|
this.logger.debug(
|
||||||
|
{ exp: this.tokenExp, margin: this.refreshMarginSeconds },
|
||||||
|
'jwt token stored',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// No exp claim — default 55 min from now to stay safe within 60min Baserow TTL.
|
||||||
|
this.tokenExp = Math.floor(Date.now() / 1000) + 55 * 60;
|
||||||
|
this.logger.warn('jwt token has no exp claim — defaulting to 55min TTL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acquire a token: refresh if we have a stale cached token, else login from scratch.
|
||||||
|
*/
|
||||||
|
private async acquireToken(): Promise<string> {
|
||||||
|
if (this.cachedToken !== null && !this.isTokenFresh()) {
|
||||||
|
// Try refresh first; fall back to full login on failure.
|
||||||
|
try {
|
||||||
|
const token = await this.doRefresh(this.cachedToken);
|
||||||
|
this.storeToken(token);
|
||||||
|
this.logger.info('baserow jwt refreshed');
|
||||||
|
return token;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn({ err }, 'baserow jwt refresh failed — falling back to full login');
|
||||||
|
// Fall through to full login below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await this.doLogin();
|
||||||
|
this.storeToken(token);
|
||||||
|
this.logger.info('baserow jwt login succeeded');
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doLogin(): Promise<string> {
|
||||||
|
this.logger.debug({ email: this.email }, 'baserow jwt login');
|
||||||
|
const url = `${this.baseUrl}/api/user/token-auth/`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: this.email, password: this.password }),
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => '');
|
||||||
|
this.logger.error(
|
||||||
|
{ status: res.status, email: this.email, password: '***' },
|
||||||
|
'baserow jwt login failed',
|
||||||
|
);
|
||||||
|
throw new BaserowAuthError(
|
||||||
|
`BRIDGE_BASEROW_AUTH_FAILED: login returned ${res.status} — ${body}`,
|
||||||
|
res.status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as TokenAuthResponse;
|
||||||
|
if (!data.token) {
|
||||||
|
throw new BaserowAuthError('BRIDGE_BASEROW_AUTH_FAILED: response missing token field', 502);
|
||||||
|
}
|
||||||
|
return data.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doRefresh(currentToken: string): Promise<string> {
|
||||||
|
this.logger.debug('baserow jwt refresh');
|
||||||
|
const url = `${this.baseUrl}/api/user/token-refresh/`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token: currentToken }),
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new BaserowAuthError(
|
||||||
|
`BRIDGE_BASEROW_AUTH_FAILED: refresh returned ${res.status}`,
|
||||||
|
res.status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as TokenAuthResponse;
|
||||||
|
if (!data.token) {
|
||||||
|
throw new BaserowAuthError(
|
||||||
|
'BRIDGE_BASEROW_AUTH_FAILED: refresh response missing token field',
|
||||||
|
502,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return data.token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Disabled stub (when creds not configured)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class BaserowJwtManagerDisabled implements BaserowJwtManager {
|
||||||
|
isEnabled(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getToken(): Promise<string> {
|
||||||
|
return Promise.reject(
|
||||||
|
new BaserowAuthError(
|
||||||
|
'BASEROW_USER_AUTH_NOT_CONFIGURED: set BASEROW_USER_EMAIL and BASEROW_USER_PASSWORD to enable metadata endpoints',
|
||||||
|
503,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Error class
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class BaserowAuthError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly statusCode: number,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'BaserowAuthError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Factory
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function createBaserowJwtManager(opts: {
|
||||||
|
baseUrl: string;
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
refreshMarginSeconds: number;
|
||||||
|
logger: Logger;
|
||||||
|
}): BaserowJwtManager {
|
||||||
|
if (opts.email && opts.password) {
|
||||||
|
return new BaserowJwtManagerImpl({
|
||||||
|
baseUrl: opts.baseUrl,
|
||||||
|
email: opts.email,
|
||||||
|
password: opts.password,
|
||||||
|
refreshMarginSeconds: opts.refreshMarginSeconds,
|
||||||
|
logger: opts.logger,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new BaserowJwtManagerDisabled();
|
||||||
|
}
|
||||||
|
|
@ -39,6 +39,16 @@ const ConfigSchema = z.object({
|
||||||
// global (XADD MAXLEN ~). Défaut 10 000 events. Les events plus anciens sont
|
// global (XADD MAXLEN ~). Défaut 10 000 events. Les events plus anciens sont
|
||||||
// purgés automatiquement par Redis (mode ~ = approximatif, plus performant).
|
// purgés automatiquement par Redis (mode ~ = approximatif, plus performant).
|
||||||
streamMaxLen: z.coerce.number().int().positive().default(10_000),
|
streamMaxLen: z.coerce.number().int().positive().default(10_000),
|
||||||
|
// Baserow service account — user JWT pour les endpoints metadata (views, tables detail).
|
||||||
|
// Un DB token (Token brg_*) suffit pour CRUD rows mais renvoie 401 PERMISSION_DENIED
|
||||||
|
// sur GET /api/database/views/table/:id/ etc. Un compte Baserow dedie resout ce probleme.
|
||||||
|
// Creer le compte via /admin/users/ dans l'interface Baserow.
|
||||||
|
// Si absent, les endpoints metadata renvoient 503 BASEROW_USER_AUTH_NOT_CONFIGURED.
|
||||||
|
baserowUserEmail: z.string().email().optional(),
|
||||||
|
baserowUserPassword: z.string().min(1).optional(),
|
||||||
|
// Refresh le JWT `refreshMarginSeconds` secondes avant son expiration (defaut : 60s).
|
||||||
|
// Baserow JWT valide 60min par defaut — refresh a t=59min.
|
||||||
|
baserowJwtRefreshMargin: z.coerce.number().int().positive().default(60),
|
||||||
// Optional slug -> table_id map so callers can use human-friendly slugs
|
// Optional slug -> table_id map so callers can use human-friendly slugs
|
||||||
// (e.g. /api/v1/views/table/personne) instead of the numeric Baserow ID.
|
// (e.g. /api/v1/views/table/personne) instead of the numeric Baserow ID.
|
||||||
// Format: JSON object string like '{"personne":609,"formation":610}'.
|
// Format: JSON object string like '{"personne":609,"formation":610}'.
|
||||||
|
|
@ -89,6 +99,9 @@ export function loadConfig(): Config {
|
||||||
rateLimitMutationMax: process.env.RATE_LIMIT_MUTATION_MAX,
|
rateLimitMutationMax: process.env.RATE_LIMIT_MUTATION_MAX,
|
||||||
rateLimitMutationWindow: process.env.RATE_LIMIT_MUTATION_WINDOW,
|
rateLimitMutationWindow: process.env.RATE_LIMIT_MUTATION_WINDOW,
|
||||||
streamMaxLen: process.env.STREAM_MAXLEN,
|
streamMaxLen: process.env.STREAM_MAXLEN,
|
||||||
|
baserowUserEmail: process.env.BASEROW_USER_EMAIL,
|
||||||
|
baserowUserPassword: process.env.BASEROW_USER_PASSWORD,
|
||||||
|
baserowJwtRefreshMargin: process.env.BASEROW_JWT_REFRESH_MARGIN,
|
||||||
baserowTableIds: process.env.BASEROW_TABLE_IDS,
|
baserowTableIds: process.env.BASEROW_TABLE_IDS,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ import { BaserowFieldsRepo } from '../repos/baserow-fields-repo.js';
|
||||||
import { BaserowRowsRepo } from '../repos/baserow-rows-repo.js';
|
import { BaserowRowsRepo } from '../repos/baserow-rows-repo.js';
|
||||||
import { BaserowTablesRepo } from '../repos/baserow-tables-repo.js';
|
import { BaserowTablesRepo } from '../repos/baserow-tables-repo.js';
|
||||||
import { BaserowViewsRepo } from '../repos/baserow-views-repo.js';
|
import { BaserowViewsRepo } from '../repos/baserow-views-repo.js';
|
||||||
|
import type { BaserowJwtManager } from './baserow-jwt-manager.js';
|
||||||
|
import { createBaserowJwtManager } from './baserow-jwt-manager.js';
|
||||||
import type { Config } from './config.js';
|
import type { Config } from './config.js';
|
||||||
import { isDocmostJwtEnabled, isOidcEnabled } from './config.js';
|
import { isDocmostJwtEnabled, isOidcEnabled } from './config.js';
|
||||||
import { logger as rootLogger } from './logger.js';
|
import { logger as rootLogger } from './logger.js';
|
||||||
|
|
@ -43,6 +45,8 @@ export interface Container {
|
||||||
docmostJwt: DocmostJwtVerifier | null;
|
docmostJwt: DocmostJwtVerifier | null;
|
||||||
groupsScopesMap: GroupsScopesMap;
|
groupsScopesMap: GroupsScopesMap;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
|
/** Gestionnaire JWT user Baserow pour endpoints metadata. Toujours present. */
|
||||||
|
baserowJwt: BaserowJwtManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
let _container: Container | null = null;
|
let _container: Container | null = null;
|
||||||
|
|
@ -76,11 +80,21 @@ export async function initContainer(opts: InitOptions): Promise<Container> {
|
||||||
});
|
});
|
||||||
const redis = opts.redis ?? new RedisCache({ url: config.redisUrl, logger: rootLogger });
|
const redis = opts.redis ?? new RedisCache({ url: config.redisUrl, logger: rootLogger });
|
||||||
|
|
||||||
|
// JWT manager created before repos so views repo can inject it.
|
||||||
|
// We create it early with a partial config (email/password may be undefined).
|
||||||
|
const baserowJwtEarly = createBaserowJwtManager({
|
||||||
|
baseUrl: config.baserowApiUrl,
|
||||||
|
email: config.baserowUserEmail,
|
||||||
|
password: config.baserowUserPassword,
|
||||||
|
refreshMarginSeconds: config.baserowJwtRefreshMargin,
|
||||||
|
logger: rootLogger,
|
||||||
|
});
|
||||||
|
|
||||||
const repos: RepoSet = {
|
const repos: RepoSet = {
|
||||||
tables: new BaserowTablesRepo({ client: baserow, logger: rootLogger }),
|
tables: new BaserowTablesRepo({ client: baserow, logger: rootLogger }),
|
||||||
rows: new BaserowRowsRepo({ client: baserow, logger: rootLogger }),
|
rows: new BaserowRowsRepo({ client: baserow, logger: rootLogger }),
|
||||||
fields: new BaserowFieldsRepo({ client: baserow, logger: rootLogger }),
|
fields: new BaserowFieldsRepo({ client: baserow, logger: rootLogger }),
|
||||||
views: new BaserowViewsRepo({ client: baserow, logger: rootLogger }),
|
views: new BaserowViewsRepo({ client: baserow, logger: rootLogger, jwtManager: baserowJwtEarly }),
|
||||||
};
|
};
|
||||||
|
|
||||||
const tokens = parseTokens(config.bridgeApiTokens);
|
const tokens = parseTokens(config.bridgeApiTokens);
|
||||||
|
|
@ -118,6 +132,20 @@ export async function initContainer(opts: InitOptions): Promise<Container> {
|
||||||
rootLogger.info('DocAdenice JWT HMAC mode disabled');
|
rootLogger.info('DocAdenice JWT HMAC mode disabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// baserowJwtEarly is already the definitive instance (created before repos).
|
||||||
|
const baserowJwt = baserowJwtEarly;
|
||||||
|
|
||||||
|
if (baserowJwt.isEnabled()) {
|
||||||
|
rootLogger.info(
|
||||||
|
{ email: config.baserowUserEmail },
|
||||||
|
'Baserow user JWT manager enabled',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
rootLogger.info(
|
||||||
|
'Baserow user JWT manager disabled — metadata endpoints will return 503 if called',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const container: Container = {
|
const container: Container = {
|
||||||
config,
|
config,
|
||||||
baserow,
|
baserow,
|
||||||
|
|
@ -128,6 +156,7 @@ export async function initContainer(opts: InitOptions): Promise<Container> {
|
||||||
docmostJwt,
|
docmostJwt,
|
||||||
groupsScopesMap,
|
groupsScopesMap,
|
||||||
logger: rootLogger,
|
logger: rootLogger,
|
||||||
|
baserowJwt,
|
||||||
};
|
};
|
||||||
setContainer(container);
|
setContainer(container);
|
||||||
return container;
|
return container;
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,17 @@ import type { Logger } from 'pino';
|
||||||
import type { BaserowClient, BaserowListOptions } from '../adapters/baserow-client.js';
|
import type { BaserowClient, BaserowListOptions } from '../adapters/baserow-client.js';
|
||||||
import type { RedisCache } from '../adapters/redis-cache.js';
|
import type { RedisCache } from '../adapters/redis-cache.js';
|
||||||
import { Row } from '../domain/row.js';
|
import { Row } from '../domain/row.js';
|
||||||
|
import type { BaserowJwtManager } from '../lib/baserow-jwt-manager.js';
|
||||||
|
import { BaserowAuthError } from '../lib/baserow-jwt-manager.js';
|
||||||
|
import { errors } from '../lib/errors.js';
|
||||||
import type { ViewFilter, ViewGroupBy, ViewSorting } from '../domain/view.js';
|
import type { ViewFilter, ViewGroupBy, ViewSorting } from '../domain/view.js';
|
||||||
import { View } from '../domain/view.js';
|
import { View } from '../domain/view.js';
|
||||||
|
|
||||||
export interface BaserowViewsRepoOptions {
|
export interface BaserowViewsRepoOptions {
|
||||||
client: BaserowClient;
|
client: BaserowClient;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
|
/** JWT manager pour les endpoints metadata Baserow. */
|
||||||
|
jwtManager?: BaserowJwtManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListRowsResult {
|
export interface ListRowsResult {
|
||||||
|
|
@ -135,10 +140,37 @@ function mapRawToView(
|
||||||
export class BaserowViewsRepo {
|
export class BaserowViewsRepo {
|
||||||
protected readonly client: BaserowClient;
|
protected readonly client: BaserowClient;
|
||||||
protected readonly logger: Logger;
|
protected readonly logger: Logger;
|
||||||
|
protected readonly jwtManager: BaserowJwtManager | undefined;
|
||||||
|
|
||||||
constructor(opts: BaserowViewsRepoOptions) {
|
constructor(opts: BaserowViewsRepoOptions) {
|
||||||
this.client = opts.client;
|
this.client = opts.client;
|
||||||
this.logger = opts.logger.child({ repo: 'views' });
|
this.logger = opts.logger.child({ repo: 'views' });
|
||||||
|
this.jwtManager = opts.jwtManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appelle listViews via JWT user si disponible, sinon tente le DB token.
|
||||||
|
* Si le JWT manager est absent ou non configure, renvoie une erreur claire.
|
||||||
|
*/
|
||||||
|
private async listViewsResolved(tableId: number): Promise<
|
||||||
|
Array<{ id: number; name: string; type: string; table_id: number } & Record<string, unknown>>
|
||||||
|
> {
|
||||||
|
if (this.jwtManager) {
|
||||||
|
let jwt: string;
|
||||||
|
try {
|
||||||
|
jwt = await this.jwtManager.getToken();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof BaserowAuthError && err.statusCode === 503) {
|
||||||
|
throw errors.internal(
|
||||||
|
'BASEROW_USER_AUTH_NOT_CONFIGURED: configure BASEROW_USER_EMAIL and BASEROW_USER_PASSWORD to access view metadata endpoints',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return this.client.listViewsWithJwt(tableId, jwt);
|
||||||
|
}
|
||||||
|
// Pas de JWT manager — DB token, peut echouer avec 401 selon la version Baserow.
|
||||||
|
return this.client.listViews(tableId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -146,7 +178,7 @@ export class BaserowViewsRepo {
|
||||||
* Compat R1 — sans cache Redis, retourne View[] simple.
|
* Compat R1 — sans cache Redis, retourne View[] simple.
|
||||||
*/
|
*/
|
||||||
async list(tableId: number): Promise<View[]> {
|
async list(tableId: number): Promise<View[]> {
|
||||||
const raws = await this.client.listViews(tableId);
|
const raws = await this.listViewsResolved(tableId);
|
||||||
return raws.map(mapRawToView);
|
return raws.map(mapRawToView);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -169,7 +201,7 @@ export class BaserowViewsRepo {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const raws = await this.client.listViews(tableId);
|
const raws = await this.listViewsResolved(tableId);
|
||||||
const views = raws.map(mapRawToView);
|
const views = raws.map(mapRawToView);
|
||||||
|
|
||||||
if (redis) {
|
if (redis) {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ import { Hono } from 'hono';
|
||||||
import { logger as honoLogger } from 'hono/logger';
|
import { logger as honoLogger } from 'hono/logger';
|
||||||
import type { BaserowClient } from '../../src/adapters/baserow-client.js';
|
import type { BaserowClient } from '../../src/adapters/baserow-client.js';
|
||||||
import type { RedisCache } from '../../src/adapters/redis-cache.js';
|
import type { RedisCache } from '../../src/adapters/redis-cache.js';
|
||||||
|
import type { BaserowJwtManager } from '../../src/lib/baserow-jwt-manager.js';
|
||||||
|
import { BaserowJwtManagerDisabled } from '../../src/lib/baserow-jwt-manager.js';
|
||||||
import type { Container, RepoSet } from '../../src/lib/container.js';
|
import type { Container, RepoSet } from '../../src/lib/container.js';
|
||||||
import { setContainer } from '../../src/lib/container.js';
|
import { setContainer } from '../../src/lib/container.js';
|
||||||
import { logger } from '../../src/lib/logger.js';
|
import { logger } from '../../src/lib/logger.js';
|
||||||
|
|
@ -37,6 +39,7 @@ export interface TestContainerOverrides {
|
||||||
baserow?: BaserowClient;
|
baserow?: BaserowClient;
|
||||||
redis?: RedisCache;
|
redis?: RedisCache;
|
||||||
tokens?: ApiTokenRecord[];
|
tokens?: ApiTokenRecord[];
|
||||||
|
baserowJwt?: BaserowJwtManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -75,6 +78,7 @@ export function installTestContainer(over: TestContainerOverrides): Container {
|
||||||
rateLimitMutationMax: 10000,
|
rateLimitMutationMax: 10000,
|
||||||
rateLimitMutationWindow: 60,
|
rateLimitMutationWindow: 60,
|
||||||
streamMaxLen: 10000,
|
streamMaxLen: 10000,
|
||||||
|
baserowJwtRefreshMargin: 60,
|
||||||
},
|
},
|
||||||
baserow: fakeBaserow,
|
baserow: fakeBaserow,
|
||||||
redis: fakeRedis,
|
redis: fakeRedis,
|
||||||
|
|
@ -84,6 +88,7 @@ export function installTestContainer(over: TestContainerOverrides): Container {
|
||||||
docmostJwt: null,
|
docmostJwt: null,
|
||||||
groupsScopesMap: {},
|
groupsScopesMap: {},
|
||||||
logger,
|
logger,
|
||||||
|
baserowJwt: over.baserowJwt ?? new BaserowJwtManagerDisabled(),
|
||||||
};
|
};
|
||||||
setContainer(container);
|
setContainer(container);
|
||||||
return container;
|
return container;
|
||||||
|
|
|
||||||
327
bridge/tests/lib/baserow-jwt-manager.test.ts
Normal file
327
bridge/tests/lib/baserow-jwt-manager.test.ts
Normal file
|
|
@ -0,0 +1,327 @@
|
||||||
|
/**
|
||||||
|
* Tests unitaires BaserowJwtManager — Patch 031.
|
||||||
|
*
|
||||||
|
* Strategie : mock global.fetch pour controler les reponses Baserow.
|
||||||
|
* Pas d'appel reseau reel.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
BaserowAuthError,
|
||||||
|
BaserowJwtManagerDisabled,
|
||||||
|
BaserowJwtManagerImpl,
|
||||||
|
createBaserowJwtManager,
|
||||||
|
} from '../../src/lib/baserow-jwt-manager.js';
|
||||||
|
import { logger } from '../../src/lib/logger.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a minimal JWT with a given `exp` unix timestamp.
|
||||||
|
* Signature is fake — we only need the payload to be parseable.
|
||||||
|
*/
|
||||||
|
function buildFakeJwt(expOffsetSeconds: number): string {
|
||||||
|
const exp = Math.floor(Date.now() / 1000) + expOffsetSeconds;
|
||||||
|
const payload = Buffer.from(JSON.stringify({ sub: 'bridge-svc', exp })).toString('base64url');
|
||||||
|
return `header.${payload}.signature`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFakeJwtNoExp(): string {
|
||||||
|
const payload = Buffer.from(JSON.stringify({ sub: 'bridge-svc' })).toString('base64url');
|
||||||
|
return `header.${payload}.signature`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockFetch(responses: Array<{ ok: boolean; status: number; body: unknown }>) {
|
||||||
|
let call = 0;
|
||||||
|
return vi.fn(async () => {
|
||||||
|
const spec = responses[call++] ?? responses[responses.length - 1];
|
||||||
|
return {
|
||||||
|
ok: spec!.ok,
|
||||||
|
status: spec!.status,
|
||||||
|
json: async () => spec!.body,
|
||||||
|
text: async () => JSON.stringify(spec!.body),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const silentLogger = logger.child({ test: true });
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// BaserowJwtManagerImpl
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('BaserowJwtManagerImpl', () => {
|
||||||
|
let originalFetch: typeof global.fetch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalFetch = global.fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildManager(overrides?: { refreshMarginSeconds?: number }) {
|
||||||
|
return new BaserowJwtManagerImpl({
|
||||||
|
baseUrl: 'http://baserow.test',
|
||||||
|
email: 'bridge@acadenice.com',
|
||||||
|
password: 'secret',
|
||||||
|
refreshMarginSeconds: overrides?.refreshMarginSeconds ?? 60,
|
||||||
|
logger: silentLogger,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. isEnabled returns true
|
||||||
|
it('isEnabled returns true', () => {
|
||||||
|
const mgr = buildManager();
|
||||||
|
expect(mgr.isEnabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. initial login — token returned
|
||||||
|
it('initial login returns token', async () => {
|
||||||
|
const token = buildFakeJwt(3600);
|
||||||
|
global.fetch = mockFetch([{ ok: true, status: 200, body: { token } }]) as typeof global.fetch;
|
||||||
|
const mgr = buildManager();
|
||||||
|
const result = await mgr.getToken();
|
||||||
|
expect(result).toBe(token);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. token is cached after first call
|
||||||
|
it('token is cached — only one fetch call on repeated getToken()', async () => {
|
||||||
|
const token = buildFakeJwt(3600);
|
||||||
|
const fetchMock = mockFetch([{ ok: true, status: 200, body: { token } }]);
|
||||||
|
global.fetch = fetchMock as typeof global.fetch;
|
||||||
|
const mgr = buildManager();
|
||||||
|
await mgr.getToken();
|
||||||
|
await mgr.getToken();
|
||||||
|
await mgr.getToken();
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. refresh called when token close to expiry
|
||||||
|
it('refresh is called when token is within refresh margin', async () => {
|
||||||
|
const initialToken = buildFakeJwt(30); // expires in 30s, margin=60 → needs refresh
|
||||||
|
const refreshedToken = buildFakeJwt(3600);
|
||||||
|
const fetchMock = mockFetch([
|
||||||
|
{ ok: true, status: 200, body: { token: initialToken } }, // login
|
||||||
|
{ ok: true, status: 200, body: { token: refreshedToken } }, // refresh
|
||||||
|
]);
|
||||||
|
global.fetch = fetchMock as typeof global.fetch;
|
||||||
|
const mgr = buildManager({ refreshMarginSeconds: 60 });
|
||||||
|
const first = await mgr.getToken();
|
||||||
|
expect(first).toBe(initialToken);
|
||||||
|
// Now token exp is within margin — next call should trigger refresh
|
||||||
|
const second = await mgr.getToken();
|
||||||
|
expect(second).toBe(refreshedToken);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. refresh uses correct endpoint
|
||||||
|
it('refresh POSTs to /api/user/token-refresh/', async () => {
|
||||||
|
const initialToken = buildFakeJwt(30);
|
||||||
|
const refreshedToken = buildFakeJwt(3600);
|
||||||
|
const fetchMock = vi.fn()
|
||||||
|
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ token: initialToken }), text: async () => '' })
|
||||||
|
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ token: refreshedToken }), text: async () => '' });
|
||||||
|
global.fetch = fetchMock as unknown as typeof global.fetch;
|
||||||
|
const mgr = buildManager({ refreshMarginSeconds: 60 });
|
||||||
|
await mgr.getToken();
|
||||||
|
await mgr.getToken();
|
||||||
|
const refreshCall = fetchMock.mock.calls[1] as [string, RequestInit];
|
||||||
|
expect(refreshCall[0]).toContain('/api/user/token-refresh/');
|
||||||
|
expect(JSON.parse(refreshCall[1].body as string)).toMatchObject({ token: initialToken });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. login uses correct endpoint and body
|
||||||
|
it('login POSTs to /api/user/token-auth/ with email and password', async () => {
|
||||||
|
const token = buildFakeJwt(3600);
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true, status: 200,
|
||||||
|
json: async () => ({ token }),
|
||||||
|
text: async () => '',
|
||||||
|
});
|
||||||
|
global.fetch = fetchMock as unknown as typeof global.fetch;
|
||||||
|
const mgr = buildManager();
|
||||||
|
await mgr.getToken();
|
||||||
|
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(url).toBe('http://baserow.test/api/user/token-auth/');
|
||||||
|
const body = JSON.parse(init.body as string) as Record<string, string>;
|
||||||
|
expect(body.username).toBe('bridge@acadenice.com');
|
||||||
|
expect(body.password).toBe('secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. login error — throws BaserowAuthError
|
||||||
|
it('login failure throws BaserowAuthError with correct statusCode', async () => {
|
||||||
|
global.fetch = mockFetch([{ ok: false, status: 401, body: { error: 'ERROR_INVALID_CREDENTIALS' } }]) as typeof global.fetch;
|
||||||
|
const mgr = buildManager();
|
||||||
|
await expect(mgr.getToken()).rejects.toThrow(BaserowAuthError);
|
||||||
|
await expect(mgr.getToken()).rejects.toMatchObject({ statusCode: 401 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 8. login error does not cache bad state
|
||||||
|
it('failed login clears cached token so next call retries', async () => {
|
||||||
|
const token = buildFakeJwt(3600);
|
||||||
|
const fetchMock = mockFetch([
|
||||||
|
{ ok: false, status: 401, body: {} }, // first call fails
|
||||||
|
{ ok: true, status: 200, body: { token } }, // second call succeeds
|
||||||
|
]);
|
||||||
|
global.fetch = fetchMock as typeof global.fetch;
|
||||||
|
const mgr = buildManager();
|
||||||
|
await expect(mgr.getToken()).rejects.toThrow();
|
||||||
|
const result = await mgr.getToken();
|
||||||
|
expect(result).toBe(token);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 9. network error — throws propagated error
|
||||||
|
it('network failure propagates as-is', async () => {
|
||||||
|
const fetchMock = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'));
|
||||||
|
global.fetch = fetchMock as unknown as typeof global.fetch;
|
||||||
|
const mgr = buildManager();
|
||||||
|
await expect(mgr.getToken()).rejects.toThrow('ECONNREFUSED');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 10. concurrent getToken() calls deduplicated
|
||||||
|
it('concurrent getToken() calls result in a single login fetch', async () => {
|
||||||
|
const token = buildFakeJwt(3600);
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true, status: 200,
|
||||||
|
json: async () => ({ token }),
|
||||||
|
text: async () => '',
|
||||||
|
});
|
||||||
|
global.fetch = fetchMock as unknown as typeof global.fetch;
|
||||||
|
const mgr = buildManager();
|
||||||
|
const [r1, r2, r3] = await Promise.all([mgr.getToken(), mgr.getToken(), mgr.getToken()]);
|
||||||
|
expect(r1).toBe(token);
|
||||||
|
expect(r2).toBe(token);
|
||||||
|
expect(r3).toBe(token);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 11. JWT without exp claim — defaults to 55 min TTL
|
||||||
|
it('token without exp claim is accepted and cached with 55min default', async () => {
|
||||||
|
const token = buildFakeJwtNoExp();
|
||||||
|
const fetchMock = mockFetch([{ ok: true, status: 200, body: { token } }]);
|
||||||
|
global.fetch = fetchMock as typeof global.fetch;
|
||||||
|
const mgr = buildManager({ refreshMarginSeconds: 60 });
|
||||||
|
const result = await mgr.getToken();
|
||||||
|
expect(result).toBe(token);
|
||||||
|
// Second call should use cache (not trigger another fetch)
|
||||||
|
await mgr.getToken();
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 12. refresh failure falls back to full login
|
||||||
|
it('refresh failure triggers fallback full login', async () => {
|
||||||
|
const staleToken = buildFakeJwt(30); // expires soon → needs refresh
|
||||||
|
const freshToken = buildFakeJwt(3600);
|
||||||
|
const fetchMock = vi.fn()
|
||||||
|
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ token: staleToken }), text: async () => '' })
|
||||||
|
.mockResolvedValueOnce({ ok: false, status: 401, json: async () => ({}), text: async () => '' }) // refresh fails
|
||||||
|
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ token: freshToken }), text: async () => '' }); // fallback login
|
||||||
|
global.fetch = fetchMock as unknown as typeof global.fetch;
|
||||||
|
const mgr = buildManager({ refreshMarginSeconds: 60 });
|
||||||
|
const first = await mgr.getToken(); // login
|
||||||
|
expect(first).toBe(staleToken);
|
||||||
|
const second = await mgr.getToken(); // refresh fails → login
|
||||||
|
expect(second).toBe(freshToken);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 13. Authorization header uses JWT prefix not Bearer
|
||||||
|
it('JWT Authorization header uses "JWT" prefix when calling Baserow endpoints', async () => {
|
||||||
|
// This test validates the contract expected by fetchWithUserJwt — tested here
|
||||||
|
// via the BaserowJwtManagerImpl itself by checking login endpoint headers.
|
||||||
|
const token = buildFakeJwt(3600);
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true, status: 200,
|
||||||
|
json: async () => ({ token }),
|
||||||
|
text: async () => '',
|
||||||
|
});
|
||||||
|
global.fetch = fetchMock as unknown as typeof global.fetch;
|
||||||
|
const mgr = buildManager();
|
||||||
|
const result = await mgr.getToken();
|
||||||
|
// The manager returns the raw JWT; the caller (BaserowClient.fetchWithUserJwt)
|
||||||
|
// is responsible for prefixing "JWT ". Assert we get a valid token string.
|
||||||
|
expect(result).toBe(token);
|
||||||
|
expect(result.split('.')).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// BaserowJwtManagerDisabled
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('BaserowJwtManagerDisabled', () => {
|
||||||
|
it('isEnabled returns false', () => {
|
||||||
|
const mgr = new BaserowJwtManagerDisabled();
|
||||||
|
expect(mgr.isEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getToken rejects with BaserowAuthError statusCode 503', async () => {
|
||||||
|
const mgr = new BaserowJwtManagerDisabled();
|
||||||
|
await expect(mgr.getToken()).rejects.toThrow(BaserowAuthError);
|
||||||
|
await expect(mgr.getToken()).rejects.toMatchObject({ statusCode: 503 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getToken error message contains BASEROW_USER_AUTH_NOT_CONFIGURED', async () => {
|
||||||
|
const mgr = new BaserowJwtManagerDisabled();
|
||||||
|
try {
|
||||||
|
await mgr.getToken();
|
||||||
|
expect.fail('should have thrown');
|
||||||
|
} catch (err) {
|
||||||
|
expect((err as Error).message).toContain('BASEROW_USER_AUTH_NOT_CONFIGURED');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// createBaserowJwtManager factory
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('createBaserowJwtManager', () => {
|
||||||
|
it('returns enabled manager when email and password provided', () => {
|
||||||
|
const mgr = createBaserowJwtManager({
|
||||||
|
baseUrl: 'http://baserow.test',
|
||||||
|
email: 'svc@acadenice.com',
|
||||||
|
password: 'secret123',
|
||||||
|
refreshMarginSeconds: 60,
|
||||||
|
logger: silentLogger,
|
||||||
|
});
|
||||||
|
expect(mgr.isEnabled()).toBe(true);
|
||||||
|
expect(mgr).toBeInstanceOf(BaserowJwtManagerImpl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns disabled manager when email is absent', () => {
|
||||||
|
const mgr = createBaserowJwtManager({
|
||||||
|
baseUrl: 'http://baserow.test',
|
||||||
|
email: undefined,
|
||||||
|
password: 'secret123',
|
||||||
|
refreshMarginSeconds: 60,
|
||||||
|
logger: silentLogger,
|
||||||
|
});
|
||||||
|
expect(mgr.isEnabled()).toBe(false);
|
||||||
|
expect(mgr).toBeInstanceOf(BaserowJwtManagerDisabled);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns disabled manager when password is absent', () => {
|
||||||
|
const mgr = createBaserowJwtManager({
|
||||||
|
baseUrl: 'http://baserow.test',
|
||||||
|
email: 'svc@acadenice.com',
|
||||||
|
password: undefined,
|
||||||
|
refreshMarginSeconds: 60,
|
||||||
|
logger: silentLogger,
|
||||||
|
});
|
||||||
|
expect(mgr.isEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns disabled manager when both absent', () => {
|
||||||
|
const mgr = createBaserowJwtManager({
|
||||||
|
baseUrl: 'http://baserow.test',
|
||||||
|
refreshMarginSeconds: 60,
|
||||||
|
logger: silentLogger,
|
||||||
|
});
|
||||||
|
expect(mgr.isEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
191
bridge/tests/routes/views-r4-jwt.test.ts
Normal file
191
bridge/tests/routes/views-r4-jwt.test.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
/**
|
||||||
|
* Tests routes /api/v1/views/* — Patch 031 JWT manager integration.
|
||||||
|
*
|
||||||
|
* Valide :
|
||||||
|
* - GET /api/v1/views/table/:slug fonctionne quand user JWT configure (mock manager)
|
||||||
|
* - GET /api/v1/views/table/:slug renvoie 500 clair quand creds absentes
|
||||||
|
* (le repo remonte BASEROW_USER_AUTH_NOT_CONFIGURED qui devient 500 via error-handler)
|
||||||
|
* - Slug resolution fonctionne avec le JWT manager active
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { View } from '../../src/domain/view.js';
|
||||||
|
import type { BaserowJwtManager } from '../../src/lib/baserow-jwt-manager.js';
|
||||||
|
import { BaserowAuthError, BaserowJwtManagerDisabled } from '../../src/lib/baserow-jwt-manager.js';
|
||||||
|
import type { RepoSet } from '../../src/lib/container.js';
|
||||||
|
import { errors } from '../../src/lib/errors.js';
|
||||||
|
import type { ViewDataResult } from '../../src/repos/baserow-views-repo.js';
|
||||||
|
import {
|
||||||
|
ADMIN_TOKEN,
|
||||||
|
READ_TOKEN,
|
||||||
|
buildTestApp,
|
||||||
|
installTestContainer,
|
||||||
|
resetTestContainer,
|
||||||
|
} from '../helpers/test-app.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stub JWT managers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** JWT manager enabled — returns a fixed fake JWT. */
|
||||||
|
class StubJwtManagerEnabled implements BaserowJwtManager {
|
||||||
|
public callCount = 0;
|
||||||
|
isEnabled(): boolean { return true; }
|
||||||
|
async getToken(): Promise<string> {
|
||||||
|
this.callCount++;
|
||||||
|
return 'fake.jwt.token';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JWT manager that simulates unconfigured creds (503). */
|
||||||
|
class StubJwtManagerUnconfigured implements BaserowJwtManager {
|
||||||
|
isEnabled(): boolean { return false; }
|
||||||
|
async getToken(): Promise<string> {
|
||||||
|
throw new BaserowAuthError(
|
||||||
|
'BASEROW_USER_AUTH_NOT_CONFIGURED: set BASEROW_USER_EMAIL and BASEROW_USER_PASSWORD',
|
||||||
|
503,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fake repos
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class FakeTablesRepo {
|
||||||
|
async list(_databaseId: number) { return []; }
|
||||||
|
async get(_tableId: number) { throw errors.notFound('Table', _tableId); }
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeFieldsRepo {
|
||||||
|
async list(_tableId: number) { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeRowsRepo {
|
||||||
|
async list() { return { items: [], meta: { page: 1, per_page: 50, total: 0, total_pages: 1 } }; }
|
||||||
|
async get(_tableId: number, rowId: number) { throw errors.notFound('Row', rowId); }
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeViewsRepoJwt {
|
||||||
|
public listByTableCalls: number[] = [];
|
||||||
|
public jwtManager: BaserowJwtManager | undefined;
|
||||||
|
private views: View[];
|
||||||
|
|
||||||
|
constructor(views: View[] = [], jwtManager?: BaserowJwtManager) {
|
||||||
|
this.views = views;
|
||||||
|
this.jwtManager = jwtManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(tableId: number): Promise<View[]> {
|
||||||
|
return this.listByTable(tableId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listByTable(tableId: number): Promise<View[]> {
|
||||||
|
this.listByTableCalls.push(tableId);
|
||||||
|
// Simulate the repo's behavior: call jwtManager.getToken() if configured
|
||||||
|
if (this.jwtManager) {
|
||||||
|
await this.jwtManager.getToken(); // throws if not configured
|
||||||
|
}
|
||||||
|
return this.views;
|
||||||
|
}
|
||||||
|
|
||||||
|
async runGrid(_viewId: number, _tableId: number) {
|
||||||
|
return { items: [], meta: { page: 1, per_page: 50, total: 0, total_pages: 1 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getViewData(_viewId: number, _tableId: number): Promise<ViewDataResult> {
|
||||||
|
return { items: [], meta: { page: 1, per_page: 100, total: 0, total_pages: 1 }, viewType: 'grid' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFakeRepos(viewsRepo: FakeViewsRepoJwt): RepoSet {
|
||||||
|
return {
|
||||||
|
tables: new FakeTablesRepo() as unknown as RepoSet['tables'],
|
||||||
|
fields: new FakeFieldsRepo() as unknown as RepoSet['fields'],
|
||||||
|
views: viewsRepo as unknown as RepoSet['views'],
|
||||||
|
rows: new FakeRowsRepo() as unknown as RepoSet['rows'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(resetTestContainer);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests — JWT manager enabled
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('GET /api/v1/views/table/:tableId — JWT manager enabled', () => {
|
||||||
|
it('200 liste vide quand JWT manager configure et retourne token', async () => {
|
||||||
|
const jwtMgr = new StubJwtManagerEnabled();
|
||||||
|
const viewsRepo = new FakeViewsRepoJwt([], jwtMgr);
|
||||||
|
const repos = buildFakeRepos(viewsRepo);
|
||||||
|
const container = installTestContainer({ repos, baserowJwt: jwtMgr });
|
||||||
|
const app = buildTestApp(container);
|
||||||
|
|
||||||
|
const res = await app.request('/api/v1/views/table/5', {
|
||||||
|
headers: { Authorization: `Bearer ${READ_TOKEN}` },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { data: unknown[]; total: number };
|
||||||
|
expect(body.data).toHaveLength(0);
|
||||||
|
expect(body.total).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('200 liste avec vues quand JWT configure', async () => {
|
||||||
|
const jwtMgr = new StubJwtManagerEnabled();
|
||||||
|
const views = [
|
||||||
|
new View({ id: 1, name: 'Tous', type: 'grid', tableId: 5, order: 0 }),
|
||||||
|
new View({ id: 2, name: 'Actifs', type: 'grid', tableId: 5, order: 1 }),
|
||||||
|
];
|
||||||
|
const viewsRepo = new FakeViewsRepoJwt(views, jwtMgr);
|
||||||
|
const repos = buildFakeRepos(viewsRepo);
|
||||||
|
const container = installTestContainer({ repos, baserowJwt: jwtMgr });
|
||||||
|
const app = buildTestApp(container);
|
||||||
|
|
||||||
|
const res = await app.request('/api/v1/views/table/5', {
|
||||||
|
headers: { Authorization: `Bearer ${ADMIN_TOKEN}` },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { data: Array<{ id: number }>; total: number };
|
||||||
|
expect(body.total).toBe(2);
|
||||||
|
expect(body.data[0]?.id).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('JWT manager getToken() is called when listing views', async () => {
|
||||||
|
const jwtMgr = new StubJwtManagerEnabled();
|
||||||
|
const viewsRepo = new FakeViewsRepoJwt([], jwtMgr);
|
||||||
|
const repos = buildFakeRepos(viewsRepo);
|
||||||
|
const container = installTestContainer({ repos, baserowJwt: jwtMgr });
|
||||||
|
const app = buildTestApp(container);
|
||||||
|
|
||||||
|
await app.request('/api/v1/views/table/42', {
|
||||||
|
headers: { Authorization: `Bearer ${READ_TOKEN}` },
|
||||||
|
});
|
||||||
|
expect(jwtMgr.callCount).toBeGreaterThan(0);
|
||||||
|
expect(viewsRepo.listByTableCalls).toContain(42);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests — JWT manager not configured
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('GET /api/v1/views/table/:tableId — JWT manager not configured', () => {
|
||||||
|
it('500 quand creds JWT absentes', async () => {
|
||||||
|
const jwtMgr = new StubJwtManagerUnconfigured();
|
||||||
|
const viewsRepo = new FakeViewsRepoJwt([], jwtMgr);
|
||||||
|
const repos = buildFakeRepos(viewsRepo);
|
||||||
|
const container = installTestContainer({ repos, baserowJwt: jwtMgr });
|
||||||
|
const app = buildTestApp(container);
|
||||||
|
|
||||||
|
const res = await app.request('/api/v1/views/table/5', {
|
||||||
|
headers: { Authorization: `Bearer ${READ_TOKEN}` },
|
||||||
|
});
|
||||||
|
// The repo transforms 503 BaserowAuthError into an INTERNAL error (500)
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disabled manager getToken() rejects with BaserowAuthError 503', async () => {
|
||||||
|
const mgr = new BaserowJwtManagerDisabled();
|
||||||
|
await expect(mgr.getToken()).rejects.toMatchObject({ statusCode: 503 });
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue