Skip to content

πŸ—οΈ PUT-1758: cap workspaces per user and seats per workspace - #3725

Open
jfcastro92 wants to merge 1 commit into
juancastro/put-1750-36-notify-the-master-account-when-a-member-runs-outfrom
juancastro/put-1758-37-cap-workspaces-per-user-and-seats-per-workspace
Open

πŸ—οΈ PUT-1758: cap workspaces per user and seats per workspace#3725
jfcastro92 wants to merge 1 commit into
juancastro/put-1750-36-notify-the-master-account-when-a-member-runs-outfrom
juancastro/put-1758-37-cap-workspaces-per-user-and-seats-per-workspace

Conversation

@jfcastro92

Copy link
Copy Markdown
Collaborator

Thirteenth in the stack: #3704 β†’ #3705 β†’ #3708 β†’ #3709 β†’ #3710 β†’ #3712 β†’ #3713 β†’ #3714 β†’ #3720 β†’ #3721 β†’ #3722 β†’ #3724 β†’ this. Review bottom-up.

Bounds on how much a workspace can create. This is what has to exist before teams_enabled can go on, even internally.

Why

A seat is a real Puter account. It takes a name from the global username pool, gets a home directory, and is stamped with a storage allowance from the plan. Nothing charges for one β€” the payment integration is out of this repo and does not exist yet β€” so until it lands the only thing bounding creation is the request rate limit, and that bounds the rate, not the total: 500 mutations/day is 500 accounts/day, every day.

Two limits, both from config so a deployment can move them without shipping code:

"max_workspaces_per_user": 1,
"max_seats_per_workspace": 50,

Ordering is the substance of both checks

The workspace cap is checked before the handle. Otherwise a user at their limit who also picks a taken handle is told the handle was the problem, and goes off to pick another one. There is a test that a capped user asking for a syntactically invalid handle still hears workspace_limit_reached.

The seat cap is checked before any account state is created. provisionAccount creates a user row, then a filesystem tree, then the membership. Checking late would mean a refused provision had already consumed a global username. The test asserts the refused name is still free afterwards.

⚠ What these caps do not do

Soft-deleted workspaces do not count toward the owner's cap, so deleting frees the slot β€” which is the behaviour you want, and also means create β†’ provision 50 β†’ delete β†’ repeat still works. Each cycle is deliberate and audited, and the daily rate limit bounds the total, but the caps raise the cost rather than closing the hole. Billing closes it.

Worth stating because the deleted accounts are not recovered: softDelete nulls the workspace handle, but the accounts it created still exist, still hold their files, and keep their usernames permanently. Deleting a workspace is not a way to stop paying for the accounts in it β€” the same point PUT-1740's confirmation dialog has to make.

Lowering a limit never disables anyone

A workspace already above a reduced seat limit keeps every account it has and is simply refused new ones. There is a test that drops the cap below the current seat count and asserts every existing seat is still unsuspended β€” being over a limit is not a reason to suspend people.

Defaults, and where they actually come from

The code carries ?? 1 and ?? 50 fallbacks, but config.default.json is the merge base and now sets both keys, so the code fallback never runs in a normal deployment. That is fine, but it means a mutation to the fallback changes nothing observable β€” I found this when falsifying it and the suite stayed green. There are now two tests that reach it: one deleting the key at runtime, one setting a nonsensical 0 (which must not lock everyone out).

Also

checkOwnerInvariant was running its own inline COUNT(*) in the service while TeamStore.countPayers existed and had never been called. Dead store method plus SQL in a service β€” it now calls the store.


Verification

$ npx vitest run --config src/backend/vitest.config.ts \
      src/backend/{services,stores,controllers}/team/
      Tests  132 passed (132)

$ PUTER_TEST_DB_ENGINE=postgres … src/backend/{services,stores}/team/
      Tests  113 passed (113)

$ npm run test:backend
 Test Files  272 passed | 24 skipped (296)
      Tests  7159 passed | 26 skipped (7185)

$ npm run typecheck
Type check passed β€” no new errors (32 known, baselined).

The five existing team suites now pass max_workspaces_per_user: 100 explicitly. They create several workspaces per master and started failing the moment the cap landed β€” which is the cap working, but they are not testing it, so they say so in a comment rather than being quietly reshaped around it.

Per-site falsification
baseline                     0 failed | 11 passed
workspace cap check          3 failed |  8 passed
seat cap check               4 failed |  7 passed
cap before handle check      1 failed | 10 passed
cap before account creation  4 failed |  7 passed
countOwned excludes deleted  1 failed | 10 passed
countSeats excludes master   5 failed |  6 passed
workspace cap default        1 failed | 10 passed
restored                     0 failed | 11 passed

