Skip to main content

Club staff roles and data visibility design

Status​

Approved design, 2026-09-10. This is slice 1 of the club analytics and management program described in the discovery doc. It also carries the slice 0 prerequisite. Terms follow the glossary. An implementation plan follows this spec; nothing here authorizes provider writes, collector changes or deployment.

Slice order accepted by BASIC on 2026-09-10:

  1. Stop raw telemetry compaction deleting observations (prerequisite, in this spec).
  2. Club shell, staff roles, delegated invitations, data visibility (this spec).
  3. Analytics dashboard v2 on Recharts.
  4. Membership events from the group audit log.
  5. Group connection v2.
  6. Member management.
  7. Posts.
  8. Instance operations.
  9. Scheduled actions, bulk invitations, notifications.

Goals​

  • Give a club owner VRDex-managed staff roles with editable presets, custom roles, multiple roles per person, and delegated invitations bounded by an explicit assignable set.
  • Give the owner one table that decides who can read each analytics category: public, selected staff roles, or owner only.
  • Put both behind a club shell at the community root so later slices add pages without moving anything.
  • Make one authorization helper the single gate for web pages, the public projection, and later API and MCP responses.

Out of scope​

  • Any VRChat group action, provider permission check, or member management.
  • Analytics content changes. Home shows today's dashboard content unchanged.
  • API or MCP reads for staff. The public projection is the only non-web surface affected.
  • Ownership transfer, deletion workflows, role hierarchy, per-person permission overrides.
  • Personal home layout customization. That ships with slice 2.

Slice 0: permanent retention​

The daily cron community telemetry raw compaction deletes exact population and instance observations older than 90 days once their hourly rollup exists. BASIC decided on permanent retention (discovery Q12). Slice 0:

  • Removes the cron registration and the compaction functions and their tests.
  • Adds one backend test proving that a rollup pass leaves raw observations older than 90 days in place.
  • Updates the three telemetry docs (planning contract, backend, public) to state that observations, rollups and coverage are retained until deletion is requested, and removes the 90-day and 18-month statements. No other age-based deletion exists in code.

Slice 0 ships as its own PR before slice 1.

Permissions​

One validator, clubPermission, replaces communityCapability. Values and their state at the end of slice 1:

PermissionLabel in editorGatesAvailable in slice 1
edit_community_profileEdit community profilelegacy short-link grants only; profile editor is owner-onlydeferred
manage_eventsManage eventsexisting events mutationsyes
manage_event_mediaManage event mediaexisting events mutationsyes
view_event_operationsView event operationsexisting events queriesyes
manage_staffInvite VRDex staffminting invitations and revoking assignments within the assignable setyes
manage_integrationsManage group connectionconnect, disconnect, connection pageyes
approve_join_requestsApprove join requestsslice 5no
invite_group_membersInvite group membersslice 5no
assign_vrchat_rolesAssign permitted VRChat rolesslice 5no
remove_group_membersRemove group membersslice 5no
manage_bansBan and unbanslice 5no
publish_postsPublish postsslice 6no
manage_instancesCreate and close instancesslice 7no
manage_scheduled_actionsManage scheduled actionsslice 8no
export_analyticsExport analyticsslice 2 or laterno

The unused values manage_profile, manage_roster and manage_billing are removed, along with the alias table in the authority helper. Tests that seed them are updated.

Unavailable permissions appear in the role editor with a "Not yet available" marker, can be checked, and are stored, so presets and saved roles do not shift when later slices land. No code path reads them until the owning slice ships.

Reading analytics is never a permission. The visibility table decides reads.

Roles​

