Team Collaboration

Manage operators, roles, and permissions in the MVS Telecom Operator Console.

The MVS Telecom Operator Console runs as a single MSP operator Organization. Your team of operators (owner, admins, and techs) all work inside that one org, and every read and write is scoped to it. Collaboration is built on Better Auth's multi-tenancy primitives — Member, Invitation, and OrganizationRole — layered with the platform's RBAC permission model.

The Operator Organization

Rather than many isolated team accounts, MVS operates as one Organization. Customers, ApiKeys, RoutingPolicies, MrrSnapshots, and OpsDigests carry organizationId directly; everything else is org-scoped transitively (most often through the customer that a job, number, or device belongs to). The active org lives on the Better Auth Session (activeOrganizationId), and downstream code resolves it identically whether the caller is an interactive operator or a programmatic API key.

Membership and identity are modeled by Better Auth:

  • User / Session / Account — identity and login.
  • Organization — the MVS operator org.
  • Member — a user's membership in the org, with a role.
  • Invitation — a pending invite to join the org.
  • OrganizationRole — the role assigned to a member.

There is no separate "team workspace" abstraction. The Console shell (navy rail + frosted topbar + ⌘K palette) is the workspace, and nav-items.ts is the single source for the sidebar and palette.

Inviting Operators

Send Invitations

Invitations are issued through Better Auth's organization API (admins/owners only). A member-level invite gives the new operator the tech-tier permissions described below.

import { auth } from '@kit/auth';

await auth.api.createInvitation({
  body: {
    email: 'operator@mvs.example',
    role: 'member',
  },
  headers, // request headers carrying the session
});

Invitation Flow

  1. An owner or admin sends an invitation by email.
  2. The recipient receives an email with an invitation link.
  3. The recipient accepts the invitation (handled by the (invitation)/ route group).
  4. A Member row is created and the operator gains access to the org.

Roles and Permissions

Authorization has two layers. Roles are a numeric Better Auth hierarchy (higher = more authority); permissions are the finer-grained provisioning feature-permissions checked by the action middleware. See Security & RBAC for the full model.

Default Roles

Roles are defined in packages/rbac/src/core/defaults.ts:

Owner (level 100)

  • Full org control
  • Manage billing and the operator's own SaaS subscription
  • Invite and remove members
  • All provisioning permissions

Admin (level 50)

  • Manage members and billing
  • All provisioning permissions, including approve_spend and compensate

Member / Tech (level 10)

  • read, create, and execute provisioning
  • Deliberately cannot update/delete, approve_spend, or compensate

Unknown roles resolve to level 0 (no permissions).

The provisioning permission model

RBAC is customized in packages/rbac/src/rbac.config.ts, which adds a single provisioning resource with these capabilities:

PermissionMeaning in the platform
readView customers, jobs, tasks, inventory, billing, config/status
createCreate customers/intakes, start onboarding, trigger sync
updateEdit provisioning settings
deleteDestructive teardown; also gates API-key issuance/revocation
executeRun/retry jobs, assign/release numbers & devices, provision extensions
approve_spendThe spend gate — approve/deny carrier purchases
compensateResolve compensation (rollback/cleanup) tasks

This separates the operator who runs provisioning from the operator who authorizes money and who resolves failed rollbacks.

Checking Permissions

Portal mutations gate on these permissions via the action middleware (@kit/action-middleware), not ad-hoc helpers. For example, billing and provisioning actions are wrapped with withFeaturePermission:

import { withFeaturePermission } from '@kit/action-middleware';

// gate a write on provisioning:execute
const action = withFeaturePermission({ provisioning: ['execute'] })(/* ... */);

// gate a read
const readAction = withFeaturePermission({ provisioning: ['read'] })(/* ... */);

// the spend gate
const buyAction = withFeaturePermission({ provisioning: ['approve_spend'] })(/* ... */);

The public REST API (/api/v1) enforces the equivalent via per-key scopes on the custom ApiKey model (sha256 hash + prefix, org-scoped).

Member Management

Listing, role changes, and removal go through Better Auth's organization API, scoped to the active org.

import { auth } from '@kit/auth';

// list members of the active org
const members = await auth.api.listMembers({ headers });

// update a member's role
await auth.api.updateMemberRole({
  body: { memberId, role: 'admin' },
  headers,
});

// remove a member
await auth.api.removeMember({ body: { memberIdOrEmail }, headers });

Organization Settings

Org and account settings live under the /settings screen. The operator's own SaaS plan is the Better Auth/Stripe Subscription model — this is how MVS pays for the Console, and is not how telecom service is billed to customers (that lives in the RatePlan/Invoice billing domain surfaced at /billing).

Audit Trail

The platform has no generic team "activity log" table. Operator and system actions are recorded in the append-only AuditEvent model (FK-less, with costCents, payloadHash, and metadata.organizationId stamped on global ops actions) and surfaced read-only at the /audit screen.

import { writeAuditEvent } from '@kit/provisioning-workflows';

await writeAuditEvent({
  action: 'number.assign',
  actorType: 'operator',
  customerId,
  costCents,
  metadata: { organizationId },
});

/audit reads these events org-scoped two ways (the customer belongs to the org, or customerId is null and metadata.organizationId matches), filterable by action substring, actor type, and a day window (default 30, max 365, cap 200 rows).

Notifications

There is no notification table in MVS. The topbar bell is derived live from the operations attention feed — loadOperations() — so notifications always reflect real, current state:

  • Unread = attentionJobs + open compensationTasks (compensation HumanTasks).
  • The bell shows a dot only when unread > 0.
  • "View all" links to the /operations screen, which renders the unified "needs attention" feed (exception jobs in failed/compensating/compensation_blocked/awaiting_human, plus open compensation tasks).

This means collaboration awareness comes from the same exceptions-and-health console operators already act on — not a parallel notifications store that can drift.

Best Practices

  1. Single operator org — keep the team in one Organization; lean on org-scoping for isolation.
  2. Principle of least privilege — techs (member) run jobs and read everything; keep approve_spend and compensate with admins/owners.
  3. Use the middleware — gate every mutation with withFeaturePermission; never hand-roll permission checks.
  4. Audit trail — record money-spending and destructive actions via AuditEvent; review them on /audit.
  5. Easy onboarding — invite operators by email through the Better Auth invitation flow.
  6. Notifications follow reality — the bell is the operations attention feed, so triage from /operations rather than expecting a separate inbox.