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
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):
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
| 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
Social sharing §3 (spec), 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
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_atends it:app.granted_vehicles()ignores a grant past it at once, and the cron tick'sgrantExpiryjob (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.settakesexpiresAtper car (one already past grants nothing); an invite link carries none (shortcut). - Approvals. With
needs_approvalon the grant, what the Member logs (expense.create,service.create, confirming a recurring expense) is written withstatus = '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.listshows the caller's cars' awaiting entries (a manager: every car; a Member: the cars their grant owns);entryApproval.approvesets themapproved(auditedentry.approved);entryApproval.rejectappends acorrectionto zero with the reason, points the row at it and setsrejected, which takes it out of every list (auditedentry.rejected). The ledger is never updated. A spreadsheet import writesapproved(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_transferholds 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 handlevehicle-transfer.accept, since a Member cannot write grants): the car's live passports are revoked, their grant becomesowner, 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
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
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
packages/db/src/schema/vehicle-member.tspackages/db/src/schema/invite-link.tspackages/api/src/features/invite-link/invite-link.service.tspackages/api/src/features/vehicle-member/vehicle-member.service.tspackages/api/src/features/vehicle-member/vehicle-member.jobs.tspackages/api/src/features/entry-approval/entry-approval.service.tspackages/api/src/features/vehicle-transfer/vehicle-transfer.service.tspackages/api/src/platform/rpc/require-vehicle-role.tsapps/mobile/src/features/vehicle-member/vehicle-member.screen.tsx