Preconditions
- Inherits event-setup trunk: organizer is on the event setup hub for an event they own.
- Tenant is on a plan that allows event collaborators (some plans cap at owner-only).
- Email-sending channel configured for invitation delivery.
Happy path
-
Organizer opens Event Settings → Collaborators.
Page renders existing collaborators (paginated ui-data-table with per-row email, role, accepted-at, last-active). "Invite collaborator" CTA opens a modal.
-
Organizer enters email + selects role.
Form: email field, role select (organizer | read-only | support | check-in-staff | assistant), optional note (free text, sent in invitation email). Submit.
-
Server creates invitation, sends email.
collaborator_invitations row created (status=pending), HMAC-signed invite token, expires in 7 days. Invitation email sent via notification outbox.
-
Invitee accepts.
Click email link → /collab/accept?token=... → if invitee already has a Voyage account, prompt to sign in then auto-accept. If new, prompt to create account first then auto-accept. On accept, invitation transitions pending→accepted, collaborator_grants row created with role + event_id, audit log row written.
-
Collaborator can now use the event.
Role's permissions apply event-scoped only. The event appears in their event picker. The ui-permissions-matrix at Event Settings → Collaborators shows the collaborator with their granted abilities (computed from role).
-
Organizer revokes a collaborator.
Per-row "Remove" with ui-destructive-confirmation. On confirm, collaborator_grants row soft-deleted (kept for audit), active sessions invalidated within 60s (sessions reference the grant row's status). Audit log row written.
Failure modes
Permission denied at the right boundary
Trigger: read-only collaborator tries to add another collaborator.
Read-only role lacks collaborators:add ability. Server returns 403; UI hides the "Invite" CTA. Harness: stub read-only role, attempt POST invite, assert 403, assert no invitation row created.
Cross-event isolation
Trigger: collaborator with role on event A tries to access event B (same tenant).
Grants are event-scoped. Cross-event API access returns 404 (anti-probing — same as not-found). Harness: stub event-A grant, attempt event-B GET, assert 404.
Cross-tenant invitation forgery
Trigger: attacker forges an invitation token for a tenant they don't belong to.
Token signature is per-tenant HMAC. Forged token fails signature check; returns 404. Harness: forge token signed with wrong secret, assert 404.
Token expired
Trigger: invitee opens invitation link 8 days after sent (token expires after 7).
Server returns 410 INVITATION_EXPIRED. UI shows "This invitation has expired. Ask the organizer to resend." Organizer can resend from the admin queue (creates a new token, marks old as superseded). Harness: stub past expires_at, accept attempt, 410.
Token replay after acceptance
Trigger: invitee already accepted; same link clicked again (e.g., from email forward).
Token is one-time-use. Already-accepted token returns 410 INVITATION_ALREADY_USED. UI shows "This invitation was already accepted." If the invitee is signed in, deep-link to the event. Harness: accept twice, second returns 410.
Role escalation attempt
Trigger: invitee modifies the role parameter in the accept request payload.
Role is server-side from the invitation row, not from the accept request. The accept endpoint ignores any role in the payload. Harness: accept with payload role=organizer when invitation says read-only, assert grant.role === read-only.
Revocation cascade — active sessions
Trigger: collaborator currently has the admin shell open in another browser when their grant is revoked.
Sessions reference the grant row. Within 60s of grant.status=revoked, the next API call from the revoked session returns 403 GRANT_REVOKED with UI "Your access to this event was revoked." Harness: revoke grant, dispatch API call from active session within 60s, assert 403.
Invitation to an existing collaborator
Trigger: organizer invites the same email twice.
If the email already has an active grant on this event, return 409 ALREADY_COLLABORATOR with current role; UI shows "This person is already a collaborator (Read-Only). Update their role instead?" link to existing row. If pending invitation exists, return 409 INVITATION_PENDING with resend option. Harness: invite same email twice, second returns 409.
Audit log per state change
Trigger: lifecycle event — invite created, accepted, revoked, role changed.
Each transition writes an audit log row with operator_id (who acted), subject_id (collaborator), event_id, action, timestamp. Audit can reconstruct the full grant timeline. Harness: cycle through invite/accept/revoke, assert 3 audit rows in correct order.
Two organizers concurrent: simultaneous role change
Trigger: two organizers both edit the same collaborator's role within seconds.
Optimistic concurrency via grant.version. Stale write returns 409 VERSION_CONFLICT with conflict modal showing the current state. Last-actor's intent is preserved or rejected, never silent overwrite. Harness: stub version mismatch on PATCH, assert 409 + conflict modal visible.
Plan downgrade with active collaborators
Trigger: tenant downgrades to owner-only plan; existing event has 3 collaborators.
Existing grants preserved (no destructive plan changes). Owner can no longer add new collaborators (412 PRECONDITION_FAILED with "Upgrade to add collaborators"). Existing collaborators continue working until owner explicitly revokes. Audit row at downgrade time captures the snapshot. Harness: stub plan downgrade, assert existing grants active, new invites return 412.
Permission matrix display matches actual server enforcement
Trigger: ui-permissions-matrix shows "read events: yes" but server actually allows "edit events" too (drift).
The matrix is computed from a single source-of-truth role→ability mapping. Displayed permissions must match server enforcement bit-for-bit. Harness: enumerate every (role, ability) cell on the matrix; for each, attempt the server action; assert each cell's display matches the server's 200/403 response.
Stable test attributes
| data-test | Where | Purpose |
|---|---|---|
collaborators-page | Event Settings → Collaborators | Tab in event setup hub |
collaborators-list | Existing collaborators | ui-data-table; per-row role + accepted-at |
collaborators-invite-cta | Page header | Visible only when role has collaborators:add ability |
collaborators-invite-modal | Invite modal | Form fields: email, role, note |
collaborators-role-select | Inside invite modal | 5 options: organizer, read-only, support, check-in-staff, assistant |
collaborators-permissions-matrix | Page | ui-permissions-matrix showing role→ability mapping |
collaborators-revoke-button | Per row | ui-destructive-confirmation |
collaborators-already-collaborator | Visible after 409 ALREADY_COLLABORATOR | "Update their role instead?" link |
collaborators-version-conflict-modal | Visible after 409 VERSION_CONFLICT | Two-organizers concurrent |
invitation-accept-page | /collab/accept public-ish page | Invitee acceptance UI |
invitation-expired-message | Visible after 410 INVITATION_EXPIRED | "Ask organizer to resend" copy |
invitation-already-used-message | Visible after 410 INVITATION_ALREADY_USED | "Already accepted" copy |
plan-downgrade-warning | Settings if plan downgraded | "Existing collaborators preserved; new invites blocked" copy |
Agent test plan
Probe list
- read-only-cannot-invite: stub read-only role, POST invite, 403 + invite CTA hidden
- cross-event-404: stub event-A grant, GET event-B, 404
- cross-tenant-token-forgery-404: forge token, accept, 404
- token-expired-410: stub past expires_at, accept, 410 + expired-message visible
- token-already-used-410: accept twice, second 410 + already-used-message visible
- role-escalation-ignored: accept with payload role=organizer when invite said read-only, grant.role === read-only
- revocation-cascades-active-sessions: revoke + dispatch API from active session within 60s, 403 GRANT_REVOKED
- duplicate-invitation-409: invite same email twice, second 409 + already-collaborator-message visible
- audit-log-per-state-change: invite/accept/revoke cycle, 3 audit rows in order
- two-organizers-concurrent-409: stub version mismatch, conflict-modal visible
- plan-downgrade-preserves-existing: existing grants active after downgrade
- plan-downgrade-blocks-new-invites: post-downgrade invite returns 412
- matrix-display-matches-server-enforcement: enumerate cells, assert display == server behavior
- email-prefilled-on-acceptance: invitation email pre-fills the invitee's email field on signin
- audit-log-references-event-and-tenant: every audit row carries event_id + tenant_id