Skip to main content

Invite System & Access Control Propagation

How residents, guests, and staff are invited into properties and how their access is propagated across the system.

Overview​

The invite system spans three layers:

  1. Clerk (identity provider) -- handles user accounts and sends invitation emails
  2. bf_resident D1 database -- stores invite records with status tracking
  3. bf_user Durable Objects -- per-property persistent storage that enforces access control at request time

Invites flow one-directionally: API -> Clerk + D1 -> Durable Object -> Access Control decisions.

 Invite created (API)
|
v
+------+-------+
| Clerk | <-- invitation sent (new users only)
| bf_resident | <-- invite record stored in D1
+------+-------+
|
v (on accept)
+------+-------+
| bf_user DO | <-- guest/tenant record synced via RPC
+------+-------+
|
v (on each request)
+------+-------+
| Permissions | <-- role + capability check per RPC call
+--------------+

Invite Roles​

RolepublicMetadata.rolesDO Sync TargetDescription
Tenanttenanttenants tableFull resident access
Guesttenantguests tableCapability-gated access
Installerinstaller--Commissioning access
PropertyManagerproperty_manager--Admin-level management

Note: Guests are mapped to tenant because Clerk has no guest role. The Durable Object's guests table and permission system enforce the real access boundaries.

No Clerk organisations​

We don't use Clerk organisations. Authorisation is driven entirely by publicMetadata.roles, which clerk_auth::roles reads server-side and stores/userRoles.ts reads in the web app.

Invitations are Clerk application invitations (POST /v1/invitations, ClerkApiClient::create_invitation), not organisation invitations. Clerk caps every organisation at 20 members, with pending invitations counted towards the cap. Raising it needs the paid Enhanced B2B Authentication add-on, even on the Pro plan, and we hit that cap in October 2026. Application invitations have no such cap. The role goes in the invitation's public_metadata as {"roles": ["<role>"]}. For existing users, ClerkApiClient::add_user_role appends the role to the ones they already hold.

Clerk allows 100 invitations per hour per instance.

Invite Creation​

Handler: crates/bf_resident/src/handlers/invites.rs -- create_invite()

  1. Authorisation: Only Admins can create Installer/PM invites; PMs can create Tenant/Guest invites
  2. Token generation: A random UUID becomes the accept_token in the invite URL
  3. Clerk invitation: If no Clerk user exists for the email and none is pending, a Clerk application invitation is created. Its email goes through the Clerk email webhook (slug invitation) and is sent as our branded Resend template. The invitation's redirect_url is /invites/accept?token=<accept_token> and its expiry matches the local invite.
  4. D1 record: Invite stored with status Pending in bf_resident_db
  5. Auto-accept optimisation: If a Clerk user already exists for the email, they are granted the role and the invite is accepted and synced to the DO straight away, without needing the email link

Key files:

  • crates/bf_resident/src/handlers/invites.rs -- HTTP handlers
  • crates/bf_resident_db/src/operations/invites.rs -- D1 CRUD operations

Invite Acceptance​

Handler: crates/bf_resident/src/handlers/invites.rs -- accept_invite()

When a user clicks the invite link:

  1. Token is looked up in the invites table
  2. Expiry and duplicate-acceptance checks run
  3. User is created in Clerk if they don't exist, and the invite's role is added to publicMetadata.roles. Creating the user also accepts their pending Clerk invitation. Any invitation still pending is revoked.
  4. Only the invite identified by the token is accepted. Other pending invites for the same email are not.
  5. The appropriate DO sync fires (see below)
  6. Activity log entries are recorded
  7. User is redirected to the app

Durable Object Synchronisation​

Once an invite is accepted, the relevant data must be pushed to the property's Durable Object so that the DO can enforce access control locally.

Tenant Sync​

Files:

  • crates/bf_resident_db/src/user.rs -- sync_tenants_to_do() (lines 387-404)
  • crates/bf_user/src/durable_object/sync.rs -- sync_tenants_internal()

The tenant sync sends a full list of accepted tenants to the DO via RpcRequest::SyncTenants. The DO replaces its tenants table contents entirely.

Guest Sync​

Files:

  • crates/bf_resident_db/src/user.rs -- sync_guest_to_do() (lines 544-558)
  • crates/bf_user/src/durable_object/guests.rs -- invite_guest_internal() (lines 60-81)

Guest sync is incremental -- each accepted guest invite fires an RpcRequest::InviteGuest { user_email } RPC call. The DO creates a guest record with:

  • status: Active
  • is_permanent: true
  • access_start: current timestamp
  • access_end: None (no expiry by default)
  • permissions: default (monitor only)

