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).
| Method | Path | Min role | Success | Key rejections |
|---|---|---|---|---|
| GET | /portal/team/members | viewer (no @Roles) | 200 MemberDto[] | — |
| POST | /portal/team/invite | admin | 201 {message} | 400 owner-role, 403 feature/role, 409 dup, 402 quota |
| GET | /portal/team/invitations | admin | 200 InvitationDto[] | 403 viewer |
| DELETE | /portal/team/invitations/:invitationId | admin | 200 {message} | 404 not-pending-in-org |
| PATCH | /portal/team/members/:userId/role | owner | 200 {message} | 400 owner-role/self |
| DELETE | /portal/team/members/:userId | admin | 200 {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
clerkOrgIdskip the call. GET /portal/entitlementsquotas.team_members.usedincludes 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 billingSEATSprices).
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)
| Method | Min role | Behavior |
|---|---|---|
| GET | viewer | Returns the org row the pipe already resolved — no extra query |
| PATCH | owner | Name is trimmed server-side before validation — empty/whitespace-only → 400 (FLOW-013 D2b) |
| DELETE | owner | {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).
| Route | Notes |
|---|---|
GET /portal/admin/organizations, GET …/:id | Org list / detail |
POST …/:id/suspend · POST …/:id/activate | Org status transitions |
GET …/:id/members | Clerk members; legacy orgs without clerkOrgId → [] |
GET …/:id/entitlements | Same shape as the customer endpoint, any target org |
POST …/:id/plan-override | Body {featureKey, value: bool|number, reason?, expiresAt?} → 201 |
DELETE …/:id/plan-override/:overrideId | 200 |
POST /portal/admin/billing/organizations/:orgId/plan | Direct 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.