Skip to main content

Team & organization management

Scope: the customer-facing org/team endpoints (/portal/team/*, /portal/org, /portal/app-config/*) and the ops-passthrough admin surface. Roles and the guard stack itself live in Authentication architecture; this page is what those guards protect.

Source-checked against optolink-backend @ 29f8589519ac85445ed0598c1b9cfc125a9dd6f2 and optolink-portal @ 7d31d457ad4e02e6d9b6bd3cb84a47439f2903ea, 2026-10-07 (B-012 re-verification; src/team/team.controller.ts, src/portal/portal-org.controller.ts, src/portal/portal-app-config.controller.ts, src/portal/portal-admin-organizations.controller.ts, src/billing/controllers/billing-admin.controller.ts, src/entitlements/). HTTP-status claims are code-read only — no live curl pass was run.

Team endpoints​

src/team/team.controller.ts — standard stack ClerkAuthGuard → RolesGuard → RateLimitGuard (RateLimitPresets.portal), org via @OrgFromUser(OrgResolutionPipe).

MethodPathMin roleSuccessKey rejections
GET/portal/team/membersviewer (no @Roles)200 MemberDto[]—
POST/portal/team/inviteadmin201 {message}400 owner-role, 403 feature/role, 409 dup, 402 quota
GET/portal/team/invitationsadmin200 InvitationDto[]403 viewer
DELETE/portal/team/invitations/:invitationIdadmin200 {message}404 not-pending-in-org
PATCH/portal/team/members/:userId/roleowner200 {message}400 owner-role/self
DELETE/portal/team/members/:userIdadmin200 {message}400 self/owner-guard

Invite gate order (deliberate): owner-role 400 → canUseFeature('team_members') 403 → pending-duplicate 409 → checkQuota 402 → Clerk createInvitation (publicMetadata.optoRole carries the app role). Feature gate before quota, same rule as every other gated creation (Entitlements enforcement).

Invitations are Clerk objects. Revoke is by Clerk invitation ID (orginv_… from the list endpoint), not email; the 404 scope check runs against the org's own pending list before Clerk is called, so foreign/accepted ids 404 locally.

Seat quota (FLOW-011 D4/FB-2)​

checkQuota('team_members') counts OrgMember rows plus Clerk pending invitations — an invite reserves its seat (402 fires before Clerk's own dev-instance membership cap), and revoking an invitation frees it immediately.

  • The Clerk pending count is fail-open: a Clerk outage downgrades to members-only count + warn log; orgs without clerkOrgId skip the call.
  • GET /portal/entitlements quotas.team_members.used includes pending invitations (one extra Clerk call per entitlements read, ~80–350 ms recorded).
  • Seat ladder (onExhausted: 'block' on every capped plan): starter 1 · solo 2 · growth 5 · scale 15 · enterprise unlimited/overage (src/entitlements/plans.config.ts — the ladder intentionally mirrors the billing SEATS prices).

Owner-removal guard (FLOW-011 D1/FB-1)​

DELETE /portal/team/members/:userId returns 400 "The organization owner cannot be removed; transfer ownership first" for any member whose Clerk membership role is owner — even when an admin calls it. Ownership transfer doesn't exist yet; this is a hard reject (an org without its owner loses the only role that can manage roles).

Role stamping on organizationMembership.created (FLOW-013 R1)​

resolveAndStampRole runs on every organizationMembership.created and, when no pending invitation exists (directly-created/mirrored member), defaults the role to owner — overwriting a role you stamped into Clerk membership metadata seconds earlier.

Source-checked against optolink-backend @ 29f8589519ac85445ed0598c1b9cfc125a9dd6f2 and optolink-portal @ 7d31d457ad4e02e6d9b6bd3cb84a47439f2903ea, 2026-10-07 (src/clerk/clerk-sync.service.ts, resolveAndStampRole — no-invitation path → optoRole = 'owner').

Testing implication (mirrored test orgs): stamp test roles after the relay settles — create the membership, wait a few seconds, confirm the relay's owner stamp via the membership metadata, then PATCH …/metadata to the test role and mint the token. Sessions minted via the login page default to the personal workspace — sign in fresh once the org exists (first org auto-scopes).

Org lifecycle (/portal/org)​

MethodMin roleBehavior
GETviewerReturns the org row the pipe already resolved — no extra query
PATCHownerName is trimmed server-side before validation — empty/whitespace-only → 400 (FLOW-013 D2b)
DELETEowner{deleted:true}; 403 for the ops org; 409 while the subscription is ACTIVE/PAST_DUE — cancel first

DELETE runs one transaction: delete LinkMatch + ClickEvent rows, disable the audit_log_immutable trigger, delete the Organization, re-enable the trigger. AuditLog.organizationId is optional with default onDelete: SetNull, so audit rows survive with NULL org. The Clerk org is deleted after the local commit, best-effort (a Clerk failure is logged, not fatal — members' Clerk memberships die with the org either way).

App-config upsert (/portal/app-config/:platform)​

PATCH is developer-gated; the platform segment is uppercased server-side. The DTO is partial by design (FLOW-013 D2a): bundleId + storeUrl are required only when the call creates the row (400 "bundleId and storeUrl are required when creating an app config" otherwise); on an existing config, unspecified fields — including sha256Fingerprints — are preserved. The onboarding wizard always sends a full payload; details in Onboarding architecture.

Ops passthrough (/portal/admin/*)​

ClerkAuthGuard → OpsRoleGuard → RateLimitGuard — authorization is possession of a session in the seeded ops org (CLERK_OPS_ORG_ID), fail-closed when unset (Authentication architecture, ops-org bypass).

RouteNotes
GET /portal/admin/organizations, GET …/:idOrg list / detail
POST …/:id/suspend · POST …/:id/activateOrg status transitions
GET …/:id/membersClerk members; legacy orgs without clerkOrgId → []
GET …/:id/entitlementsSame shape as the customer endpoint, any target org
POST …/:id/plan-overrideBody {featureKey, value: bool|number, reason?, expiresAt?} → 201
DELETE …/:id/plan-override/:overrideId200
POST /portal/admin/billing/organizations/:orgId/planDirect plan set, no checkout/gateway: upserts Subscription + mirrors Organization.plan in one transaction, gateway sentinel STRIPE (never renewed), clears scheduledPlan/cancelAtPeriodEnd

Removed with the PAYG engine (expect 404): GET /portal/admin/billing/invoices, GET /portal/admin/billing/accounts/:orgId — the new invoices are gateway PAID records with no waive/retry semantics.

Runtime verification (recorded)​

Recorded live 2026-09-12 against the QA Team Org (FLOW-011); tokens minted per Real-token HTTP. Re-verify live on next use.

A() { curl -s -H "Authorization: Bearer $ADMIN" "$@"; } # admin token
A $API/portal/team/invitations # 200 []
A -X POST $API/portal/team/invite -H 'Content-Type: application/json' \
-d '{"emailAddress":"p1+clerk_test@optolink.io","role":"developer"}' # 201
A $API/portal/team/invitations # 200 [{id: orginv_…}]
A -X POST $API/portal/team/invite -H 'Content-Type: application/json' \
-d '{"emailAddress":"p2+clerk_test@optolink.io","role":"viewer"}' # 402 (seat reserved)
A -X DELETE $API/portal/team/invitations/orginv_… # 200 revoked
A -X DELETE $API/portal/team/members/<OWNER_USER_ID> # 400 owner-guard
A -X DELETE $API/portal/team/invitations/orginv_DOESNOTEXIST # 404 (scope check, no Clerk call)

Gotcha: a nest start --watch pane can silently run a stale build if it was started as plain nest start — verify a new route exists (401 on the new path) before probing, or you'll chase phantom 404s.