Skip to content

feat: the team seat experience β€” no email required, forced password change, team label, and plan-based limits (PUT-1792) - #3846

Open
jfcastro92 wants to merge 20 commits into
mainfrom
juancastro/put-1792-optional-seat-email
Open

feat: the team seat experience β€” no email required, forced password change, team label, and plan-based limits (PUT-1792)#3846
jfcastro92 wants to merge 20 commits into
mainfrom
juancastro/put-1792-optional-seat-email

Conversation

@jfcastro92

@jfcastro92 jfcastro92 commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Everything a team-provisioned account meets on its way in, and everything its
owner meets running the team. Found by testing Teams on a region and then
driving the whole flow locally against Stripe; seventeen commits, each
independently reviewable.

Seat needs no email, and is never asked to confirm one b19b312
Seat is prompted to choose its own password on first sign-in a983969 fa36f2e
Seat is emailed its credential, when an address is given fb74789
Seat is told which team its account belongs to 106978e
Team size and allowance depend on whether the owner pays e886e86 63f857c
A plan is bought per account, not one tier for the team efe6ef0 2083ef4 f2c9737 187e4c8 b8b0650 c2a975b 06bdc26 1efe086
Per-account actions are icons bafe8af
A seat's account surface is the one that is actually its own 480d6bc
Running out of seats says what raises the limit 63f857c
A team tier is named, not printed as its policy id de74b0c
A seat is not offered a plan link it cannot use 8d90418

Closes PUT-1792. Most of this is beyond that ticket β€” see Scope at the end.


1. Email was mandatory, and confirmation was demanded

createMember read the address with #requireString, and provisionAccount validated and de-duplicated it. An admin creating ten seats had to invent ten addresses and then live with ten uniqueness constraints β€” for accounts that sign in by username.

It also set requires_email_confirmation: true, reasoning that an admin-supplied address is unverified. True, but requireVerifiedAccount turns away on exactly

if (user?.requires_email_confirmation && !user?.email_confirmed) { ... }

so a fresh seat was asked to confirm an address it may not hold, and could not use the product until it did. The team creating the account is the trust anchor, not the mailbox. Now false either way.

idx_user_owned_email is partial and skips password-null rows, so omitting the address sidesteps the index rather than creating a collision surface β€” two emailless seats do not conflict, and there is a test for it.

2. Nothing asked the seat to change its password

The backend already refused every route with password_change_required, and /user-protected/change-password was already exempt β€” "the one route an account owing a password change may reach". The client half was missing entirely: zero references to that code in the GUI, so a seat signed in and then failed at everything with no prompt.

Two gaps. The flag never reached the client β€” neither the login response nor /whoami carried requires_password_change. And no window could serve as a gate: UIWindowChangePassword is a settings dialog, closable and never resolving on success.

UIWindowPasswordChangeRequired mirrors the existing *Required windows, resolves only once the change lands, and initgui loops on it. It runs last, matching assertVerifiedAccount's own order. It refuses a new password equal to the current one β€” otherwise the account stays on the credential its administrator still holds, which is the entire thing the gate exists to end.

initgui runs its verification gates in two places β€” the token-in-URL branch and the session-restore/login branch β€” and the first commit wired only one, so signing in with a password skipped the gate entirely. fa36f2e adds it to both and pins the invariant with a test that asserts each gate appears twice.

3. The credential had no delivery

team_account_created said the team "will send you a temporary password separately" and carried none. It now carries the username and temporary password, so an admin who supplies an address hands nothing over by side channel. Both credential-issuing paths pass the one they just minted β€” provisioning and re-issue; there is a test asserting the old password is absent from the re-issue mail.