The email column has a UNIQUE constraint, so re-syncing the same guest is a no-op (INSERT OR IGNORE).

Guest Revocation​

Files:

  • crates/bf_resident_db/src/user.rs -- revoke_guest_from_do() (lines 561-574)
  • crates/bf_user/src/durable_object/guests.rs -- revoke_guest_by_email_internal()

When an invite is deleted, RpcRequest::GuestRevoked { user_email } is sent to the DO, which sets the guest's status to Revoked.

Full Property Sync​

File: crates/bf_resident_db/src/user.rs -- sync_property_to_do() (lines 467-511)

Used during property creation or manual re-sync. Pushes all property metadata, devices, tenants, documents, and guests in a single batch via the RefreshMetadata RPC.

Access Control in the Durable Object​

File: crates/bf_user/src/permissions.rs

Every RPC request to the DO passes through check_permission(), which:

  1. Resolves user roles (get_user_roles(), line 47):
    • Checks Clerk JWT for Admin/Installer/Tenant roles
    • Queries the DO's tenants table for tenant status
    • Queries the DO's guests table for status = 'Active'
  2. Checks any role grants access for the given request and property status

Property Status Gates​

Property StatusAllowed Roles
SetupAdmin, Installer
Commissioning, Commissioned, PreTenancyAdmin, Installer
ScheduledMovedIn, Tenancy, ScheduledMovedOutAdmin, Tenant, Guest

Guest Permission Capabilities​

File: crates/bf_user/src/permissions.rs -- check_guest_permission() (line 328)

Guests have fine-grained capability-based access rather than blanket role access:

CapabilityControls
monitor (always on)List devices, read states, notifications, access logs
security_controlArm/disarm alarm hub
front_door_and_cameraLock/unlock doors, doorbell sessions
thermostat_controlTemperature setpoints, HVAC modes
view_energy_usageEnergy readings and graphs

Denied to all guests: Guest/PIN management, admin operations, backup/restore, sync operations.

GuestStatus Serialisation Convention​

Guest status is stored as plain text in the DO's SQLite database (e.g., Active, Revoked, Expired). The GuestStatus enum uses strum derives for string conversion:

// crates/bf_user/src/models.rs
#[derive(strum_macros::AsRefStr, strum_macros::EnumString, ...)]
#[strum(serialize_all = "PascalCase")]
pub enum GuestStatus {
Active,
Expired,
Revoked,
}

SQL queries compare against plain text values:

-- Correct:
SELECT COUNT(*) FROM guests WHERE email = ? AND status = 'Active'

-- Incorrect (legacy bug pattern -- do NOT use serde_json::to_string for enum status columns):
-- status = '"Active"'

Convention: Always use .as_str() or .as_ref() (via strum) when storing enum values in SQL columns. Never use serde_json::to_string() for simple enum status fields, as this wraps the value in JSON double-quotes that break SQL WHERE clause comparisons. See the PropertyStatus pattern in crates/bf_types/src/resident/property.rs for the canonical example.

Sequence Diagram​

PM/Admin                  bf_resident              Clerk              bf_user DO
| | | |
|-- POST /invites -------->| | |
| |-- create invitation->| |
| |-- INSERT invite ----->| |
| |<-- invite created ----| |
|<-- 201 Created ----------| | |
| | | |
| User clicks email link | |
| | | |
| |<-- GET /accept?token=.. |
| |-- create user/role ->| |
| |-- UPDATE status=Accepted |
| |-- RPC: InviteGuest --|-----------------> |
| | | upsert guest |
| | | status=Active |
| |<-- redirect to app --| |
| | | |
| Guest accesses property | |
| | | |
|-- RPC request (via proxy)|-----------------------------------> |
| | | check_permission |
| | | get_user_roles |
| | | -> Guest role |
| | | check_guest_perm |
|<-- response (or 403) ----|<------------------------------------| |

Troubleshooting​

Guest Cannot Access After Accepting Invite​

  1. Check the guests table in the property's DO -- does the record exist?
  2. Verify status column value is exactly Active (no JSON quotes)
  3. Check property status is occupied (ScheduledMovedIn, Tenancy, or ScheduledMovedOut)
  4. Verify the guest's email matches (case-insensitive lookup via LOWER())

Invite Sync Not Reaching DO​

  1. Check bf_resident_db::user::sync_guest_to_do() logs for RPC errors
  2. Verify the property has a valid DO binding in wrangler config
  3. Check that bf_user::proxy() can reach the DO (service auth must be configured)