workspace cap default initially reported 0 failed β€” not because the default was untested but because it is unreachable behind config.default.json. The two fallback tests above were written in response.

countSeats excludes master is worth its own note: the master is org_owned = 0, so counting all memberships instead of seats would spend one of the 50 on the person paying for them.

Not here

Per-workspace seat limits β€” "Acme gets 200, everyone else 50" β€” are genuinely per-workspace state and really mean "the master bought 200 seats", which is billing. When that lands it is an additive group.seat_limit column, nullable, NULL falling back to the config default, with no rework of this.


Closes PUT-1758.

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
πŸ”΅ Lines 93.82%
⬆️ +0.02%
28692 / 30580
πŸ”΅ Statements 91.84%
⬆️ +0.01%
31142 / 33906
πŸ”΅ Functions 90.41%
⬆️ +0.05%
5236 / 5791
πŸ”΅ Branches 80.75%
🟰 ±0%
20650 / 25570
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/backend/types.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/core/http/HttpError.ts 100%
🟰 ±0%
88.88%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/services/team/TeamService.ts 84.25%
⬆️ +2.02%
72.8%
⬆️ +2.33%
95.12%
⬆️ +1.19%
85.98%
⬆️ +2.19%
90-91, 111-112, 126, 194-196, 199-201, 210-222, 251-254, 276-287, 337-340, 348, 354, 391-394, 446-448, 502, 586-589, 635-637, 657, 672
Generated in workflow #1593 for commit f870975 by the Vitest Coverage Report Action

@jfcastro92
jfcastro92 force-pushed the juancastro/put-1758-37-cap-workspaces-per-user-and-seats-per-workspace branch from 70fc391 to 7edf3f1 Compare September 2, 2026 21:38
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1758-37-cap-workspaces-per-user-and-seats-per-workspace branch from 7edf3f1 to 0b1e545 Compare September 3, 2026 13:47
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1758-37-cap-workspaces-per-user-and-seats-per-workspace branch from 0b1e545 to 9f7867c Compare September 3, 2026 14:30
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1758-37-cap-workspaces-per-user-and-seats-per-workspace branch from 9f7867c to 80ce2ba Compare September 3, 2026 15:42
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1758-37-cap-workspaces-per-user-and-seats-per-workspace branch from 80ce2ba to a8d6cd7 Compare September 3, 2026 18:56
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1758-37-cap-workspaces-per-user-and-seats-per-workspace branch from a8d6cd7 to 1795adc Compare September 3, 2026 19:20
@jfcastro92

Copy link
Copy Markdown
Collaborator Author

Local validation

Engine: mysql 8 Β· caps read from the real config.json:

"teams_enabled": true,
"max_workspaces_per_user": 1,
"max_seats_per_workspace": 3

Workspace cap β€” the second create by the same owner:

POST /teams {"name":"Acme Two","handle":"acme"}
  -> 409 {"error":"You may own 1 workspace","code":"workspace_limit_reached","limit":1}

Note the cap is checked before handle validation, so an owner at their limit
gets the limit error rather than a handle error. That ordering seems right β€”
the limit is the binding constraint and the handle is moot.

Seat cap β€” the fourth seat in a workspace limited to 3:

seat1 -> 200
seat2 -> 200
seat3 -> 200
seat4 -> 409 {"error":"This workspace is limited to 3 seats",
              "code":"seat_limit_reached","limit":3}

The owner is not counted against the seat cap β€” 3 seats plus the owner is 4
members, which matches the config comment describing the cap as bounding
provisioned seats.

Both limits are surfaced with a machine-readable code and the limit value, so
a client can render the number without parsing prose. Values came from the real
config file rather than a fixture, which is the part the unit tests cannot show.

@jfcastro92
jfcastro92 force-pushed the juancastro/put-1758-37-cap-workspaces-per-user-and-seats-per-workspace branch from 1795adc to a76fec1 Compare September 3, 2026 19:45
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1758-37-cap-workspaces-per-user-and-seats-per-workspace branch from a76fec1 to 9652fa8 Compare September 3, 2026 20:02
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1758-37-cap-workspaces-per-user-and-seats-per-workspace branch from 9652fa8 to 95b9d04 Compare September 3, 2026 20:32
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1758-37-cap-workspaces-per-user-and-seats-per-workspace branch from 95b9d04 to b21ca6c Compare September 3, 2026 23:55
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1758-37-cap-workspaces-per-user-and-seats-per-workspace branch from b21ca6c to 94c7dd7 Compare September 4, 2026 01:02
@jfcastro92
jfcastro92 requested a review from Salazareo September 4, 2026 01:34
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1758-37-cap-workspaces-per-user-and-seats-per-workspace branch from 94c7dd7 to 5403557 Compare September 4, 2026 03:23
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1758-37-cap-workspaces-per-user-and-seats-per-workspace branch from 5403557 to 012ee1d Compare September 4, 2026 03:42
@jfcastro92

