# Family garage

How M-Familia shares a household's cars with a preset or an area matrix per car: vehicle_member grants (until a date, with approvals), invite links and QR, the activity line, handing a car to another Member, the row-level security that hides ungranted cars and areas, and the permissions procedures enforce.

## What it does [#what-it-does]

The family garage (US41, RF42) lets the owners and admins of an Organization (the household) give another Member some of its cars, each with an Access Preset or a custom matrix of Vehicle Permissions (privacy spec §4, boards G-Papeis and G-Permissoes). Owners and admins see and do everything; any other Member sees only the cars granted to them, and of each only the areas it permits.

The matrix has six areas (expenses, maintenance, odometer, documents, net worth, photos), each `see`, `log` (implies see) or `edit` (correct and delete; implies log), or left out (hidden). Selling and removing the car are not an area: the household's owners and admins, or an `owner` grant, do that. The presets are fixed sets (`PRESET_PERMISSIONS` in `vehicle-member.enums.ts`):

| Preset       | Words              | Expenses | Maintenance | Odometer | Documents | Net worth | Photos |
| ------------ | ------------------ | -------- | ----------- | -------- | --------- | --------- | ------ |
| `co_owner`   | "Co-owner"         | edit     | edit        | edit     | edit      | edit      | edit   |
| `can_log`    | "Can log expenses" | log      | log         | log      |           |           | log    |
| `driver`     | "Driver"           |          |             | log      |           |           | see    |
| `workshop`   | "Workshop"         |          | log         | see      |           |           | see    |
| `accountant` | "Accountant"       | see      | see         | see      | see       | see       | see    |
| `view_only`  | "View only"        | see      | see         | see      |           |           | see    |
| `owner`      | "Owner" (legacy)   | edit     | edit        | edit     | edit      | edit      | edit   |

`custom` stores the matrix as set, named after the preset it equals; a matrix that grants nothing takes the car back. Each grant also keeps the coarse Vehicle Role (`owner` for an `owner` grant, `editor` when an area is at `log` or more, else `viewer`) for the checks that are not about one area. A fuel-up counts as an odometer entry: a Driver logs it (and only it) without seeing the car's expenses.

The migration `vehicle_permissions` turned the old roles into presets: `owner` stays Owner, `editor` became Can log expenses and `viewer` View only; the old documents-and-net-worth switch added Documents and Net worth (`editor`: documents `edit`, net worth `log`, as custom; `viewer`: Accountant).

## Data and isolation [#data-and-isolation]

`vehicle_member` holds one row per car and grantee: a Member (`member_id`, cascading when they leave the household) or a pending Invitation (`invitation_id`). Accepting the Invitation moves its rows to the new Member in Better Auth's `afterAcceptInvitation` hook.

Two functions feed every car-scoped policy ([ADR-0009](/docs/adr/0009-row-level-security)):

- `app.manages_org()`: the context User is an owner or admin of the Active Organization;
- `app.granted_vehicles(area)`: the cars their grants name (`'car'`), or those whose permissions name the area.

`vehicle`, `ownership_period`, `expense`, `acquisition`, `disposal`, `share_package` and the rest admit a row when its car is managed or granted. Per area, a row also needs its area seen: `ledger_entry` (every amount) Expenses, unless the caller wrote it (a Driver's fill-up); `recurring_expense` Expenses; `service` and a reminder Maintenance (a document's due-date reminder Documents); `odometer_reading` Odometer; `vehicle_photo` Photos; `vehicle_document` Documents; `valuation_snapshot` and `loan` (and through it `loan_snapshot`) Net worth. Totals, insights and the activity line read the ledger under the same policy, so they hold only what the caller may see. `share_access` follows its passport. Only owners and admins write `vehicle_member`, and they may read pending Invitations to grant them. The RLS suite proves that a Member of the household without a grant sees nothing of a car, View only no net worth, and a Driver only their own amounts.

## Procedures [#procedures]

| Procedure                                                    | Who               | Failures                                                                                      |
| ------------------------------------------------------------ | ----------------- | --------------------------------------------------------------------------------------------- |
| `vehicleMember.list` `{}`                                    | every Member      | none: every grant for an owner or admin, the caller's own otherwise, and `manages`            |
| `vehicleMember.set` `{ memberId \| invitationId, vehicles }` | owners and admins | `FORBIDDEN`, `INPUT_VALIDATION`, `NOT_FOUND` (grantee or car), `CONFLICT` (an owner or admin) |

