What it does
A Passport (RF39, US38) is what a seller shows a buyer: a copy of the sections the owner ticks, never a view of the account (RN08). Prepare sale offers five sections with their counts: Photos, Services and mileage, Anonymized receipts, Specs and Costs and values. The car's make, model and year always go; plate, VIN, address and garage location never can (RN09), and materializePassport from @autofolio/domain throws if one is ever asked for. The link is valid for 7, 30 or 90 days.
"Create link" builds the snapshot (ADR-04 of the handoff) and stores it in share_package with:
- the SHA-256 of a random 256-bit token (base64url); the link
<origin>/p/<token>is returned once and never stored; - the token's first eight characters as a hint, so the owner tells links apart (
autofolio.app/p/7Kq2wM9x…); the other 35 keep 210 bits secret; - copies of the car's photos (at most 30, cover first) under
passports/<id>/, named by position so a public URL carries no internal id, made with a server-side S3 copy before the row is written; a failure removes the copies already made; - a ★
consent_recordrow (passport_share, versionpassport-2026-10) for the owner who shared it.
Receipts are shared as each service's date, shop and item names only. A receipt file can carry names and addresses that cannot be removed reliably, so no file and no amount leaves the account. Services carry date, title, reading and trust, never their cost or note.
The view the buyer gets
passportView(snapshot, expiresAt, sign) in passport.service.ts turns a stored snapshot into PassportView (passport.contract.ts): the car, validUntil, and each section or null, with photos signed for 15 minutes. "Preview as buyer" (passport.preview) builds the same view for the current choice without storing or copying anything, so the preview is what the buyer gets. Distances are km; each reader's page shows its own unit.
The buyer's page
/p/:token in apps/web (D-Passaporte, US40, RF40) needs no account. Its loader calls passport.open { token }, a public procedure that runs as system code (asSystem, SystemReason passport.open): it reads a live package by the token's hash, never a live table, and writes one ★ share_access row per visit with the date and the country Cloudflare's edge resolved (cf-ipcountry, on Workers only). No address is stored with it; the address only meters openings in rate_limit (60 a minute, swept by the auth cleanup), past which the call answers TOO_MANY_REQUESTS.
A revoked, expired or unknown token answers null, and the page shows the same neutral notice for all three (D-Passaporte-Revogado), so it never tells whether a link existed. A live one renders Summary (with this owner since, last mileage, services with a receipt, then specs and costs when shared, each estimate with source, date and confidence), Services (date, mileage, title and trust, then the anonymized receipts) and Photos, from the same PassportView as the owner's preview. Tabs the owner did not share are absent.
The page speaks the buyer's language (?lang=, the site cookie or Accept-Language among pt, en and de), else the owner's account language. Dates and money follow that language; distance is in its region's unit (mi for en-US). Responses under /p/ carry X-Robots-Tag: noindex, Cache-Control: private, no-store and Referrer-Policy: no-referrer, robots.txt disallows the path, and the log scrubber replaces any /p/<token> with [redacted].
Procedures
| Procedure | Failures |
|---|---|
passport.prepare { vehicleId } | NOT_FOUND (car), CONFLICT (not in the garage) |
passport.preview { vehicleId, sections, days } | INPUT_VALIDATION, NOT_FOUND (car), CONFLICT (not in the garage) |
passport.create { vehicleId, sections, days } | INPUT_VALIDATION, NOT_FOUND (car), CONFLICT (not in the garage) |
passport.list { vehicleId?, limit? } | INPUT_VALIDATION |
passport.get { id } | NOT_FOUND (link) |
passport.setExpiry { id, days } | INPUT_VALIDATION, NOT_FOUND (link), CONFLICT (revoked, cleaned) |
passport.revoke { id } | NOT_FOUND (link); revoking a revoked link does nothing |
passport.open { token } | INPUT_VALIDATION, TOO_MANY_REQUESTS; null when not available |
All but open are for Members of the Organization; open is public. list and get answer each link's status (active, expired, revoked), views and last visit; get adds its latest 100 visits.
Revoking
Revoke deletes the copied photos, then sets revoked_at and drops the snapshot in one statement; a retry after a failure finishes the job. The row and its visits stay. A CHECK keeps a revoked package snapshot-free, and the tenant update policy lets a live package change only its expiry or be revoked; a revoked one never changes again.
An expired link keeps its snapshot for PASSPORT_EXPIRED_KEEP_DAYS (30 days), so the owner can still extend it. Then the cron tick's passports job (passport.jobs.ts, 20 links a tick) deletes its photo copies and its snapshot, like a revoke; the link stays expired and its expiry no longer moves (passportExtendable, which also hides the row in the app).
Data and isolation
share_package and share_access are isolated by Active Organization; share_access and consent_record are append-only. No audit_log event is written: the consent record (who shared, when, which text) and the package's created_by and revoked_at are the trail, and withAudit's transaction would hold the storage calls.
App
Prepare sale (screen-M-Vender, route /prepare-sale/[vehicleId]) opens from the vehicle profile. Its toggles carry the prototype's ids (item-photos, item-services, item-docs, item-specs, item-costs); "Valid for" opens a 7 / 30 / 90-day sheet; "Create link" (criar-link) shows the Link created sheet with the link, Copy link, Share and Passports. "Preview as buyer" opens /passport-preview/[vehicleId].
Passports (screen-M-Passaporte, route /passports) opens from Profile and from M-Vender. Each card shows the car, the link's hint and status, views and last visit, and Revoke (after a confirmation); a card opens the link (/passports/[id]) with its expiry, what it shares and every visit.
The demo garage's M3 link carries copies of its three photos. In the English demo garage it opens at /p/demo-token, which flow F6 uses: npx playwright test (root playwright.config.ts, local only) seeds that garage through the dev seed sign-in, so the web app must run with DEV_SEED_LOGIN=true.
autofolio://dev/M-Vender opens the seeded M3; autofolio://dev/M-Passaporte shows its seeded live link with seven visits. Flow F5 ticks the receipts and creates a link.
Key files
packages/api/src/features/passport/passport.contract.tspackages/api/src/features/passport/passport.service.tspackages/api/src/features/passport/passport.rules.tspackages/db/src/schema/passport.tspackages/api/src/features/passport/passport.jobs.tsapps/web/src/features/passport/passport.view.tsxapps/mobile/src/features/passport/passport.prepare.screen.tsxapps/mobile/src/features/passport/passport.list.screen.tsx