New table communityRoles:

  • communityProfileId, key (slug, unique per community), label, description (optional), permissions (array of clubPermission), assignableRoleIds (array of communityRoles ids this role's holders may grant), presetKey (optional, one of admin, moderator, event_staff), state (active or deleted), createdAt, updatedAt.
  • Index by_communityProfileId_state.

Presets are seeded by a seedPresetRoles mutation that the staff page calls when the owner opens it and the community has no roles yet. Nothing is seeded on connect, so communities without staff gain no rows:

PresetPermissionsAssignable roles
Adminevery value in the table abovenone
Moderatorapprove join requests, invite group members, assign VRChat roles, remove group members, ban and unbannone
Event Staffmanage events, manage event media, view event operations, publish posts, create and close instancesnone

Assignable sets default to empty. Delegation is inert until the owner grants it, which matches discovery Q5.

Presets are ordinary roles after seeding: the owner can rename, edit, or delete them. presetKey only lets the editor show "Started from the Admin preset". A role cannot list itself as assignable.

Deleting a role revokes every active assignment of it, removes it from every other role's assignable set, and removes it from every visibility category's role list. A category whose role list becomes empty by deletion falls back to owner only, and the response names the categories that changed so the page can say so. The delete confirmation submits the role's updatedAt from when it opened. If another tab changes the role, deletion fails before revoking assignments or changing visibility.

Only the owner creates, edits, or deletes roles. The role editor keeps the updatedAt value shown when editing began. An edit must submit that value, and saveRole rejects it if the role changed meanwhile. Every successful role edit advances updatedAt beyond its previous value, even when two edits occur in the same millisecond. This prevents a stale tab from restoring permissions or assignable roles removed by another edit.

Assignments​

communityAuthorities changes shape. The free-form roleKey, roleLabel and capabilities fields go away. Each row is one person holding one role:

  • communityProfileId, subjectTokenIdentifier, subject, roleId, state (active or revoked), grantedAt, grantedBySubject, revokedAt (optional), revokedBySubject (optional), updatedAt.
  • Indexes by_communityProfileId_state, by_subjectTokenIdentifier_state_communityProfileId, by_communityProfileId_roleId_state.

Nothing outside tests has written this table, so there is no data migration. The two tests that seed it are rewritten against roles.

A person's effective permissions are the union of their active roles' permissions. The owner never holds rows; ownership stays in profileOwners.

Rules:

  • Nobody changes their own rows. An actor cannot assign a role to themselves or revoke their own assignment, owner included.
  • The owner may assign or revoke any role.
  • A staff member with manage_staff may revoke an assignment only if the assignment's role is in the union of their own roles' assignable sets.
  • Rows are never deleted. Revoking writes revokedAt and revokedBySubject.

Invitations​

New table communityStaffInvitations:

  • communityProfileId, tokenHash (SHA-256 of the single-use token), roleIds (non-empty array), createdBySubject, createdAt, expiresAt (creation plus seven days), state (pending, accepted, revoked, expired), acceptedBySubject (optional), acceptedAt (optional), revokedAt (optional), revokedBySubject (optional).
  • Indexes by_tokenHash, by_communityProfileId_state.

Minting: the owner, or a staff member with manage_staff whose assignable union contains every requested role, calls createStaffInvitation. The mutation returns the raw token once; only the hash is stored. The page shows the link /account/communities/[slug]/invite/[token] with a copy control.

Accepting: a signed-in user with an active browser session and a currently verified email opens the link. acceptStaffInvitation verifies the hash, state and expiry, refuses if the user is the community owner or already holds any of the roles, creates one authority row per role with grantedBySubject set to the inviter, and marks the invitation accepted. The link is dead afterwards.

Revoking: the owner, or the inviter, or a manage_staff holder whose assignable union covers the invitation's roles. Expired invitations are shown as expired by comparing expiresAt at read time; no cron.

At acceptance, recheck every offered role and the inviter's current authority in the same mutation that grants assignments and consumes the token. Resolve the stored inviter identity against current ownership and active staff roles, not against the recipient's identity or a snapshot of the inviter's old permissions. The inviter must still be the owner, or hold manage_staff with an assignable union covering every offered role. The inviter need not have an active browser session; the accepting user must. Deleted roles, revoked staff access, loss of manage_staff, or a reduced assignable set invalidate acceptance. Fail without granting any assignments or consuming the token, using the existing invalid-invitation message. An inviter cannot accept their own invitation to bypass the self-assignment rule.

Data visibility​

New table communityDataVisibility, one document per community. Reads compute the defaults below when no document exists; the document is written on the first edit:

  • communityProfileId, categories, updatedAt. Index by_communityProfileId.
  • categories is an object keyed by category, each value { audience, staffRoleIds } where audience is public, staff or owner, and staffRoleIds is either null meaning all staff or a non-empty array of role ids. It is only read when audience is staff.

Categories and defaults:

Category keyLabelPublic allowedDefault
current_populationCurrent populationyesstaff, all
population_historyPopulation historyyesstaff, all
group_sizeGroup sizeyesstaff, all
instance_historyInstance historyyesstaff, all
membership_movementMembership movementyesstaff, all
individual_membership_historyIndividual membership historynoowner
event_recapsEvent recapsyesstaff, all

Nothing is public by default. The validator rejects public for individual_membership_history and rejects an empty role array.

Only the owner edits visibility. setCategoryVisibility takes one category, its displayed value, and its new value. It rejects a stale edit and writes an action log entry for a successful change.

Migration from publicMetrics​

communityVrchatIntegrations.publicMetrics holds five booleans today. Mapping: currentPopulation to current_population, populationHistory to population_history, groupMemberCount to group_size, groupMemberGrowth to membership_movement, eventRecaps to event_recaps. A true becomes audience: "public".

Two PRs:

  1. Add the table, the migration (defined with the existing @convex-dev/migrations runner), and the read helper. The public projection reads the visibility document when it exists and falls back to publicMetrics otherwise. setPublicMetric is removed and the dashboard's toggles are replaced by the visibility page.
  2. After BASIC runs the migration against production, drop publicMetrics from the schema and the fallback from the projection. Merging never changes live data here, so the plan states the run step explicitly.

Authorization helper​

New module convex/_clubAccess.ts replaces _communityAuthority.ts:

  • resolveClubActor(ctx, communityProfileId) returns one of { kind: "owner" }, { kind: "staff", subject, roleIds, permissions } or { kind: "none" }. Owner detection uses the existing active-browser-session and userOwnsProfile path. Staff detection uses toAuthSubject and the active authority rows joined to active roles, as today.
  • requireClubPermission(actor, permission) throws unless the actor is the owner or a staff member holding it.
  • canReadCategory(actor, visibility, category): public passes anyone including anonymous; staff passes the owner and any staff member whose role ids intersect the list, or any staff member when the list is null; owner passes only the owner.
  • assignableRoleIdsFor(actor, roles) returns the union for a staff actor and every active role for the owner.

Existing callers in events.ts, _shortLinks.ts and communityTelemetry.ts move to the new helper without behavior change beyond the permission rename. The private dashboard query changes its gate from manage_integrations to "actor is owner or staff", then filters each section through canReadCategory. The public projection getPublicCommunityTelemetry uses canReadCategory with an anonymous actor and is the only place public reads are shaped.

Mapping today's dashboard sections to categories: current population and active instances to current_population; population chart to population_history; member count chart to group_size; recent instances and instance history to instance_history; event associations and recaps to event_recaps. Existing net group-member growth, including summary and rollup fields, is gated by membership_movement immediately in slice 1, independently of group_size. Apply that gate wherever growth occurs, including nested public recap and population-history payloads. Slice 3 adds aggregate joins/departures to this category; individual_membership_history has no data until then. Net count change is not a count of joins or departures.

Action log​

New table communityActionLog: communityProfileId, actorSubject, action, targetSubject (optional), roleId (optional), details (object), createdAt. Index by_communityProfileId_createdAt. Slice 1 writes role created, updated, deleted; assignment granted, revoked; invitation created, accepted, revoked; visibility changed. Later slices reuse it for provider actions with outcome states. The staff page shows the last 50 entries to the owner.

Routes and pages​

Under apps/web/src/app/account/communities/[slug]/:

  • layout.tsx: neutral shared layout that permits the invitation route without requiring existing staff membership or loading private workspace data. Put the staff-only shell and its access gate in a (workspace) route-group layout, with Home, staff, visibility, connection and telemetry beneath that group; URL paths stay unchanged. The Workspace shell has a left sidebar with the community name, navigation, and a footer showing connection state. Navigation entries are filtered by the resolved actor. Narrow viewports collapse the sidebar into a horizontal scrolling row, as in the approved prototype.
  • page.tsx: Home. Renders the existing dashboard content, minus the connection form and the public toggles.
  • staff/page.tsx: Staff and roles. Visible to the owner and to manage_staff holders. Sections: club owner row, active staff with roles and revoke controls, pending invitations, invite form, roles list with permission editor, and the action log. Role editing controls render only for the owner; delegates see roles read-only and can invite only within their assignable set.
  • visibility/page.tsx: Data visibility. Owner only. One row per category with an audience select and, when the audience is staff, a role picker with an "All staff" option, reusing the per-row select pattern from the account privacy panel.
  • connection/page.tsx: Group connection. Visible to the owner and manage_integrations holders. Holds today's connect form and disconnect action.
  • invite/[token]/page.tsx: outside the (workspace) group. Signed-out visitors and signed-in non-staff can reach it. A token-scoped query returns only the minimal community identity and offered role labels for a valid invitation; it returns no staff roster, analytics, connection details or action log. Invalid tokens disclose no invitation details. Signed-out visitors sign in and return to this route; acceptance requires an active browser session. Successful acceptance enters the workspace. Audit ancestor account layouts too so their gates preserve this sign-in return flow. Workspace queries retain their own authorization checks regardless of layout access.
  • telemetry/page.tsx: permanent redirect to the community root.

Sidebar order for slice 1: Home, Staff and roles, Data visibility, Group connection. Slice 2 inserts Analytics and Instances after Home.

All pages use the existing page shell, form, select and button components. No new UI dependency.

Public copy​

Every string below is new public-facing prose and needs BASIC's approval before merge, per the repo copy rule. The implementation PR lists any change to this table.

WhereText
SidebarHome, Staff and roles, Data visibility, Group connection
Staff page, empty staffNo staff yet.
Staff page, invite formInvite club staff; Roles; Create invite link; Copy link; This link works once and expires in 7 days.
Staff page, roles noteVRDex roles control this dashboard. VRChat group roles are managed separately.
Role editorNot yet available; Roles this role can assign; Started from the Admin preset (and Moderator, Event Staff variants)
Role delete confirmDeleting this role removes it from everyone who holds it. Categories visible only to this role become owner only.
Visibility pageWho can see each category; Public settings apply to community pages and public APIs.; Public; Selected staff; Owner only; All staff; Not applicable
Invite pageYou have been invited to join the staff of this club.; Accept invitation; Sign in to accept; This invitation is no longer valid.
Access noticesYou do not have access to this page.

Testing​

Backend, with the existing convex-test pattern in tests/backend/:

  • Permission union across multiple roles; owner passes every permission; none actor fails.
  • Delegation: a delegate can invite only within the assignable union, cannot invite Admin when not assignable, cannot revoke outside it, cannot touch their own rows; owner cannot be invited.
  • Invitation lifecycle: mint, accept, second acceptance fails, expired fails, revoked fails, deleted role fails. Acceptance also fails atomically after inviter access revocation, loss of manage_staff, reduction of the assignable set, or attempted self-acceptance. A still-authorized inviter need not be signed in when the recipient accepts.
  • Role deletion cascades: assignments revoked, assignable sets cleaned, visibility fallback to owner with the changed categories reported.
  • Visibility: validator rejects public individual history and empty role arrays; canReadCategory for each audience and actor kind; dashboard sections filtered; public projection matches the visibility document and, before migration, the legacy booleans.
  • Migration: five booleans map to the expected categories. Existing net growth retains its public visibility before and after migration. When group_size is public but membership_movement is restricted, omit explicit growth fields from private responses for unauthorized staff and from all public projections, including nested rollups/recaps; test the inverse combination too. These gates control returned fields, not inferences from an intentionally public count history.
  • Slice 0: a rollup pass leaves raw observations older than 90 days in place.

Web: one Playwright fixture page under apps/web/src/app/playwright/club-staff/ exercising the staff page and visibility page with seeded roles, following the community telemetry fixture. The fixture covers visual states. A route-level end-to-end test exercises the actual layout hierarchy: signed-out invitation access, sign-in return, acceptance by a non-staff user, and workspace entry afterward. Before acceptance, direct private routes and queries remain denied and the invitation response contains no private workspace data.

Open items for later slices​

  • Slice 2 defines home layout customization and the Analytics and Instances pages.
  • Slice 3 extends membership_movement with joins/departures and populates individual_membership_history; the identifiable table must use canReadCategory. Existing net growth is already gated in slice 1.
  • API and MCP responses for staff adopt resolveClubActor when a slice exposes them; the helper is designed so no second gate is needed.
  • Ownership transfer and deletion workflows remain deferred (discovery Q13).