# Passports

How Prepare sale (M-Vender) shares a car as a field-by-field snapshot behind a hashed link, how the buyer opens it on the web without signing up, and how Passports (M-Passaporte) shows visits, moves an expiry and revokes a link.

## What it does [#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_record` row (`passport_share`, version `passport-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 [#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 [#the-buyers-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 [#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 [#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 [#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 [#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 [#key-files]

- [`packages/api/src/features/passport/passport.contract.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/passport/passport.contract.ts)
- [`packages/api/src/features/passport/passport.service.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/passport/passport.service.ts)
- [`packages/api/src/features/passport/passport.rules.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/passport/passport.rules.ts)
- [`packages/db/src/schema/passport.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/db/src/schema/passport.ts)
- [`packages/api/src/features/passport/passport.jobs.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/passport/passport.jobs.ts)
- [`apps/web/src/features/passport/passport.view.tsx`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/web/src/features/passport/passport.view.tsx)
- [`apps/mobile/src/features/passport/passport.prepare.screen.tsx`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/features/passport/passport.prepare.screen.tsx)
- [`apps/mobile/src/features/passport/passport.list.screen.tsx`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/features/passport/passport.list.screen.tsx)