With no address nothing is sent, which was already true (#notifyUser returns early) and is now covered.

4. A seat could not tell it was one

whoami now carries team: { uid, name }, gated on user actors β€” a seat's employer is no more an app's business than its phone number, which the same handler already withholds β€” and on teams_enabled, so a deployment without teams is byte-identical.

It rides whoami rather than a route of its own because the sidebar needs it at first paint; a /teams/whoami would add a request to every page load for every user, and almost none are seats. The lookup costs nothing: getOrgSeat is already cached, negatives included, and team_name comes off a join the query already made.

In the sidebar it sits under the Puter wordmark as a muted second line, hidden when collapsed. Owners see nothing β€” they already know, and one may own several teams, so there is no single name to show.

5. Team size followed nothing at all

4 seats free, 40 paid, decided by subscriptionSatisfies(id, true) β€” which is !FREE_SUBSCRIPTION_IDS.has(id), so a plan an extension registers counts as paid without core knowing its name. An unreadable plan takes the smaller cap: over-provisioning a free team is the worse failure.

Two defects kept that from being true of any real deployment, both found by testing it rather than reading it:

  • config.default.json shipped max_seats_per_team: 50, and the flat override wins over the plan β€” so the whole free/paid branch was dead code unless an operator deleted a key nothing told them about. A free owner got 50 seats. The defaults now carry max_seats_per_team_free / _paid and leave the flat override unset; a deployment that wants one number for everyone still sets it and still wins. A test reads config.default.json and fails if the flat key comes back.
  • #ownerPays asked metering about an {id, uuid} stub. A resolver keyed on any other field missed, so the answer came down to whether something else had cached that user's plan first β€” the same team, at the same seat count, was refused or allowed depending on nothing. It passes the whole user row now, and a test asserts the actor carries the username.

A seat of a team that pays for nothing gets half the free plan (org_seat_free). Without it a team is a way to mint free tiers β€” provision four seats and each arrives with a full free allowance nobody paid for. The figures are derived from REGISTERED_USER_FREE rather than restated, so the two cannot drift, and the id joins FREE_SUBSCRIPTION_IDS because nobody paid for it either and it must not satisfy a plan gate. It is a default resolver, so a paid team tier β€” which only prod knows about, via registerSubscriptionResolver β€” still outranks it.

Running out of seats used to report the number and stop. It now says what raises the limit and offers the plan picker, and the button appears only where a billing extension put one on the page.

6. A plan belongs to an account, not to a team

The first pass sold one tier for the whole team. That is the wrong unit: a team buys Puter for particular people, and the people differ. Each seat now carries its own tier β€” one subscription, one Stripe item per tier, quantity = seats on that tier β€” and the accounts table has a Plan column with a per-row action.

The plan card came out again (c2a975b) once every row carried its own, and the picker moved to the billing extension (06bdc26) so it reuses the personal plan modal's own frame rather than a lookalike. OSS renders from a prod-served catalogue β€” the precedent TabHome.js already set β€” and carries no pricing.

1efe086 is the one that made it work. The action did nothing, through three layers: listMembers never returned a member's uuid, the SDK's toMember dropped it, and the Plan column read seatTiers[undefined] so every seat rendered as Free. The uuid is owner-only β€” it is what billing keys a seat's plan on, and one member has no business identifying another.

7. A seat's account surface was not its own

A provisioned account could rename itself, delete itself, and see a Billing tab for a subscription it does not hold. Each is now refused server-side and dropped from the UI, keyed on one predicate (isOrgSeat): whoami reports a team only for an org-owned seat, and the owner joined their own team.

The Teams tab showed a seat nothing but its own audit rows, so a member could not see who else was on the team they were told they shared with. It lists them now; listMembers was already membership-gated and already withholds from a member what is not theirs.

The dashboard share modal had no way to reach a team, though the desktop dialog has had one since team sharing shipped and the SDK has always taken { team }. Same control, same copy, same shareTeams helper. The access list needed a team bucket to go with it: a team share names no holder, so the aggregate dropped it and a team you had just shared with vanished from the list.

Smaller things from the same pass: the record table pages at ten rows instead of running off the bottom, and the team card no longer labels the absence of a handle that nothing here can set.

8. A team tier printed as its policy id

i18n() echoes a key it has no translation for. Personal tiers have keys;
team-basic does not β€” so a seat's dashboard read "team-basic" where a
personal plan reads "Basic". The offering already travels with the subscription
(prod's subscriptions/current now carries a seat's team tier), so the display
name was already on the wire. i18n stays the fallback for the tiers that do
have keys.

Found by driving a real team purchase against a local instance, not by reading.

A seat's dashboard, after the fix (~/Desktop/team-billing-fixes-20260911/07-seat-sees-its-plan-not-free.png) 07-seat-sees-its-plan-not-free

9. A seat was offered a plan link it cannot use

The Usage card's Upgrade link relabels to "Manage β†’" for anyone on a plan β€”
including a seat, whose plan belongs to its team. Following it only reaches a
dialog saying so. Hidden for a seat on the same isOrgSeat predicate the rest of
the seat restrictions use; unchanged for everyone else, verified both ways
against a live instance (owner still sees "Upgrade β†’", seat sees nothing).


Test bugs this surfaced

  • The team suite was reading max_seats_per_team out of the developer's own config.json, so the cap under test was whatever that file said. It would have passed locally and failed in CI, which has no such file. The suite pins the value and the cap tests set their own.
  • A new HTTP test shared a rate-limit budget with two unrelated ones and pushed them into 429. The fixture is built through the service instead of over the wire.

Testing

backend (team, auth, share, whoami)   778 passed (17 files)
gui + sdk                             564 passed (45 files)
npm run typecheck                     no new errors

Driven end to end against a local instance with Stripe in test mode β€” real checkout sessions, real webhooks, real seats:

free cap holds          3 seats β†’ 4th allowed β†’ 5th refused, 409 seat_limit_reached, limit 4
paid cap holds          max_seats_per_team_paid pinned β†’ refused at that number, not the default
seat restrictions       no Billing tab, no Delete Account, no Change Username; note names the team
member roster           8 colleagues listed, "(you)" on self
team share              share β†’ mode change β†’ revoke, all three on the team's own row
per-account plan        picker opens on the seat's uuid and renders the catalogue's own copy
seat limit dialog       "Your plan covers 5 accounts…" + See plans

Falsified, each breaking only its own test:

requires_email_confirmation: true  β†’ Γ— never demands confirmation, with or without an address
same-password guard removed        β†’ Γ— refuses to reuse the password the admin handed over
resolve on a rejected response     β†’ Γ— stays open on a rejected change, so the gate cannot be escaped
gate on one initgui branch only    β†’ Γ— wires the gate into both chains
credential dropped from re-issue   β†’ Γ— emails the fresh credential on re-issue, not the old one
isUser gate removed                β†’ Γ— withholds it from an app actor
free and paid caps equal           β†’ Γ— lets a paid owner past four
flat cap back in config.default    β†’ Γ— is not overridden by the shipped defaults
id/uuid stub actor restored        β†’ Γ— asks about the owner with a whole user
seat resolver returning null       β†’ Γ— resolves half the free plan
allowance not halved               β†’ Γ— resolves half the free plan
uuid withheld from the owner       β†’ Γ— keeps the seat uuid, which the plan action is keyed on
team bucket removed from aggregate β†’ Γ— gives a team a row of its own

Scope

Only commit 1 is PUT-1792. The rest came out of the same rounds of testing and are small enough to have travelled with it, but say the word and I will split any of them out.

Two calls I made rather than guessed, both cheap to reverse:

  • "Free org users" means seats, not owners. The owner keeps their own plan; only org_owned = 1 accounts get the halved policy. That also preserves the disjointness the two subscription resolvers rely on.
  • 50% applies to usage and storage. If you meant usage alone, it is one line.

Two things worth a reviewer's eye:

  • prodMeteringAndBilling also registers a default resolver, for corporate email domains. Two defaults means firstResolver picks by registration order, so a seat on a trusted domain could land on corpo rather than org_seat_free. Same class of problem the team resolver already avoids by keying on getOrgSeat, and worth a follow-up if that overlap is real in prod.
  • Nothing in the dashboard sets a team handle, so every team made there has none. I hid the empty label rather than inventing a way to set one; if handles should be settable, that is a separate change.

…rm one

PUT-1792. Two separate problems, both from treating a provisioned account like
a self-registered one.

`email` was required, so an admin creating ten seats had to invent ten addresses
and then keep track of ten uniqueness constraints -- for accounts that sign in
by username and never use the address. It is now optional at every layer, and
the add-account form does not ask for it at all: username is the only thing a
seat needs.

`requires_email_confirmation` was set to true, with the reasoning that an
admin-supplied address is unverified. True, but `requireVerifiedAccount` turns
away on exactly `requires_email_confirmation && !email_confirmed`, so a
freshly created seat was asked to confirm an address it may not hold and could
not use the product until it did. The team creating the account is the trust
anchor, not the mailbox, so this is now false either way.

An address is still accepted and still stored when given, because the notices
are worth delivering. `#notifyUser` already returned early on a missing
address, so `team_account_created`, `team_account_disabled`,
`team_password_reset` and `team_closed` degrade quietly with no new branching --
the temporary password is in the API response, which is the documented delivery.

`idx_user_owned_email` is partial and skips password-null rows, so omitting the
address sidesteps it rather than creating a collision surface. Two seats with no
address do not conflict, and there is a test for it.

Docs now say an emailless seat is recoverable only through its team's owner.
That falls out of the design rather than being a limitation of this change, but
it should be written down rather than discovered.

Falsified: putting `requires_email_confirmation: true` back fails
"never demands confirmation, with or without an address" with
`expected true to be false`, and nothing else.

164 team tests, 40 SDK tests, typecheck clean.
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
πŸ”΅ Lines 94.03%
⬇️ -0.01%
30260 / 32180
πŸ”΅ Statements 92.03%
⬇️ -0.01%
32901 / 35748
πŸ”΅ Functions 91%
⬇️ -0.02%
5563 / 6113
πŸ”΅ Branches 81.15%
⬇️ -0.03%
21894 / 26979
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
extensions/whoami.ts 87.12%
⬇️ -0.24%
80.28%
⬆️ +0.92%
62.5%
🟰 ±0%
88.54%
⬇️ -0.34%
46, 114-122, 160-164, 194, 199, 202, 229-231
src/backend/types.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/clients/email/templates.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/controllers/auth/AuthController.ts 91.07%
⬆️ +0.02%
78.92%
⬆️ +0.07%
78.22%
🟰 ±0%
91.97%
⬆️ +0.03%
6, 24-26, 32, 33, 34, 38-39, 44, 45, 47, 488-490, 504-506, 641-642, 716-718, 735-737, 879, 941-945, 963, 1005-1010, 1019, 1030, 1045-1050, 1056, 1100-1101, 1104-1109, 1252, 1283-1284, 1303-1307, 1345-1347, 1349-1351, 1399, 1427-1438, 1513-1515, 1528, 1588-1590, 1624, 1637-1639, 1665-1667, 1698, 1702-1704, 1802, 1888, 1903, 1942, 1955, 2007, 2066, 2079-2082, 2124-2126, 2144-2147, 2201-2203, 2215-2217, 2233-2235, 2249-2252, 2258, 2277, 2288, 2434-2438, 2467, 2498-2500, 2512-2518, 2578-2583, 2662-2664, 2727, 2765, 2806-2808, 2874, 2904, 2983-2985, 2987-2989, 2993-2996, 3009-3012, 3084-3089, 3092-3094, 3143, 3161, 3179, 3192, 3215, 3227, 3244, 3254, 3265, 3319, 3493-3497, 3519-3521, 3535, 3538-3542, 3618
src/backend/controllers/events/limits.ts 100%
🟰 ±0%
88.88%
⬇️ -11.12%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/controllers/team/TeamController.ts 85.02%
⬇️ -0.48%
46.91%
⬆️ +1.52%
73.58%
⬆️ +0.51%
88.95%
⬇️ -0.20%
6, 24-26, 32, 33, 34, 38-39, 44, 45, 47, 209, 224, 282, 305-314, 358-360, 373, 375-377, 383-385, 390-392, 399-401
src/backend/core/http/middleware/rateLimit.js 94.46%
⬆️ +0.08%
89.65%
⬆️ +0.29%
87.69%
⬆️ +0.19%
94.82%
⬆️ +0.07%
146, 149, 338-366, 461, 774-777, 785
src/backend/data/subPolicies/index.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/data/subPolicies/orgSeatFreePolicy.ts 100% 100% 100% 100%
src/backend/drivers/ai-chat/ChatCompletionDriver.ts 96.48%
⬇️ -0.01%
89.82%
⬇️ -0.06%
97.56%
🟰 ±0%
97.74%
⬇️ -0.01%
213-216, 347, 377, 393, 457-459, 488-492, 527, 542, 575, 643, 785, 809, 815, 822, 1501
src/backend/services/metering/consts.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/services/team/TeamService.ts 89.66%
⬆️ +0.56%
77.4%
⬆️ +2.82%
100%
🟰 ±0%
92%
⬆️ +0.61%
112, 114, 130, 154-155, 175-176, 197, 273, 292-294, 297-299, 308-320, 349-352, 379, 385-387, 485-488, 496, 502, 543-546, 677-679, 739, 824-827, 927, 946-948
src/backend/stores/team/TeamStore.ts 97.67%
⬆️ +0.04%
86.95%
🟰 ±0%
100%
🟰 ±0%
98.08%
⬆️ +0.02%
258, 292, 463, 512
Generated in workflow #1814 for commit 15527ea by the Vitest Coverage Report Action

The backend already refused every route with `password_change_required` until a
provisioned account replaced the password its administrator chose, and
`/user-protected/change-password` was already exempt so the account could act.
The client half was missing entirely: nothing in the GUI referenced that code,
so a seat signed in and then failed at everything with no prompt and no way out.

Two gaps, both closed here.

The flag never reached the client. Neither the login response nor `/whoami`
carried `requires_password_change`, so the GUI could not have known even if it
wanted to. It ships from both now, alongside the other three verification flags
the whoami extension already describes as "the flags the GUI acts on".

There was no window to show. `UIWindowChangePassword` is a settings dialog --
closable, and it never resolves on success -- so it cannot act as a gate.
`UIWindowPasswordChangeRequired` mirrors the existing `*Required` windows: it
resolves true only once the change lands, and `initgui` loops on it. It runs
last in the boot chain, matching the server order in assertVerifiedAccount.

It refuses a new password equal to the current one. Without that the account
stays on the credential its administrator still holds, which is the entire thing
the gate exists to end.

Falsified twice: dropping the same-password guard fails "refuses to reuse the
password the admin handed over", and resolving on a rejected response fails
"stays open on a rejected change, so the gate cannot be escaped" -- each
breaking only its own test.

175 backend tests, 326 GUI/SDK tests, typecheck clean.
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1792-optional-seat-email branch from dc5d963 to a983969 Compare September 10, 2026 20:35
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for puter.js SDK

Status Category Percentage Covered / Total
πŸ”΅ Lines 62.75%
⬆️ +0.65%
4626 / 7371
πŸ”΅ Statements 61.25%
⬆️ +0.63%
4914 / 8022
πŸ”΅ Functions 63.03%
⬆️ +0.32%
873 / 1385
πŸ”΅ Branches 55.01%
⬆️ +0.58%
3064 / 5569
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/puter-js/src/modules/teams/createMember.js 75%
⬆️ +3.58%
69.23%
⬇️ -5.77%
100%
🟰 ±0%
75%
⬆️ +3.58%
24, 28
src/puter-js/src/modules/teams/lib/shapes.js 92.85%
🟰 ±0%
78.26%
⬇️ -2.69%
85.71%
🟰 ±0%
92.3%
🟰 ±0%
29-32
Generated in workflow #611 for commit 15527ea by the Vitest Coverage Report Action

…ss is given

Follows the previous commit. Dropping the email field entirely went one step too
far: without an address the temporary password shown once in the panel is the
only copy, and an admin who closes that panel has to issue a new one. The field
is back, marked optional, and now it buys something concrete.

`team_account_created` carried no credential -- it said the team "will send you
a temporary password separately". It now carries the username and the temporary
password, so an admin who supplies an address hands nothing over by side channel.

`#notifyUser` takes extra template variables, and both credential-issuing paths
pass the one they just minted: provisioning and re-issue. Re-issue passing the
fresh credential rather than the stale one is the case worth checking, and there
is a test that asserts the old password is absent from that mail.

With no address nothing is sent, which was already true -- `#notifyUser` returns
early without one -- and is now covered.

The docs said the notice carries no credential in two places. Both corrected.

Falsified: dropping the credential from the re-issue call fails "emails the fresh
credential on re-issue, not the old one" and nothing else.

178 backend tests, 326 GUI/SDK tests, typecheck clean.
A provisioned account had no way to know it was one. That matters: the team can
reset its password and close it, which is exactly what the account-created email
already warns about, and nothing in the product repeated it afterwards.

`whoami` now carries `team: { uid, name }`. Two gates on it. Only user actors --
a seat's employer is no more an app's business than its phone number, which the
same handler already withholds. And only where `teams_enabled` is on, so a
deployment without teams is byte-identical.

It rides whoami rather than a route of its own because the sidebar needs it at
first paint. A `/teams/whoami` would add a request to every page load for every
user, and almost none of them are seats. The lookup costs nothing either way:
`getOrgSeat` is already cached, negative results included, precisely because
almost nothing is a seat. `team_name` comes off a join the query already made.

In the sidebar it sits under the Puter wordmark -- the conventional slot for
workspace context -- as a muted second line, hidden when the sidebar collapses.
Owners see nothing: they already know, and one may own several teams, so there
would be no single name to show.

The markup is a helper rather than another branch inside UIDashboard, matching
how appGroups/credits/usageBudget were pulled out, so it can be tested without
mocking the window stack.

Falsified: dropping the `isUser` gate fails "withholds it from an app actor" and
nothing else.

181 backend tests, 331 GUI/SDK tests, typecheck clean.
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1792-optional-seat-email branch from 5bf6647 to 106978e Compare September 10, 2026 20:52
…allowance

Three related limits.

Seats per team now depend on the owner's plan: 4 free, 40 paid, decided by
`subscriptionSatisfies(id, true)` -- which is `!FREE_SUBSCRIPTION_IDS.has(id)`,
so a plan an extension adds counts as paid without core knowing its name. The
existing `max_seats_per_team` still overrides both, so a deployment that already
set it keeps what it asked for, and `max_seats_per_team_free` / `_paid` tune
each. An unreadable plan takes the smaller cap: over-provisioning a free team is
the worse failure.

A seat of a team that pays for nothing resolves `org_seat_free`, half the
registered free plan. Without it a team is a way to mint free tiers -- provision
four seats and each arrives with a full free allowance nobody paid for. The
figures are derived from `REGISTERED_USER_FREE` rather than restated, so the two
cannot drift, and the id joins `FREE_SUBSCRIPTION_IDS` because nobody paid for
it either and it must not satisfy a plan gate.

It is a *default* resolver, so a paid team tier -- which only prod knows about,
through `registerSubscriptionResolver` -- still outranks it. The lookup is
`getOrgSeat`, already cached with its negatives, because almost nothing is a
seat.

Found while testing: the suite was reading `max_seats_per_team` out of the
developer's own config.json, so the cap under test was whatever that file said.
It would have passed here and failed in CI, which has no such file. The suite
now pins the value and the cap tests set their own.

Falsified three ways, each breaking only its own test: equal caps fails "lets a
paid owner past four"; a resolver returning null, and an unhalved allowance,
both fail "resolves half the free plan".

186 team/whoami tests, 159 metering tests, typecheck clean.
@jfcastro92 jfcastro92 changed the title feat: a team seat needs no email address, and is never asked to confirm one (PUT-1792) feat: the team seat experience β€” no email required, forced password change, team label, and plan-based limits (PUT-1792) Sep 10, 2026
PUT-1796's UI half. The owner already manages accounts here, so the plan they
are billed for belongs on the same page rather than a tab away.

Pricing stays out of OSS. The card is drawn from whatever catalogue the server
returns, so this code knows no tier, no price and no payment provider -- a
deployment that sells nothing serves no catalogue and the card does not render.
`TabHome` already reads `/marketplace/subscriptions/current` the same way, so
OSS reading a prod-served endpoint is not a new idea here.

Two things are deliberately not offered rather than offered and broken. A tier
the server marks unavailable gets no button, because no Stripe price is
configured for it and buying would 422. And no button appears at all unless a
billing extension has set `window.team_billing_ui` -- the prices still render,
which is useful on its own, but nothing invites a click nobody can handle.
Checkout is Stripe's, so the button only dispatches `team-plan-purchase` with
the team and item id and lets the extension take it from there.

The markup is a helper rather than another branch in TabTeams, matching
teamBadge/credits/usageBudget, so it can be tested without the window stack.

Falsified: offering an unavailable tier fails "offers no button for a tier with
no configured price"; rendering buttons regardless of the extension fails
"offers no button without a billing extension to act on it".

342 GUI/SDK tests, 136 team backend tests, typecheck clean.
…-in-URL

`initgui` runs the verification gates in two places: the token-in-URL branch and
the session-restore/login branch. The password gate went into the first only, so
it never fired for the case it exists for -- a seat signing in through the login
form with the credential its administrator issued. The account reached the
desktop and then failed at every gated call with no prompt, which is the state
the gate was written to prevent.

Caught by manual testing, not by any test: both chains looked right in
isolation. Added an invariant test over initgui's source asserting each gate
appears on both paths and each loops until cleared, which is the shape of the
mistake rather than the instance of it.

Falsified: removing the login-path gate fails "runs the forced password change
on both paths" with `expected 1 to be 2`.
It filtered on `org_owned`, but the GUI annotates members to `orgOwned` --
so the count was always zero and the card read "billed for 0 accounts" beside
an accounts table saying two.

Uses `membersBillingSummary` now, the same count the table shows, which also
excludes suspended seats: they stop costing a per-account charge, which the
naive filter would have billed for.
Per-seat billing context without per-seat subscriptions. PUT-1788 D1 stands: one
tier per team, one Stripe subscription, quantity = seat count. The tier is still
changed once, in the plan card.

Four states, because "on Team Basic" is not true of every row. The owner is the
payer and keeps their own personal plan, so they show as such rather than
inheriting the team's. A suspended seat reads "not billed" -- it stops costing a
per-account charge, which is the same rule the billing summary and the plan
card's seat count already use. A seat of a team that bought nothing reads free,
which is the reduced org-seat allowance. Everyone else shows the tier.

The rule is a helper in teamsConsole so it is testable, like the rest of that
file.

Falsified: dropping the payer branch fails "says the owner is the payer, not a
seat"; dropping the suspended branch fails "says a suspended seat is not
billed".
Reverses PUT-1788 D1 at the UI. A team keeps one subscription; each account sits
on its own tier within it.

The plan card stops being the chooser and becomes a summary: what each tier
costs per account, and how many accounts are on it. Choosing happens on the
account, with a Change plan action per row, because that is the thing the tier
now belongs to.

`memberPlanLabel` reads the seat's own assignment rather than the team's single
tier, so two accounts can honestly show different plans. The other three states
are unchanged and still matter: the owner is the payer, a suspended seat is not
billed whatever it was on, and a seat nobody bought a tier for is free.

The action only appears where a billing extension is present and a catalogue
came back, so a deployment that sells nothing shows prices and no dead buttons.
A row now carries up to five actions -- change plan, reissue, suspend or enable,
delete -- and five labelled buttons do not fit the cell. Each becomes a 30px
icon button.

The label is not dropped, only hidden: it stays as `title`, as `aria-label`, and
as visually-hidden text, so a screen reader and a hover both still get it. The
glyphs are `aria-hidden`, since the button already carries the name.

Inline SVG rather than files under `icons/`: these are one-place 24px line
glyphs, and the dashboard already inlines its sidebar chevron the same way. An
`edit` glyph is defined but unused, ready for the account-edit action.

Falsified: removing the visually-hidden label fails "keeps the label reachable
without showing it".
Per-tier quantities need to know *which* seats are active, not just how many:
the count has to be split by the tier each one is on. `countActiveSeats` answers
the old question and stays for the cap and the console copy.

Keyed by `uid` rather than the numeric id, because the billing side holds uids
and would otherwise have to look the team up twice. Same suspension filter, and
a test asserts the two agree so they cannot drift apart.

Falsified: dropping the suspension clause fails "leaves out a suspended seat,
matching countActiveSeats".
It described a team-wide tier, which stopped existing when plans moved to the
account. What it still showed -- what each tier costs -- is in the Change plan
picker, next to the account it applies to, so nothing is lost by removing it.

`teamPlan.js` and its tests go with it, along with twelve i18n keys nothing
reads any more. `state.plan` stays: the Plan column and the picker both need
the catalogue and the seat assignments.
The Teams tab was asking which tier with a UIAlert, which looked nothing like
the personal plan modal. It now dispatches the seat and lets the extension
render the grid, so the two match and OSS stays free of prices.

Carries `username` and `currentTier` so the picker can title itself and mark
the plan the account is already on.
The per-account subscription button did nothing. `listMembers` never
returned a member's uuid, the SDK's `toMember` dropped it, and the Plan
column read `seatTiers[undefined]`, so every seat rendered as Free and
the action dispatched with no seat.

The uuid is owner-only: it is what billing keys a seat's plan on, and one
member has no business identifying another.
A provisioned account could rename itself, delete itself, and see a
Billing tab for a subscription it does not hold β€” all of it the team's,
not the account's. Each is now refused server-side and dropped from the
UI, keyed on one predicate: whoami reports a team only for an org-owned
seat, and the owner joined their own team.

The Teams tab showed a seat nothing but its own audit rows, so a member
could not see who else was on the team they were told they shared it
with. It now lists them; listMembers was already membership-gated and
already withholds from a member what is not theirs.

The dashboard share modal had no way to reach a team, though the desktop
dialog has had one since team sharing shipped and the SDK has always
taken `{ team }`. Same control, same copy, same helper. The access list
needed a team bucket to go with it: a team share names no holder, so the
aggregate dropped it and a team you had just shared with vanished.
`config.default.json` shipped `max_seats_per_team: 50`, and that flat
override wins over the plan, so the free/paid caps were dead code on
every deployment that did not delete the key. A free owner got 50 seats.
The defaults now carry the two per-plan numbers and leave the flat
override unset; a deployment that wants one number for everyone still
sets it and still wins.

`#ownerPays` asked metering about an `{id, uuid}` stub, so a resolver
keyed on any other field missed and the answer came down to whether
something else had cached that user's plan first β€” the same team, at the
same seat count, was refused or allowed depending on nothing.

Running out of seats now says what raises the limit and offers the plan
picker, rather than reporting the number and stopping there. The record
table pages at ten rows instead of running off the bottom, and the team
card no longer labels the absence of a handle nothing here can set.
`i18n()` echoes a key it has no translation for, and a team tier has
none β€” so a seat's dashboard read "team-basic" where a personal plan
reads "Basic". The offering already travels with the subscription, so
its display name is right there; `i18n` stays the fallback for the
personal tiers that do have keys.
The Usage card's Upgrade link relabels to "Manage β†’" for anyone on a
plan, including a seat whose plan is its team's. Following it only
reaches a dialog saying so. Hidden for a seat, unchanged for everyone
else.

@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.

mightve missed it, but think the free org seat isnt being included in the gate for subscription requirement

Review catch by @Salazareo: `org_seat_free` reached `FREE_SUBSCRIPTION_IDS`
and so the `requireSubscription` gate, but two other surfaces decide on
plan and neither consults that set.

`bySubscription` maps name `user_free` and `temp_free`. A plan that
matches no key fell through to the top-level `limit` -- the paid cap --
so a seat outranked an ordinary free account: 240 event listings a minute
against their 120, and the same shape across the kv, notification,
subdomain and worker drivers. Both resolvers now fall back to the
`user_free` entry for anything in the free set, which covers every driver
at once and any free plan added later.

`subscriberOnly` compared against the two named ids, so a seat could
reach a paid-only model. It asks the set now.

A paid plan that names no cap of its own still takes the base, and an
unresolved plan still takes the base; there are tests for both so the
fallback cannot widen into "free by default".
@jfcastro92

jfcastro92 commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author

Good catch β€” you were right, not only one gate.

org_seat_free was in FREE_SUBSCRIPTION_IDS, so requireSubscription / assertActorHasSubscription did refuse it correctly. But two other surfaces decide on plan and neither consults that set:

1. Every bySubscription cap. Drivers enumerate user_free and temp_free by name, and resolveSubscriptionLimit falls through to the top-level limit β€” the paid cap β€” for anything unlisted. So a seat outranked an ordinary free account:

free user seat (before)
events list 120/min 240/min
durable subscriptions 100 500
kv list concurrency 3 5

Same shape in the kv, notification, subdomain and worker drivers.

2. subscriberOnly models. ChatCompletionDriver compared subscription.id against the two named ids, so a seat paying nothing could reach a paid-only model.

Fixed at the two resolvers rather than by adding org_seat_free to ~10 driver maps β€” an unlisted plan that is in the free set now takes the user_free entry. That covers every driver at once and any free plan added later, which is the same reason the set exists.

Guarded against over-correcting: a paid plan that names no cap of its own still takes the base, and an unresolved plan still takes the base. Tests for both, plus a seat held to the free cap and a seat refused a subscriber-only model.

15527eac4. Backend suites: 2848 passed.

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