Each car in `vehicles` is `{ vehicleId, preset, permissions? }` (the matrix only for `custom`). `set` replaces what the person holds and is audited (`vehicle.access_changed`). Inviting by email, removing a Member and cancelling an Invitation stay Better Auth's organization plugin.

| Procedure                          | Who                        | Failures                                                                                         |
| ---------------------------------- | -------------------------- | ------------------------------------------------------------------------------------------------ |
| `vehicleMember.activity` `{}`      | every Member               | none: others' expenses and services on the cars the caller sees, newest first, no amounts        |
| `inviteLink.create` `{ vehicles }` | owners and admins          | `FORBIDDEN`, `INPUT_VALIDATION`, `NOT_FOUND` (a car)                                             |
| `inviteLink.list` `{ limit }`      | owners and admins          | `FORBIDDEN`                                                                                      |
| `inviteLink.revoke` `{ id }`       | owners and admins          | `NOT_FOUND` (used, revoked or another household's)                                               |
| `inviteLink.open` `{ token }`      | anyone (the page, the app) | `TOO_MANY_REQUESTS`; null for a used, revoked, expired or unknown link                           |
| `inviteLink.accept` `{ token }`    | any signed-in User         | `NOT_FOUND` (as for open), `CONFLICT` (an owner or admin of that household), `TOO_MANY_REQUESTS` |

## Invite by link or QR [#invite-by-link-or-qr]

Social sharing §3 ([spec](https://github.com/CarlosZiegler/autofolio/blob/main/docs/specs/social-sharing.md)), behind `social.invite-link`. `invite_link` keeps the household, the grants chosen before sharing (`vehicles`: car, preset, permissions and role each; no foreign key: a car that leaves drops out), `expires_at` (7 days), `used_at`/`used_by` and `revoked_at`. The token is the passport's (256 random bits; only its SHA-256 and an 8-character hint are stored), and the URL is `<origin>/i/<token>`. Tenants may read, create and revoke the household's links as its owners or admins; nothing else is granted.

The invitee is not a Member yet, so `open` and `accept` run as system code (SystemReason `invite-link.accept`), keyed by the token's hash and rate-limited (30 openings per address, 10 accepts per User, a minute). `accept` claims the link first in one transaction (of two people racing, one gets it), adds the User as a `member` of the household (role `member`) unless they already are, upserts their `vehicle_member` grants as the link holds them and audits `invite_link.accepted`; `create` and `revoke` are audited by `withAudit`. An owner or admin of that household who opens their own link gets `CONFLICT` and the link stays for the invitee.

The web page `/i/:token` (`features/invite-link/invite-link.page.tsx`) shows who invites, the cars with their preset, whether documents or net worth come with them, and until when, and "Open in the app" (`<NATIVE_APP_SCHEME>://invite/<token>`); like `/p/` and `/g/` it is `noindex`, `no-store`, `no-referrer`, disallowed in robots.txt, and the log scrubber drops its token.

## Temporary access, approvals and handing a car over [#temporary-access-approvals-and-handing-a-car-over]

Privacy spec §4, boards G-Temporario, G-Aprovacao and G-Transferir; flags `people.temporary-access` and `people.approvals` (on in dev) show them in the app.

- **Until a date.** A grant's `expires_at` ends it: `app.granted_vehicles()` ignores a grant past it at once, and the cron tick's `grantExpiry` job (`vehicle-member.jobs.ts`, 100 per tick) deletes the due ones and pushes "Access ended" to the person and to the household's owners and admins. `vehicleMember.set` takes `expiresAt` per car (one already past grants nothing); an invite link carries none (`shortcut`).
- **Approvals.** With `needs_approval` on the grant, what the Member logs (`expense.create`, `service.create`, confirming a recurring expense) is written with `status = 'awaiting'`: in the ★ ledger like any other entry, but out of M-Gastos, insights, the car's History and the sale result (`heldBack`) until the car's owner decides. `entryApproval.list` shows the caller's cars' awaiting entries (a manager: every car; a Member: the cars their grant owns); `entryApproval.approve` sets them `approved` (audited `entry.approved`); `entryApproval.reject` appends a `correction` to zero with the reason, points the row at it and sets `rejected`, which takes it out of every list (audited `entry.rejected`). The ledger is never updated. A spreadsheet import writes `approved` (`shortcut`), and a reading is a fact, not a total: an awaiting fill-up's odometer reading counts at once.
- **Handing a car over.** `vehicle_transfer` holds one waiting offer per car (`vehicleTransfer.offer`, for the car's owner, to a Member who does not manage the household). The offer names its car (`car`: make, model, year and nickname, never plate or VIN), copied when it is made, because the Member holds no grant that would let them read the car before accepting. The Member declines (`decline`) or accepts (`accept`, on the system handle `vehicle-transfer.accept`, since a Member cannot write grants): the car's live passports are revoked, their grant becomes `owner`, and a giver who held the car by a grant keeps the preset they chose (`giver_preset`) or leaves it. The household's owners and admins keep every car. The giver or a manager withdraws (`withdraw`). Every step is audited (`vehicle_transfer.*`); the history stays with whoever logged it.

## Activity line [#activity-line]

`vehicleMember.activity` reads `ledger_entry` (operating costs and upgrades, not imports or corrections) by authors other than the caller, with whether a `service` points at the entry, newest first, at most 20. The car tables' row-level security keeps it to the cars the caller sees; the app names the authors from the household's Members ("Someone" once they left).

Every car write chains a check after its input (`platform/rpc/require-vehicle-role.ts`): `requireVehiclePermission(area, level, of)` for the area it writes (`log` to add, `edit` to correct, change or delete), `requireVehicleRole("owner", of)` to buy, finance a purchase, sell, share (passports, showcases, history transfers, a partner's confirmation) or remove it, `requireVehicleRole("editor", of)` for the car's own details, Moments and insights. `expense.create` takes Expenses `log`, or Odometer `log` for a fuel-up. A weaker grant answers `FORBIDDEN`; no grant leaves the car to row-level security (`NOT_FOUND`, as for another household's car). Adding a car, "I bought it" and data exports are for owners and admins (`requireOrgRole`), since the plan limit and the export count every car.

What row-level security cannot see is closed too: reminder alerts reach a Member only for their cars and only with Maintenance (document due dates with Documents), a spreadsheet import writes only to cars with Expenses `log`, and the Organization's files (the Files gateway, Document Text procedures and the agent tools) are for owners and admins.

## App [#app]

M-Familia (`screen-M-Familia`, route `/family`) opens from Profile. It lists the household's people with their strongest preset, then its current cars with who holds them ("Dana: can log expenses"), and the privacy note. For owners and admins, "Invite someone" (`familia-convidar`) opens the access sheet: email or link, a preset or none per car, Send. A person opens the sheet with their cars (`familia-carro-<id>`, each with its preset) and Remove (or cancel the Invitation); a car opens G-Papeis.

G-Papeis (`screen-G-Papeis`, route `/vehicle-access?vehicleId&person`) titles "What Dana can do" over the car, lists the presets as a radio group (`papeis-<preset>`) and Custom (`papeis-custom`), Save (`papeis-salvar`) and, for a car they hold, "Remove access to this car" (`papeis-remover`, confirmed). Custom opens G-Permissoes (`screen-G-Permissoes`, `/vehicle-permissions?vehicleId&person&from`): the matrix from the preset G-Papeis had chosen, a checkbox per area and level (`permissoes-<area>-<level>`; checking Edit checks Log and See, clearing See clears the rest), Sale locked "owner only", the rule, and "Save permissions" (`permissoes-salvar`). Saving returns to M-Familia. The "more" button opens My invitations and "Your garages", the household switcher: the User's households (no slugs), the open one checked; switching opens the other garage at its start, and the next sign-in opens on the household last switched to while the User is still its Member (`account_preference.last_organization_id`). M-Veiculo hides what the grant cannot do: Service and Documents segments without their areas, Add expense without Expenses or Odometer `log` (a Driver's form offers fuel only), Add a value without Net worth `log`, the gallery without Photos, selling without `owner`.

G-Papeis's second group, with the flags: "Access until" (`papeis-ate`) opens a sheet with Today, 3 days, 1 week, Choose date (each until 23:59) or No end date; "Approve what Dana logs" (`papeis-aprovar`) is a switch, shown for a preset that logs. Changing the preset or the matrix keeps both.

M-Familia's first group lists what waits for the User: "To approve" (`familia-aprovar`) and each car another Member is handing them (`familia-oferta`). A car the User owns (`familia-carro-<id>`) opens a sheet: "Lend the car" (`familia-emprestar`, managers) and "Hand to a member" (`familia-transferir`); a car with an offer waiting says "Waiting for Dana to accept".

G-Temporario (`screen-G-Temporario`, `/vehicle-lend?vehicleId&person`): To (`temporario-para`: a Member, or someone new by email, who gets an Invitation holding the car), Can (`temporario-pode`: a preset, Driver by default), the span chips (`temporario-today`, `-3d`, `-1w`, `-date`), Ends and "Send invite" (`temporario-enviar`). G-Aprovacao (`screen-G-Aprovacao`, `/approvals`): each entry with who logged it, the car, when and the amount, Approve (`aprovacao-aprovar`) and Reject (`aprovacao-recusar`, a sheet asking for the reason), and "Approve all 3" (`aprovacao-todos`). G-Transferir (`screen-G-Transferir`, `/vehicle-transfer?vehicleId&person`): New owner (`transferir-para`), You stay as (`transferir-fico`; a manager keeps every car), what changes, and "Send for Dana to accept" (`transferir-enviar`); with an offer waiting, Withdraw (`transferir-retirar`). The Member answers on `screen-G-Transferir-Aceitar` (`/vehicle-transfer-offer?id`, not drawn): Accept the car (`transferir-aceitar`) or Decline (`transferir-recusar`). M-Veiculo's Costs and Service rows say "Awaiting approval".

shortcut: the expiry and a new offer are not pushed when they are made, only when an access ends; the offer is found in M-Familia.

With `social.invite-link`, the invite sheet starts on "Link or QR" (`familia-via-link`; "Email" is `familia-via-email`): "Create invite link" opens the Link created sheet with the QR (`sheet-link-qr`), the link, Copy link and Share link. Pending links are rows of the people group ("Invite link · 2 cars · until Oct 30"), revoked after a confirmation, and "Recent activity" (`familia-atividade`) shows the last five lines. The garage shows the people header (`garage-pessoas`: avatars, "Shared garage", the latest line) once the household has more than one Member; it opens M-Familia. `autofolio://invite/<token>` opens the invite (`screen-Social-Invite`, public): signed in, "Join the garage" (`convite-entrar`) switches to the household and opens its garage; signed out, "Create account" or "I have an account" come back to it after the sign-in. `autofolio://dev/Social-People` opens M-Familia.

shortcut: an https invite link opens the web page, which hands over to the app; universal links (apple-app-site-association, Android App Links) would skip it.

`autofolio://dev/M-Familia` opens the seeded household: Dana can log the Corolla's expenses, each awaiting approval (a fill-up today, a car wash yesterday), Sam may view a sold car and has the Porsche lent as a Driver for three days (a fill-up awaiting approval). `autofolio://dev/G-Papeis` and `autofolio://dev/G-Permissoes` open Dana's access to the Corolla Cross, `G-Temporario` the Porsche lent to Sam, `G-Transferir` the Corolla handed to Dana and `G-Aprovacao` the three entries.

shortcut: an Invitation cancelled or rejected keeps its grants until it is deleted; they are never materialized and do not show.

## Key files [#key-files]

- [`packages/db/src/schema/vehicle-member.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/db/src/schema/vehicle-member.ts)
- [`packages/db/src/schema/invite-link.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/db/src/schema/invite-link.ts)
- [`packages/api/src/features/invite-link/invite-link.service.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/invite-link/invite-link.service.ts)
- [`packages/api/src/features/vehicle-member/vehicle-member.service.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/vehicle-member/vehicle-member.service.ts)
- [`packages/api/src/features/vehicle-member/vehicle-member.jobs.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/vehicle-member/vehicle-member.jobs.ts)
- [`packages/api/src/features/entry-approval/entry-approval.service.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/entry-approval/entry-approval.service.ts)
- [`packages/api/src/features/vehicle-transfer/vehicle-transfer.service.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/features/vehicle-transfer/vehicle-transfer.service.ts)
- [`packages/api/src/platform/rpc/require-vehicle-role.ts`](https://github.com/CarlosZiegler/autofolio/blob/main/packages/api/src/platform/rpc/require-vehicle-role.ts)
- [`apps/mobile/src/features/vehicle-member/vehicle-member.screen.tsx`](https://github.com/CarlosZiegler/autofolio/blob/main/apps/mobile/src/features/vehicle-member/vehicle-member.screen.tsx)

