docs : architecture comptes/annonces, README ; smoke.sh paramétrable (origine, URL psql)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
3 changed files +50 −5
modified
README.md
+12 −0
@@ -20,5 +20,17 @@ Extraits minces en lecture seule (`sqlite3 ATTACH` sur le nœud source, rsync LA | ||
| 20 | 20 | ## Déploiement (MacLustr, remote-first) |
| 21 | 21 | Manifeste `M1M32:~/dispatch/apps/toveli.json` (nœud épinglé M3U96b, port 8460, route MacLustr Tunnel https://www.toveli.ca). Création : `mld stage <dir> toveli && mld deploy toveli && mld adopt toveli`, puis première synchro `ssh M3U96b 'cd ~/apps/toveli && pnpm sync'`. Ensuite, toute modification se fait dans `M3U96b:~/apps/toveli` (dépôt git, origin spbgit `toveli.git`). |
| 22 | 22 | |
| 23 | +## Comptes, publications directes, échanges (extension 2026-10-17) | |
| 24 | +PostgreSQL 17 local (compte `toveli_app`, port non exposé) + Drizzle (`src/db/schema.ts`, migrations `drizzle/`). Connexion sans mot de passe par code courriel (Resend ; transport simulé sans clé), sessions révocables, wizard de publication (location/vente, photos traitées par sharp, attestation, modération), messagerie et visites, favoris et alertes serveur, console admin protégée par TOTP. Détails : `docs/architecture/account-and-listings.md`. | |
| 25 | + | |
| 26 | +```bash | |
| 27 | +createdb toveli && createuser toveli_app # ou docker compose up postgres | |
| 28 | +DATABASE_URL=postgresql://toveli_app:…@127.0.0.1:5432/toveli pnpm db:migrate | |
| 29 | +pnpm test # unitaires + intégration PostgreSQL (toveli_test) | |
| 30 | +pnpm start & scripts/smoke.sh http://127.0.0.1:8460 toveli # parcours API bout en bout (courriels dans data/outbox) | |
| 31 | +pnpm jobs # alertes, expirations, purge (PM2 cron horaire en prod) | |
| 32 | +pnpm tsx scripts/grant-role.ts <courriel> ADMIN # rôle serveur ; TOTP exigé ensuite sur /admin | |
| 33 | +``` | |
| 34 | + | |
| 23 | 35 | ## Garde-fous produit |
| 24 | 36 | Pas de « bonne affaire » : écart estimation / prix demandé avec confiance ; estimation ≠ évaluation agréée (OEAQ) ; photos hotlinkées avec crédit source ; chaque fiche renvoie à l'annonce d'origine ; favoris/comparateur/recherches en local (aucun compte) ; clés API uniquement côté serveur. |
added
docs/architecture/account-and-listings.md
+33 −0
@@ -0,0 +1,33 @@ | ||
| 1 | +# Toveli — identité unique, publications directes, échanges (2026-10-17) | |
| 2 | + | |
| 3 | +## État exact des accès (Phase 0) | |
| 4 | +- Dépôts sources audités en lecture seule (voir `docs/source-audit/`). Aucun module d'authentification réutilisable : Lou-Ka/Immo-Ka/Vrai-Prix utilisent Google OAuth + un SSO JWT partagé (hub externe) et des favoris stockés sur ce hub → **exclus** (identité Toveli indépendante, OTP courriel). | |
| 5 | +- Module de publication existant : aucun (les trois plateformes agrègent). Le wizard Toveli est nouveau. | |
| 6 | +- Resend : aucune clé n'est présente dans le code ni dans les manifestes ; `RESEND_API_KEY` vide ⇒ transport simulé (`data/outbox/mails.jsonl`). Toute clé déjà divulguée doit être révoquée et remplacée dans le gestionnaire de secrets (manifeste mld, mode 600, hors git). | |
| 7 | +- PostgreSQL 17 installé sur M3U96b (Homebrew), écoute 127.0.0.1/::1 uniquement, `scram-sha-256` pour TCP, rôle `toveli_app` propriétaire de la base `toveli` (pas superuser). Port 5432 jamais exposé (pare-feu + bind local). | |
| 8 | + | |
| 9 | +## ADR — PostgreSQL + Drizzle à côté de la base canonique SQLite | |
| 10 | +Les annonces agrégées (117 k) sont reconstruites chaque nuit dans `data/toveli.db` (lecture seule). Les données Toveli (comptes, annonces directes, médias, messages) exigent transactions, verrous (OTP, visites), contraintes et sauvegardes → PostgreSQL local, Drizzle ORM (migrations SQL versionnées dans `drizzle/`, appliquées par `pnpm db:migrate` à chaque déploiement, journal `__drizzle_migrations`). La recherche unifiée (`src/lib/search.ts`) fusionne les deux mondes en mémoire (volume direct faible) avec un seul contrat `ListingCard` (`sourceId` toveli | immo-ka | lou-ka, `connector` proprietaire | professionnel | <site>). | |
| 11 | + | |
| 12 | +## ADR — Resend + OTP | |
| 13 | +Code 6 chiffres CSPRNG (`randomInt`), 10 min, usage unique, 5 essais, HMAC-SHA256(code, OTP_HMAC_SECRET, id du défi) stocké, jamais le code ; vérification dans une transaction `SELECT … FOR UPDATE` (double soumission → une seule réussite, testé) ; réponse neutre à la demande ; fenêtres glissantes par IP/adresse (10/10 min, 5/h, 12/j, 60/j IP), délai de renvoi 60 s ; aucun blocage irréversible. Un code correct sans acceptation des conditions (nouveau compte) renvoie 409 sans consommer le code. Courriel « Votre code de connexion Toveli » (HTML responsive + texte). Sessions : jeton 32 octets aléatoires, HMAC stocké, cookie `tv_session` HttpOnly/Secure/SameSite=Lax, 30 j, inactivité 14 j, révocation unitaire ou globale ; CSRF = SameSite + vérification Origin/Referer sur toute mutation. Changement d'adresse : code envoyé à la nouvelle adresse, autres sessions révoquées. Champs sensibles (téléphone, secret TOTP) chiffrés AES-256-GCM. | |
| 14 | + | |
| 15 | +## Rôles et capacités | |
| 16 | +Un compte = toutes les capacités (chercher, favoris, alertes, publier, échanger). `OWNER` n'est pas un rôle : c'est `listings.owner_user_id`. `user_roles` (ADMIN, MODERATOR, AGENT_VERIFIED) attribués côté serveur (`scripts/grant-role.ts` ou console admin) avec journal. Admin : rôle + TOTP (otpauth) vérifié < 30 min, toutes les mutations journalisées (`audit_events`, append-only), `moderation_actions`. | |
| 17 | + | |
| 18 | +## Modèle de données (PostgreSQL) — voir `src/db/schema.ts` | |
| 19 | +users, user_profiles, otp_challenges, sessions, user_roles, properties, listings (`source_type` DIRECT_OWNER|DIRECT_AGENT|AGGREGATED_PARTNER, `status` machine d'états, `version` optimiste, CHECK prix ≥ 0, owner obligatoire pour DIRECT), listing_media, listing_versions, listing_claims (revendication d'une annonce agrégée : jamais de transfert automatique), favorites, saved_searches (+alertes), email_deliveries, inquiries, messages (idempotence), viewing_requests (REQUESTED|CONFIRMED|CANCELLED|DONE, conflit < 30 min), reports, moderation_actions, audit_events, rate_limits. `rental_applications` : non créée volontairement (v1 n'exige aucun document pour contacter ; à concevoir avec rétention stricte). | |
| 20 | + | |
| 21 | +## Publication — machine d'états | |
| 22 | +DRAFT → (publish) PUBLISHED | PENDING_REVIEW (drapeaux spam : liens multiples, formulations d'arnaque, loyer < 350 $, majuscules) → admin approve/reject. PUBLISHED ⇄ PAUSED ; → SOLD | RENTED | ARCHIVED → reopen → DRAFT. Pré-requis : municipalité, position, prix plausible, titre ≥ 8, description ≥ 40, ≥ 1 photo, attestation du droit de publier (horodatée), chambres sauf terrain/commercial. Expiration 90 j (rappel J-7, mise en pause automatique par `scripts/jobs.ts`). | |
| 23 | + | |
| 24 | +## Médias | |
| 25 | +Jeton d'envoi à usage unique (équivalent presign local) → `PUT /api/uploads/<token>` → sniff des magic bytes (JPEG/PNG/WebP/HEIC), HEIC converti (heic-convert) ou rejeté proprement, ré-encodage WebP 1600/800/320 via sharp (neutralise les charges utiles, retire EXIF/GPS/ICC), min 200 px, max 25 Mo / 30 photos. Stockage disque `STORAGE_DIR` servi par `/api/media/<ym>/<hex>/<w>.webp` (clé validée). Pas d'antivirus dédié : limite documentée (ré-encodage + absence d'exécution). | |
| 26 | + | |
| 27 | +## Confidentialité et Loi 25 | |
| 28 | +Minimisation (courriel seul pour s'inscrire), coordonnées jamais publiques, masquage automatique des courriels/téléphones/liens dans les messages, adresse exacte masquable (rue ou secteur, position brouillée), export JSON et suppression de compte en libre-service (annonces archivées, messages anonymisés, compte anonymisé), journaux minimisés (IP hachées) 12 mois, politique de confidentialité et conditions provisoires (`/confidentialite`, `/conditions`) à valider juridiquement avant ouverture publique des comptes. | |
| 29 | + | |
| 30 | +## Limites actuelles et prochaines étapes | |
| 31 | +- Playwright : captures QA (`scripts/screenshots.mjs`) ; scénarios E2E complets à écrire (le test API bout en bout `scripts/smoke.sh` couvre connexion → publication → contact → visite). | |
| 32 | +- Envoi réel de courriels : nécessite une clé Resend et un domaine vérifié (SPF/DKIM/DMARC pour `connexion@toveli.ca`). | |
| 33 | +- Stockage S3 compatible, CAPTCHA adaptatif, WebAuthn (TOTP en place), revendication d'annonces agrégées (table prête, UI à faire), applications locatives (hors v1). | |
modified
scripts/smoke.sh
+5 −5
@@ -6,7 +6,7 @@ H=(-H "content-type: application/json" -H "Origin: $ORIGIN" -H "Host: $HOSTH") | ||
| 6 | 6 | G=(-H "Host: $HOSTH") |
| 7 | 7 | CJ=$(mktemp) |
| 8 | 8 | echo "--- request-code"; curl -s -X POST $B/api/auth/request-code "${H[@]}" -d '{"email":"Simon.Test@exemple.ca"}'; echo |
| 9 | −CODE=$(tail -1 data/outbox/mails.jsonl | python3 -c "import json,sys,re;m=json.loads(sys.stdin.readline());print(re.search(r'\b(\d{6})\b',m['text']).group(1))"); CID=$(psql ${PSQL_URL:+"$PSQL_URL"} ${PSQL_URL:-"-d $DB"} -tA -c "select id from otp_challenges order by created_at desc limit 1"); echo "code=$CODE challenge=$CID" | |
| 9 | +CODE=$(tail -1 data/outbox/mails.jsonl | python3 -c "import json,sys,re;m=json.loads(sys.stdin.readline());print(re.search(r'\b(\d{6})\b',m['text']).group(1))"); CID=$(psql "${PSQL_URL:-$DB}" -tA -c "select id from otp_challenges order by created_at desc limit 1"); echo "code=$CODE challenge=$CID" | |
| 10 | 10 | echo "--- verify sans conditions (attendu 409)"; curl -s -o /dev/null -w "%{http_code}\n" -X POST $B/api/auth/verify-code "${H[@]}" -d "{\"challengeId\":\"$CID\",\"code\":\"$CODE\",\"acceptTerms\":false}" |
| 11 | 11 | echo "--- verify avec conditions"; curl -s -c $CJ -X POST $B/api/auth/verify-code "${H[@]}" -d "{\"challengeId\":\"$CID\",\"code\":\"$CODE\",\"acceptTerms\":true}"; echo |
| 12 | 12 | echo "--- réutilisation (attendu 400)"; curl -s -o /dev/null -w "%{http_code}\n" -X POST $B/api/auth/verify-code "${H[@]}" -d "{\"challengeId\":\"$CID\",\"code\":\"$CODE\",\"acceptTerms\":true}" |
@@ -23,12 +23,12 @@ echo "--- upload png"; UP=$(curl -s -b $CJ -X POST $B/api/listings/$LID/media/pr | ||
| 23 | 23 | echo "--- upload fichier non image (attendu 422)"; UP=$(curl -s -b $CJ -X POST $B/api/listings/$LID/media/presign "${H[@]}" -d '{}' | python3 -c "import json,sys;print(json.load(sys.stdin)['uploadUrl'])"); printf 'MZ not an image at all........' | curl -s -b $CJ -X PUT "$B$UP" -H "content-type: image/png" "${H[@]:1}" --data-binary @-; echo |
| 24 | 24 | echo "--- publish sans attestation (attendu 422)"; curl -s -b $CJ -X POST $B/api/listings/$LID/publish "${H[@]}" | cut -c1-200; echo |
| 25 | 25 | echo "--- attest + publish"; curl -s -b $CJ -X POST $B/api/listings/$LID/attest "${H[@]}" >/dev/null; curl -s -b $CJ -X POST $B/api/listings/$LID/publish "${H[@]}"; echo |
| 26 | −SLUG=$(psql ${PSQL_URL:+"$PSQL_URL"} ${PSQL_URL:-"-d $DB"} -tA -c "select slug from listings where id='$LID'"); echo "--- page publique toveli:$SLUG"; curl -s -o /tmp/dp.html -w "%{http_code} %{size_download}o\n" "$B/annonce/toveli%3A$SLUG"; grep -o "Publiée par le propriétaire\|Contacter\|Demander une visite\|En bref\|Budget mensuel" /tmp/dp.html | sort | uniq -c | |
| 26 | +SLUG=$(psql "${PSQL_URL:-$DB}" -tA -c "select slug from listings where id='$LID'"); echo "--- page publique toveli:$SLUG"; curl -s -o /tmp/dp.html -w "%{http_code} %{size_download}o\n" "$B/annonce/toveli%3A$SLUG"; grep -o "Publiée par le propriétaire\|Contacter\|Demander une visite\|En bref\|Budget mensuel" /tmp/dp.html | sort | uniq -c | |
| 27 | 27 | echo "--- recherche unifiée"; curl -s "$B/api/search?marche=location&ville=Montr%C3%A9al&limit=3" | python3 -c "import json,sys;d=json.load(sys.stdin);print(d['total'], [i['uid'] for i in d['items']][:3])" |
| 28 | −echo "--- second utilisateur : contact + visite"; curl -s -X POST $B/api/auth/request-code "${H[@]}" -d '{"email":"acheteur@exemple.ca"}' >/dev/null; CODE2=$(tail -1 data/outbox/mails.jsonl | python3 -c "import json,sys,re;m=json.loads(sys.stdin.readline());print(re.search(r'\b(\d{6})\b',m['text']).group(1))"); CID2=$(psql ${PSQL_URL:+"$PSQL_URL"} ${PSQL_URL:-"-d $DB"} -tA -c "select id from otp_challenges order by created_at desc limit 1"); CJ2=$(mktemp); curl -s -c $CJ2 -o /dev/null -X POST $B/api/auth/verify-code "${H[@]}" -d "{\"challengeId\":\"$CID2\",\"code\":\"$CODE2\",\"acceptTerms\":true}" | |
| 28 | +echo "--- second utilisateur : contact + visite"; curl -s -X POST $B/api/auth/request-code "${H[@]}" -d '{"email":"acheteur@exemple.ca"}' >/dev/null; CODE2=$(tail -1 data/outbox/mails.jsonl | python3 -c "import json,sys,re;m=json.loads(sys.stdin.readline());print(re.search(r'\b(\d{6})\b',m['text']).group(1))"); CID2=$(psql "${PSQL_URL:-$DB}" -tA -c "select id from otp_challenges order by created_at desc limit 1"); CJ2=$(mktemp); curl -s -c $CJ2 -o /dev/null -X POST $B/api/auth/verify-code "${H[@]}" -d "{\"challengeId\":\"$CID2\",\"code\":\"$CODE2\",\"acceptTerms\":true}" | |
| 29 | 29 | curl -s -b $CJ2 -X POST $B/api/listings/$LID/inquiries "${H[@]}" -d '{"body":"Bonjour, le logement est-il encore disponible ? Mon numéro : 514 555 0100"}'; echo |
| 30 | 30 | SLOT=$(python3 -c "import datetime;print((datetime.datetime.now(datetime.timezone.utc)+datetime.timedelta(days=2)).isoformat())"); curl -s -b $CJ2 -X POST $B/api/listings/$LID/viewings "${H[@]}" -d "{\"slots\":[\"$SLOT\"],\"note\":\"Je peux passer en soirée.\"}"; echo |
| 31 | 31 | echo "--- propriétaire voit la demande (téléphone masqué ?)"; curl -s -b $CJ "${G[@]}" $B/api/me/inquiries | python3 -c "import json,sys;d=json.load(sys.stdin);print(len(d['inquiries']),'conversations,',len(d['viewings']),'visites;', d['inquiries'][0]['lastBody'] if d['inquiries'] else '')" |
| 32 | −echo "--- intrus (attendu 404)"; IQ=$(psql ${PSQL_URL:+"$PSQL_URL"} ${PSQL_URL:-"-d $DB"} -tA -c "select id from inquiries order by created_at desc limit 1"); curl -s -o /dev/null -w "%{http_code}\n" "${G[@]}" $B/api/inquiries/$IQ/messages | |
| 32 | +echo "--- intrus (attendu 404)"; IQ=$(psql "${PSQL_URL:-$DB}" -tA -c "select id from inquiries order by created_at desc limit 1"); curl -s -o /dev/null -w "%{http_code}\n" "${G[@]}" $B/api/inquiries/$IQ/messages | |
| 33 | 33 | echo "--- pages"; for u in /membre /membre/annonces /membre/annonces/$LID /membre/messages /membre/visites /membre/profil /membre/securite /membre/alertes /membre/favoris /connexion /admin "/recherche?marche=location&ville=Montr%C3%A9al"; do printf "%s → " "$u"; curl -s -b $CJ "${G[@]}" -o /dev/null -w "%{http_code}\n" "$B$u"; done |
| 34 | −echo "--- courriels simulés"; psql ${PSQL_URL:+"$PSQL_URL"} ${PSQL_URL:-"-d $DB"} -tA -c "select template, status, count(*) from email_deliveries group by 1,2 order by 1" | |
| 34 | +echo "--- courriels simulés"; psql "${PSQL_URL:-$DB}" -tA -c "select template, status, count(*) from email_deliveries group by 1,2 order by 1" | |
| 35 | 35 | |