Copy link
Copy Markdown
Collaborator Author

Review fix: the caps did not hold under concurrency

You measured 5 workspaces under a cap of 1. Confirmed β€” both caps were count-then-insert with nothing serializing them, so concurrent requests each read a count below the limit and all inserted.

Reproduced as a test first, with the lock removed:

Γ— holds the workspace cap against concurrent creates
  AssertionError: expected […] to have a length of 1 but got 5
Γ— holds the seat cap against concurrent provisions
  AssertionError: expected […] to have a length of 3 but got 6

Five and six β€” your number exactly.

The fix

#withCapLock, wrapping both count-then-insert paths: owner:<id> for workspace creation, team:<uid> for provisioning. Same claim/TTL/retry shape as ACLService.#withNodeLock, deliberately β€” including that it degrades to unserialized when Redis is unreachable rather than failing the request. A cap is a bound, not a correctness invariant, and refusing to create a workspace because Redis blinked is worse than the race it prevents.

Not a unique constraint: the limits are configurable (max_workspaces_per_user, max_seats_per_workspace), and "at most N rows" is not expressible as an index for N > 1.

After:

Tests  13 passed (13)

Both new tests fail with the lock removed and nothing else does.

⚠ Worth your eye: this serializes per owner and per workspace within one Redis. Two regions with separate Redis instances would still race. That matches the existing posture for setUserUser/setUserGroup, so it is consistent rather than newly weak β€” but it is a bound, not a guarantee, and I would rather say so than imply otherwise.

Full suite 7883 passed | 26 skipped, typecheck clean, all 9 commits in the stack typecheck individually.

