Summary
upsertMembership in `server/src/routes/workos-webhooks.ts:93-145` mirrors WorkOS `organization_membership.created` events into our local `organization_memberships` table without checking seat availability. Any path where WorkOS adds a user that bypasses our invite endpoints — SSO domain auto-join, dashboard manual add, API direct add — silently squeezes past the cap.
Why this is the real squeeze surface
We have a well-architected seat-cap mechanism: `checkSeatAvailability` in `db/organization-db.ts:430-487` is transactionally safe, counts active members + pending invites, and is correctly called by manual invite endpoints (`routes/organizations.ts:2519, :3270`). It's just not called at the webhook ingest path.
Surfaced while investigating escalation #302 (vastlint.org). The design principle the gap violates: data layer should mirror reality; policy should fire at action gates. PR #3966 fixes the data-layer half (don't hide brand ownership for personal orgs); this issue covers the policy half.
Behavior to add
In `upsertMembership`, before `upsertOrganizationMembership`:
```ts
const tier = resolveMembershipTier(orgRow);
const limits = getSeatLimits(tier);
const seatTypeForCap = consumed?.seat_type ?? 'community_only';
const availability = await checkSeatAvailability(membership.organization_id, seatTypeForCap);
if (!availability.allowed) {
// Choose policy:
// (a) Refuse-and-notify — don't mirror, log + Slack the org admins
// (b) Auto-demote to community_only if cap allows
// (c) Mirror as inactive/pending and require admin action
// (a) is safest — keeps WorkOS as truth, our cache as policy-enforced,
// divergence is audit-visible.
}
```
Recommended policy: refuse-and-notify
- Don't mirror the membership locally
- Log at `warn` level with `{orgId, userId, email, tier, currentUsage, limit}`
- Notify org admins via Slack: "WorkOS added X to your org but you're at seat cap. Upgrade or remove from WorkOS."
- Don't try to remove from WorkOS automatically (that's a separate decision)
Auto-demote (b) is tempting but silently changes seat semantics; refuse-and-notify keeps the human in the loop.
Test plan
- Unit: with `tier=individual_professional` (1 contributor, 0 community) and 1 existing contributor, webhook with new `organization_membership.created` → not mirrored, warn logged, Slack notification queued
- Integration: post a webhook event for an over-cap org, assert `organization_memberships` count unchanged and Slack notification record written
Related
Summary
upsertMembershipin `server/src/routes/workos-webhooks.ts:93-145` mirrors WorkOS `organization_membership.created` events into our local `organization_memberships` table without checking seat availability. Any path where WorkOS adds a user that bypasses our invite endpoints — SSO domain auto-join, dashboard manual add, API direct add — silently squeezes past the cap.Why this is the real squeeze surface
We have a well-architected seat-cap mechanism: `checkSeatAvailability` in `db/organization-db.ts:430-487` is transactionally safe, counts active members + pending invites, and is correctly called by manual invite endpoints (`routes/organizations.ts:2519, :3270`). It's just not called at the webhook ingest path.
Surfaced while investigating escalation #302 (vastlint.org). The design principle the gap violates: data layer should mirror reality; policy should fire at action gates. PR #3966 fixes the data-layer half (don't hide brand ownership for personal orgs); this issue covers the policy half.
Behavior to add
In `upsertMembership`, before `upsertOrganizationMembership`:
```ts
const tier = resolveMembershipTier(orgRow);
const limits = getSeatLimits(tier);
const seatTypeForCap = consumed?.seat_type ?? 'community_only';
const availability = await checkSeatAvailability(membership.organization_id, seatTypeForCap);
if (!availability.allowed) {
// Choose policy:
// (a) Refuse-and-notify — don't mirror, log + Slack the org admins
// (b) Auto-demote to community_only if cap allows
// (c) Mirror as inactive/pending and require admin action
// (a) is safest — keeps WorkOS as truth, our cache as policy-enforced,
// divergence is audit-visible.
}
```
Recommended policy: refuse-and-notify
Auto-demote (b) is tempting but silently changes seat semantics; refuse-and-notify keeps the human in the loop.
Test plan
Related