SPB Git forge

spb/toveli

Public
14commits 1branches 0releases
1.1 MBsize
maindefault branch
1 h agolast push
TypeScript 50.6% JavaScript 46.1% SQL 2.1% Shell 0.6% CSS 0.5%

docs : architecture comptes/annonces, README ; smoke.sh paramétrable (origine, URL psql)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 h ago (Oct 17, 2026) parent dad9e02

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