@jfcastro92

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (6d7be8bd6), which landed the KV share-grant series (#3690–#3693).

One collision worth naming: those commits took migration 0080 / mysql_mig_35 / postgres_mig_24 β€” the exact three numbers this stack's temp_password_expires_at migration held. Renumbered to 0081 / mysql_mig_36 / postgres_mig_25, registry entry [76, ['0081_temp-password-expiry.sql']], and CURRENT_SCHEMA_VERSION is now 77. Main's three files are byte-identical to what it merged; only ours moved.

Verified per commit rather than at the tip β€” each of the 9 typechecks on its own, so no intermediate commit is broken. Full suite 7883 passed | 26 skipped.

@Salazareo Salazareo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one question but might be non issue

export const DISABLED_BY_WORKSPACE = 'disabled_by_workspace';

/** Cap-lock bounds; past the retry budget a 409 beats waiting. */
const CAP_LOCK_ATTEMPTS = 20;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure if attempts too high? this might hold connection for up to a second like this

@jfcastro92
jfcastro92 force-pushed the juancastro/put-1758-37-cap-workspaces-per-user-and-seats-per-workspace branch from 012ee1d to c02fb25 Compare September 4, 2026 04:31
@jfcastro92

Copy link
Copy Markdown
Collaborator Author

Follow-up on the cap lock: waiting out the budget is no longer a refusal.

The first version threw 409 conflict when the retry budget expired. That surfaced as a flaky test β€” GroupGrants.test.ts failed once in a full-suite run and passed in isolation, because its fixture builds two workspaces while the rest of the suite runs in parallel against a shared Redis keyspace. The waiter's budget is 1s; the TTL is 10s, so a holder that dies leaves waiters to time out.

A flaky test is the cheap version of the real symptom: a legitimate single create refused because something unrelated held the key. So the timeout now proceeds unserialized, exactly as the Redis-unreachable path already did:

// Waiting out the budget is not a refusal: the cap is a bound, and
// a spurious 409 on a lone create is worse than a rare overshoot.
if (!held) return run();

The cap check still runs in both cases β€” what is lost is only the serialization of the count-then-insert window, which is the same thing that is lost when Redis is down. Consistent with what this PR already says: the cap is a bound, not a correctness invariant.

Both concurrency tests still pass and still falsify: with the lock removed they measure 5 workspaces under a cap of 1 and 6 seats under 3.

@jfcastro92
jfcastro92 force-pushed the juancastro/put-1758-37-cap-workspaces-per-user-and-seats-per-workspace branch from c02fb25 to d4d25f4 Compare September 4, 2026 13:35
@jfcastro92

Copy link
Copy Markdown
Collaborator Author

Terminology: "workspace" is now "team", everywhere

The feature said puter.teams in the namespace and "workspace" in every string, comment and error code. That divergence was deliberate at design time; it is now settled the other way, and the whole stack says team.

Applied to every commit rather than added on top, so each PR shows its own code already named correctly and no intermediate commit references a name a later one changes. All 13 commits still typecheck individually.

The contract-bearing renames

These were free to change now and expensive after release, which is why they went in this pass:

Before After
not_the_workspace_owner not_the_team_owner
workspace_limit_reached team_limit_reached
workspace_deleted team_deleted
max_workspaces_per_user max_teams_per_user
max_seats_per_workspace max_seats_per_team
disabled_by_workspace disabled_by_team
createWorkspace / updateWorkspace / deleteWorkspace createTeam / updateTeam / deleteTeam

Plus twoWorkspaces.ts β†’ twoTeams.ts, shareWorkspaces.js β†’ shareTeams.js, the teams-workspace-* CSS classes, and the team_workspace_deleted email template, which the mechanical pass turned into the stutter team_team_deleted and is now team_closed.

Scoped, and checked

"workspace" means other things in this repo. These were deliberately left alone, and verified untouched:

  • kvShares.ts and the KV integration tests, where workspace:abc: is a key prefix in an example, not a team
  • Events.md and KV.md, which use it the same way
  • UIPermissionDialog tests, same
  • package.json / webpack/BaseConfig.cjs / .gitignore, where it is npm workspaces

Audited three ways: every changed line mentions team or workspace (no collateral edits), no stutters or mangled words (team_team, teamspace, …), and both old config keys are gone with the new ones consistent across all 12 sites that read them.

One thing not renamed

The git branch names still say workspace (put-1740-1762-workspace-gui). Renaming a branch changes a PR's head ref and closes the PR, so they stay as they are β€” they are refs, not product surface.

@jfcastro92
jfcastro92 force-pushed the juancastro/put-1758-37-cap-workspaces-per-user-and-seats-per-workspace branch from d4d25f4 to eee777c Compare September 4, 2026 14:30
@jfcastro92

Copy link
Copy Markdown
Collaborator Author

not sure if attempts too high? this might hold connection for up to a second like this

You are right, and it was worse than just holding: the first version also threw 409 when the budget ran out, so unrelated contention could refuse a legitimate create. That already showed up as a flaky GroupGrants.test.ts run.

Two changes:

The budget is now 200ms, not 1s β€” 8 Γ— 25ms instead of 20 Γ— 50ms. The lock is held for a count plus an insert, single-digit milliseconds, so 200ms is already far past the contended case; the old numbers were padding with no reasoning behind them.

Running out is no longer a refusal. It proceeds unserialized, exactly as the Redis-unreachable path already did:

// Waiting out the budget is not a refusal: the cap is a bound, and
// a spurious 409 on a lone create is worse than a rare overshoot.
if (!held) return run();

So the worst case is now 200ms of waiting and then the unserialized path β€” never a held connection for a second, and never a spurious error.

Both concurrency tests still pass and still falsify: with the lock removed they measure 5 teams under a cap of 1 and 6 seats under 3.

A seat is a real Puter account: it takes a name from the global username pool
and gets a home directory. Nothing charges for one β€” that is prod's job β€” so
until it does, the only bound on creation is the request rate limit, which
bounds the rate and not the total.

  max_teams_per_user   default 1
  max_seats_per_team   default 50

Ordering is the substance of both checks. The team cap is tested before
the handle, so a capped user is told they are capped rather than that the name
they picked was unusable. The seat cap is tested before any account state
exists, so a refused provision does not burn a global username.

Soft-deleted teams do not count toward the owner's cap, so deleting frees
the slot β€” which does mean create, provision, delete, repeat still consumes
usernames over time, bounded by the daily rate limit. The caps raise the cost
and make the cycle audited; they do not close it.

Lowering the seat limit blocks new provisioning and disables nobody.

Both limits are published in rate-limits-and-quotas.md, and both keys are
documented in config.template.jsonc and config.default.json.

Closes PUT-1758.
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1758-37-cap-workspaces-per-user-and-seats-per-workspace branch from 66d76a4 to f870975 Compare September 4, 2026 19:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants