169 lines
18 KiB
Markdown
169 lines
18 KiB
Markdown
# Kazier — Contrat API V1 (autoritatif)
|
|
|
|
Status : **autoritatif**. Le client mobile Kotlin est la source de vérité : `data/remote/ApiService.kt` + `data/remote/dto/Dtos.kt` (contrat Retrofit/Moshi) sont implémentés ; le serveur Go doit les matcher exactement (méthode + path + enveloppe), il ne re-négocie pas. Document consolidé depuis le client — toute divergence de ce doc doit être portée dans le client d'abord.
|
|
|
|
Références : `V2.md` (modèle cible), `mobile/services/db/` (conventions sync), `AGENTS.md` (structure + commandes).
|
|
|
|
---
|
|
|
|
## 1. Base et transport
|
|
|
|
- Base URL serveur : configurable à l'exécution dans les Réglages de l'appli (défaut `http://10.0.2.2:8080/api/v1`).
|
|
- JSON partout, sauf `POST /files/upload` (multipart).
|
|
- Enveloppe succès : `{ "data": T, "meta"?: { "page": int, "pageSize": int, "total": int } }` (`meta` présent sur les listes paginées).
|
|
- Erreur : `{ "error": { "code": string, "message": string } }` + statut HTTP adéquat.
|
|
- Côté client, toute réponse non-`2xx` est normalisée en `ApiError` : `code` du body si présent, sinon `HTTP_<status>` ; échec réseau → `NETWORK_ERROR`. Une réponse `2xx` mais dont le body n'est pas du JSON d'enveloppe valide (HTML, corps vide, JSON mal formé, absence de la clé `data`) → `INVALID_RESPONSE` (client-only).
|
|
|
|
## 2. Identité et identifiants (invariants)
|
|
|
|
- **User-first (V1 finale)** : le device s'enregistre d'abord (`POST /devices`) avec son identité **générée localement** (`device_user_id` 32-hex mobile) — **réponse `{ "deviceId" }` uniquement, sans token**. Puis le client appelle `POST /auth/login` (`username` + `password` + `device_id`) pour obtenir son token **paseto** v4-local. Requêtes suivantes : `Authorization: Bearer <token>` (toutes les routes **sauf `/health`, `/devices`, `/auth/login`**).
|
|
- **Claims du token** : le **subject = `user_id`** (AUTORISE, clé de scoping de toutes les ressources) ; `device_id` est un **claim secondaire, porté mais NON autorisant seul** (idempotence outbox + jobs OCR). Résolu par middleware `RequireAuth` qui vérifie aussi que le compte existe toujours (`deleted_at IS NULL`).
|
|
- **TTL : 7 jours, sans refresh.** À expiration, le client re-logine (`POST /auth/login`). Le changement de mot de passe (`PATCH /users/me/password`) **n'invalide pas** les tokens déjà émis — limite V1 assumée (pas de liste de révocation) jusqu'à l'expiration.
|
|
- Au login, `devices.user_id` mémorise le **dernier user connecté** (INFORMATIF uniquement, jamais autorisant — recommande `POST /devices` → `POST /auth/login` pour un nouveau device, sans réutiliser le device d'un autre compte).
|
|
- **Identifiants** : `resource_id`, `device_user_id`, `user_id`, `is_admin`… = **TEXT opaque 32-hex minuscule**, `^[0-9a-f]{32}$`. Le mobile génère toujours `lower(hex(randomblob(16)))` ; le serveur stocke **tel quel**, sans conversion UUID (cf. note V2.md). Contrainte serveur : `CHECK (col ~ '^[0-9a-f]{32}$')` sur toutes les colonnes id + FK.
|
|
- **Usernames** : `username` = forme affichée ; l'unicité et la résolution portent sur `username_normalized` (lowercase + trim). Résolution d'un destinataire : `GET /users/resolve?username=` (exact uniquement, jamais de listing ni de préfixe — pas d'énumération de comptes).
|
|
- **Bootstrap** : au premier démarrage, si `users` est vide, `ADMIN_USERNAME`/`ADMIN_PASSWORD` (env) créent le **premier admin** ; absents → le serveur **refuse de démarrer**. L'env n'écrase jamais un compte existant.
|
|
- Horodatages échangés en **millisecondes epoch** (le mobile utilise `Date.now()`).
|
|
|
|
## 3. Endpoints
|
|
|
|
| Méthode | Path | Requête | Réponse `data` | Statut absence |
|
|
|---|---|---|---|---|
|
|
| GET | `/health` | — | `{ "status": "healthy" }` | — |
|
|
| POST | `/devices` | `{ "deviceId": "…32-hex" }` (client-generated) | `{ "deviceId": "…32-hex" }` — **aucun token** (V1 finale) | `INVALID_DEVICE_ID` |
|
|
| POST | `/auth/login` | `{ "username", "password", "device_id" }` | `{ "token", "expires_at" (ms), "user": { "id", "username", "is_admin" } }` | `UNAUTHORIZED` / `INVALID_DEVICE_ID` |
|
|
| GET | `/shares/links/:token` | — (public, sans auth) | `{ "token", "resource_id", "resourceType", "name", "access", "expiresAt" }` | `NOT_FOUND` (inconnu/révoqué/expiré) |
|
|
| GET | `/users/resolve` | query `username` (obligatoire) | `{ "id", "username" }` | `NOT_FOUND` |
|
|
| PATCH | `/users/me/password` | `{ "current_password", "new_password" }` | `{ "id" }` | `INVALID_PASSWORD` (403) |
|
|
| GET | `/files` | query `folderId?`, `page?`, `pageSize?`, `sort?` | `FileDto[]` (+ `meta`) | — |
|
|
| GET | `/files/:id` | — | `FileDto` | `NOT_FOUND` |
|
|
| DELETE | `/files/:id` | — | `{ "id": "…" }` | `NOT_FOUND` |
|
|
| GET | `/files/search` | query `q` (obligatoire), `page?`, `pageSize?` | `FileDto[]` (+ `meta`) | — |
|
|
| GET | `/files/folders` | — | `FolderDto[]` (racines) | — |
|
|
| POST | `/files/upload` | multipart : `file` (uri/name/type), `folderId?` | `FileDto` | `FILE_TOO_LARGE` |
|
|
| POST | `/ocr/jobs` | `{ "fileId": "…" }` | `OcrJob` | — |
|
|
| GET | `/ocr/jobs/:id` | — | `OcrJob` | `NOT_FOUND` |
|
|
| GET | `/files/:id/ocr` | — | `FileOcr` (`{ text, updatedAt }`) | `NOT_FOUND` |
|
|
| POST | `/sync/ops` | voir §6 | voir §6 | — |
|
|
| GET | `/sync/permissions` | query `after?` (cached_at ms) | `ResourcePermission[]` | — |
|
|
|
|
> **Lecture partagée** : `GET /files`, `GET /files/:id`, `GET /files/search`, `GET /files/:id/ocr` et `GET /files/folders` couvrent les ressources **possédées ET partagées** (accès `viewer+`, §6.2). Le rename d'une ressource partagée passe par l'outbox `update_metadata` (nécessite `editor+`, §6.1). `DELETE /files/:id` reste owner-only.
|
|
|
|
### DTOs (copie conforme de `mobile/api/types.ts`)
|
|
|
|
```ts
|
|
type FileDto = {
|
|
id: string; // resource_id 32-hex
|
|
name: string;
|
|
size: number;
|
|
mimeType?: string | null;
|
|
folderId?: string | null; // resource_id 32-hex
|
|
tags?: string[];
|
|
createdAt?: string;
|
|
updatedAt?: string;
|
|
};
|
|
type FolderDto = { id: string; name: string; parentId?: string | null };
|
|
type OcrJobStatus = 'queued' | 'processing' | 'done' | 'failed';
|
|
type OcrJob = { id: string; status: OcrJobStatus; text?: string | null; error?: string | null };
|
|
type FileOcr = { text: string; updatedAt?: string | null };
|
|
```
|
|
|
|
## 4. Upload
|
|
|
|
- Multipart : champ `file` + `folderId?` optionnel + `resourceId?` optionnel. **Le client ne fixe jamais `Content-Type`** (le boundary doit être généré par la plateforme).
|
|
- `resourceId` optionnel : cible un **fichier déjà connu du user** (créé metadata-only via l'outbox `create_resource`, ou précédemment uploadé) pour lui **attacher ses octets physiques** — cas d'usage : le client local-first doit fournir le bytes d'un fichier SAF pour l'OCR serveur (`POST /ocr/jobs`). Le `resourceId` fourni doit exister et appartenir au user (sinon `NOT_FOUND`, même réponses/format que `GET /files/:id`) ; la ligne n'est **jamais re-créée ni renommée ni déplacée**, seule la métadonnée physique (`size`/`mimeType`/extension) est rafraîchie et `FileDto` renvoyé. `resourceId` absent → comportement historique (nouvelle ressource).
|
|
- Limite : `MAX_FILE_SIZE_MB` (défaut 50). Dépassement → 413 `{ "error": { "code": "FILE_TOO_LARGE", … } }`.
|
|
- Le fichier physique est stocké sous `UPLOAD_DIR/<user_id>/<resource_id>.<ext>` ; la métadonnée est persistée en base et renvoyée en `FileDto`. Si la persistance de la métadonnée échoue (ex. `NAME_CONFLICT`), le fichier physique est supprimé.
|
|
|
|
## 5. OCR
|
|
|
|
- `POST /ocr/jobs { fileId }` → `OcrJob` immédiat (`status: queued`), traitement **asynchrone** par une **file serveur bornée** (`ocr_jobs` + worker `Ocr.Run`, `OCR_WORKERS` défaut 2) — le handler n'enfile **jamais** de traitement, Tesseract tourne hors request handler (FIFO, récupération des `processing` orphelins au boot).
|
|
- `GET /ocr/jobs/:id` → statut. Le mobile **poll toutes les 3s** jusqu'à `done`/`failed` (`hooks/useUpload.ts`). Cycle : `queued → processing → done | failed` ; `done` renvoie `text`, `failed` renvoie `error`.
|
|
- Moteur : **Tesseract en appel système** (`ocr/tesseract.go`), langue `OCR_LANG` (défaut `fra+eng`). Les images sont passées directement à `tesseract` ; les **PDF** subissent une extraction du calque texte (`ledongthuc/pdf`, déjà en go.mod) — un PDF scanné produit un texte vide plutôt qu'un rendu/OCR (hors scope V1).
|
|
- `fileId` inconnu/pas du user → `NOT_FOUND`. Fichier physique introuvable (ex. suppression manuelle sous `UPLOAD_DIR`) → job `failed` `"file not readable"`. Le job est créé par le device courant (`ocr_jobs.device_id`) mais la validation de la ressource est scopée par le **user**.
|
|
- **Le texte atterrit sur la ressource** : à la complétion (`done` avec texte non vide), le worker écrit l'extrait trimé dans `resources.ocr_text` (et **bump `updated_at`**, d'où une injection dans le delta outbox/snapshot). `ocr_jobs.text` reste l'historique du job.
|
|
- **`GET /files/:id/ocr`** → `FileOcr { text, updatedAt }` : l'extrait server-side du fichier, lisible par le **possédant ET les grantees** (`viewer+`, même portée que `GET /files/:id` ; `text` `""` si pas encore extrait). Un **second device** rapatrie ainsi le texte d'un OCR fait par un autre device (ou par la ressource partagée) **sans re-téléverser les octets ni re-soumettre de job** — le client Kotlin le fait en tête de `OcrRepository.syncFromServer`/`processAuto` et du viewer (`OcrViewModel`).
|
|
|
|
## 6. Contrat de sync (outbox + snapshot)
|
|
|
|
### 6.1 Outbox — `POST /sync/ops`
|
|
|
|
```json
|
|
{
|
|
"operations": [
|
|
{
|
|
"operation_id": "…32-hex", // généré par le client (GenerateId), jamais réutilisé
|
|
"ref_type": "resource", // "resource" | "share" | "share_link"
|
|
"ref_id": null, // id local de la ligne share/share_link (sinon null)
|
|
"resource_id": "…32-hex", // ressource cible
|
|
"resource_type": "folder", // "folder" | "file"
|
|
"operation": "create_resource",
|
|
"payload": {}
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
- `operation` ∈ `create_resource | update_metadata | delete_resource | move_resource | share | revoke_share | update_share | create_link | revoke_link` (cf. `PendingOperationType` mobile).
|
|
- **Identifiants** : `operation_id` est un **TEXT 32-hex** généré par le client (`^[0-9a-f]{32}$`, CHECK-enforced depuis la migration `000008`), distinct de `resource_id`. L'idempotence outbox reste **par device** : `UNIQUE(device_id, operation_id)` (la réinscription d'un device avec un login différent ne réutilise pas l'historique outbox d'un autre compte). Pour chaque op : si déjà traitée → **no-op** (comptée comme appliquée, les doublons arrivent à cause du backoff/retry). Sinon appliquée si valide.
|
|
- **Ordre** : les opérations sont appliquées **séquentiellement**, dans l'ordre du batch. Le serveur **s'arrête à la première erreur non-idempotente** et renvoie l'index atteint — le client reprend à cet index.
|
|
- Réponse : `2xx` avec `{ "applied": int, "failed": { "operation_id": "…32-hex", "code": string, "message": string } | null }` (`applied` = index de la prochaine op à envoyer).
|
|
- Côté client, le `pushStatus` (pending/synced/failed) des shares/share_links est **dérivé** de l'état des opérations de l'outbox ; **dead-letter immédiat** sur erreur 4xx non-idempotente (`failed`, non resélectionné ; `attempts` reste un compteur diagnostic, pas un seuil) — seul le transitoire (`NETWORK_ERROR`/5xx) est rejoué avec backoff. **Côté serveur, les ops partage créent un état réel** (tables `shares`/`share_links`, migration `000009`) — l'outbox est le seul chemin d'écriture des droits.
|
|
- Sémantique d'application (côté serveur) :
|
|
- `create_resource` : crée la ressource ; **déjà présente → no-op** (rejeu idempotent). `payload.name` obligatoire ; `payload.parentResourceId` (32-hex, optionnel) = dossier parent — absent → racine. **Parent inexistant → `NOT_FOUND`** (cohérent avec `move_resource`). Ordre garanti par construction client : le walk SAF émet les `create` des dossiers (ordre préfixe) avant ceux des fichiers, dans la même transaction Room → `id ASC` = parent avant enfant.
|
|
- `update_metadata` : ressource absente → **no-op** (état terminal atteint) ; **ressource partagée : nécessite `editor+`** (§6.2) — sinon `NOT_FOUND` (une ressource visible mais sans droit de mutation n'est pas renommée) ; une ressource sans relation applicable (ni owner, ni grant) reste un no-op (pas d'énumération). `move_resource` / `delete_resource` restent **owner-only** (scoping par `user_id`, les ops d'un non-owner sont des no-op).
|
|
- `delete_resource` : **idempotent** — suppression d'une ressource absente = succès.
|
|
- **Partage** (`share` / `update_share`, même sémantique d'upsert) : `payload = { "granteeUserId": "…32-hex", "access": "viewer"|"commenter"|"editor", "inherit"?: bool, "expiresAt"?: ms }`. La ressource doit **appartenir** au user appelant (sinon `NOT_FOUND`) ; le grantee doit exister (sinon `GRANTEE_NOT_FOUND`) ; accès déjà partagé → mis à jour (idempotent). Privilégier `viewer` < `commenter` < `editor` (< `owner` réservé à l'ownership, non partageable → `INVALID_REQUEST`).
|
|
- `revoke_share` : `payload = { "granteeUserId": "…32-hex" }`. **Idempotent** (déjà révoqué/absent → no-op).
|
|
- `create_link` : `payload = { "token": "…32-hex", "access": "viewer"|"commenter"|"editor", "expiresAt"?: ms }`. Le token est **généré côté client** (`GenerateId`), c'est l'identifiant du lien (colonne `id` de `share_links`).
|
|
- `revoke_link` : `payload = { "token": "…32-hex" }`. **Idempotent**.
|
|
- Validation (deuxième champ `operation_id`, hex32 pour `resource_id` **et** `operation_id`, enum `operation`) → échec `INVALID_REQUEST` avec arrêt du batch.
|
|
- Nom déjà pris (même parent, ou à la racine) → échec `NAME_CONFLICT`.**
|
|
- **Lien public** : `GET /shares/links/:token` est **sans authentification** (résolution publique d'un lien) et renvoie `{ "token", "resource_id", "resourceType", "name", "access", "expiresAt" }`. `NOT_FOUND` si le lien est inconnu, révoqué ou expiré.
|
|
|
|
### 6.2 Snapshot — `GET /sync/permissions?after=<cached_at_ms>`
|
|
|
|
- Renvoie le delta (ou l'ensemble) des permissions effectives pour le **user** appelant, chacune sous la forme exacte consommée par `canAccess` :
|
|
|
|
```ts
|
|
type ResourcePermission = {
|
|
resource_id: string; // 32-hex
|
|
resourceType: 'folder' | 'file';
|
|
effectiveAccess: 'viewer' | 'commenter' | 'editor' | 'owner';
|
|
name: string; // nom de la ressource (hydratation mobile)
|
|
parentId: string | null; // parent 32-hex, null = racine (hydratation mobile)
|
|
inherit: boolean;
|
|
ownerId: string | null; // ownership USER si applicable
|
|
sharedById: string | null;
|
|
expiresAt: number | null; // ms epoch ; null = jamais
|
|
cachedAt: number; // ms epoch — horodatage du snapshot (TTL 24h)
|
|
updatedAt: number;
|
|
};
|
|
```
|
|
|
|
- **Calcul de `effective_access`** (le serveur est la source de vérité) :
|
|
1. Rang : `viewer = 1 < commenter = 2 < editor = 3 < owner = 4`.
|
|
2. La permission **exacte sur le nœud** est autoritaire — elle n'est pas annulée par son propre `inherit=false` et n'est pas écrasée par un ancêtre de rang supérieur.
|
|
3. Les ancêtres propagent **uniquement si leur relation a `inherit = true`** ; une relation expirée (`expires_at` passé) est ignorée **et ne propage pas**.
|
|
4. `owner_id` == le user appelant → `owner` (fallback, quel que soit le niveau remonté).
|
|
5. Le **rang le plus élevé** l'emporte pour les nœuds **sans permission propre** ; sans relation applicable et sans ownership → la ressource n'est pas dans le snapshot. `inherit = false` sur un nœud intermédiaire stoppe uniquement la propagation de **sa propre relation** ; une relation `inherit = true` plus haut continue de traverser.
|
|
- **Delta** : `after` filtre sur `resource.updated_at` **et** l'`updated_at` de la relation gagnante — une ressource nouvellement partagée apparaît dans le delta du grantee dès sa création. **Révoquée/expirée, la ressource disparaît du snapshot** : la convergence côté client passe par les pulls complets (`after=0`, à chaque login et quand le cache dépasse le TTL 24h).
|
|
- **TTL / stale** : après `PERMISSION_TTL_MS` (= 24h) sans reseed, `canAccess` **downgrade en lecture seule** (`viewer`) vers le cache.
|
|
|
|
### 6.3 Placements
|
|
|
|
- Le statut de placement (`sync_status` mobile : `local` | `cloud` | `local-cloud`) est le reflet du `resource_placements` serveur (statuts V2 : `local_only`, `synced`, `cloud_only`, `pending_upload`, `pending_download`). La matérialisation se fait via l'outbox (`create_resource` → `synced`/`pending_upload` ; suppression physique locale ≠ suppression serveur).
|
|
|
|
## 7. Codes d'erreur courants
|
|
|
|
- Authentification : `UNAUTHORIZED` (**401** — token manquant/invalide/expiré, compte supprimé, OU identifiants de login erronés : **indistinguables par design**, même code+message), `INVALID_DEVICE_ID` (**400** — device non enregistré au login).
|
|
- Ressources : `NOT_FOUND` (404), `NAME_CONFLICT` (409 — même nom dans le même parent, cf. `UNIQUE(parent_id, name)`, **ou à la racine**, index partiel `(user_id, name) WHERE parent_id IS NULL`), `FILE_TOO_LARGE` (413), `INVALID_PASSWORD` (403 sur `PATCH /users/me/password`).
|
|
- Partage : `GRANTEE_NOT_FOUND` (404 — grantee inexistant sur une op share), `NOT_FOUND` (404 — ressource non possédée sur une op share : scoping, pas d'énumération).
|
|
- Client-only : `NETWORK_ERROR`, `INVALID_RESPONSE` (2xx mais corps d'enveloppe invalide), `HTTP_<status>` (fallback). Statut `SERVICE_UNAVAILABLE` (503) si le backend n'est pas initialisé.
|
|
- **V1 finale : toutes les routes sont réelles** (pas de 501 restant).
|
|
|
|
## 8. Moteur de login (règles de sécurité)
|
|
|
|
- `POST /auth/login` : le **device doit exister** (`POST /devices` d'abord, sinon 400 `INVALID_DEVICE_ID`). La vérification du mot de passe est **constant-time** (`argon2id`, comparaison `subtle`) et le délai est **égalisé** entre « username inconnu » et « mauvais mot de passe » (vérification contre un dummy-hash) — les deux produisent exactement la même réponse 401.
|
|
- `GET /users/resolve` : résolution **exacte** du `username_normalized` uniquement ; ne renvoie **jamais** `email` ni `is_admin` (ni listing, ni préfixe → pas d'énumération de comptes).
|
|
- `PATCH /users/me/password` : exige `current_password` (mauvais curl → 403). Minimum 8 caractères. **Limite V1** : tokens émis non révoqués (validité 7 j), et `is_admin`/`ADMIN_*` non modifiables par API. |