A sender a member blocked could still land items in that member's
shared-with-me — and grant them real access — by sharing with a common
team. The group grant is one row and cannot exclude a member, so the
block is now enforced where delivery is derived per member: the
permission scan, the inbound listing and its count, and the fs-event
fan-out all skip team rows whose issuer the member blocked. Nothing is
revoked, so unblocking restores everything. Blocking now also bumps the
blocker's permission cache so it bites immediately. Direct shares keep
their contract: existing ones stand.
#unshareTeam swept re-shares by listing members with a 200 cap and
never following the cursor, so members past the cap kept their orphaned
rows. It now walks the share rows in the subtree — the set that can
actually need sweeping — and filters those issuers to members, which
has no cap by construction.
`gui_params.teams_ui` stays the deployment-wide switch; with it off, the
tab now also renders for a signed-in user who passes the email-domain
allowlist (membership included, so seats see their roster). Anonymous
renders hide it, and the API keeps deciding real access either way.
`teams_allowed_email_domains` limits who may enter the teams surface;
unset keeps today's behavior. Gated on the two routes that constitute
entry — creating a team, and the listing that shows the tab — with the
same 404 a teams-off deployment answers, so the GUI needs no change and
a staged rollout is indistinguishable from the feature being off.
Members of an existing team always pass, whatever their domain: an
allowed owner brought them in, and the surface follows the team.
Deleting a team suspends every provisioned seat, but every member route
resolved live teams only — so the suspended seats could never be
deleted afterwards, stranding the accounts and, on the billing side,
their paused subscriptions. deleteMember now resolves the team
soft-deleted or not, matching the audit reader.
It also gains the guard enableMember always had: only the team's own
suspension qualifies. A platform-suspended seat could previously be
cascade-deleted by the owner, destroying an account Puter had frozen.
Every team route except the directory refuses app and API-token callers —
administration belongs to the account console. Publishing provisioning,
credentials, suspension and audit in the app-developer docs invited
integrations that would 403 on their first request, so those thirteen
pages are gone and the overview is rewritten around what an app can do:
detect team context with list(), look up colleagues with listDirectory(),
and share with a whole team.
listDirectory — the one app-callable method — was also the only one with
no page; it has one now, leading with its consent gate. The team
recipient joins the share() docs, where the integration actually happens.
From the adversarial review of this PR.
The forced password-change gate could deadlock: it POSTed to the
cookie-only route with a bare fetch, and both initgui call sites open it
before update_auth_data mints the session cookie — a fresh browser with a
token URL 401'd every submit inside a non-dismissible loop. It uses the
session-cookie retry wrapper now, taking the caller's token because
window.auth_token does not exist yet on that path. It also gains the
logout footer its sibling gates have; a lost temporary password was a
hard lock with devtools as the only exit.
Password recovery refused for seats: the address is admin-supplied and
never verified, so whoever holds that inbox could take the seat over at
any later time. A seat's recovery channel is its admin's reset.
change-email gets the same seat guard as change-username and deletion —
the address is where admin-issued credentials go.
The login response now carries `team` alongside requires_password_change:
no-reload logins store that payload as window.user verbatim, and every
seat restriction keys on it.
Smaller: the team-badge tooltip no longer double-encodes; the create-token
hint for an emailless account stops pointing at a verification it can
never perform; the quotas doc records the halved org_seat_free allowance;
the config template tells upgrading operators how to keep the old flat
cap; the SDK suite covers emailless provisioning and the owner-only uuid.
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".
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.
`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.
`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.
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.
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.
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.
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.
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".
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".
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.
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".
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.
`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`.
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.
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.
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.
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.
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.
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.
Per-account billing needs "how many accounts is this team getting the
benefit of", and neither existing count answers it. `countSeats` includes
suspended seats, because it bounds the seat cap and a suspended seat still
holds its username. `countPayers` counts the owner. Billing off `countSeats`
overcharges for suspended accounts, contradicting what the admin console
tells the owner: "0 suspended accounts cost nothing per account."
So this is a third, separate count rather than a change to either — the cap
must keep counting suspended seats.
An unactivated seat is deliberately still counted: it holds a temporary
password, not a suspension, and it is paid for. That is why the filter is
narrower than `listDirectory`'s, which also excludes
`requires_password_change`.
Falsified: dropping the suspended clause fails exactly one test
("drops a suspended seat from the active count but not from the cap",
expected 3 to be 2) and leaves the other 34 green.
OpenAI shuts the Sora Videos API down on 2026-09-24 and sora-2 was the
default txt2vid model, so the default moves to Veo 3.1 Lite on Gemini
and the OpenAI video provider goes. While there, the video catalogs are
brought in line with what each vendor serves today, the request options
are unified across providers, and the txt2vid docs are rewritten.
- driver: default provider gemini-video-generation with
veo-3.1-lite-generate-preview; a request under the generic `ai-video`
driver name lands on the default instead of the first-registered
provider; `WIDTHxHEIGHT` sizes map onto tier catalogs by the shorter
side and fill width/height
- openai video provider, the `openai-video-generation` alias, its
config template and migration entries, and Together's openai/sora-2*
rows removed
- gemini: Veo 3.1 Fast rates 10/12/30 cents per second for
720p/1080p/4K, Veo 3.1 Lite accepts reference images, URL image
inputs are fetched server-side through the SSRF-guarded fetch
- together: drop nine models retired upstream, add eighteen from the
live listing; per-second models are estimated from the catalog rate,
clamped to remaining credit and billed at the cost Together reports
on the job; tier-sized models take resolution/ratio;
input_reference/last_frame map onto keyframes; generate_audio is
forwarded
- byteplus: Seedance 2.5 (dreamina-seedance-2-5-260628) with per-model
reference-image caps
- util/imageInput: string-level image helpers shared by the image and
video drivers; ai-image/inputImage re-exports them unchanged
- puter.js types: provider and generate_audio options; docs: txt2vid
page rewritten with per-provider model tables, unified options and
four new playground examples
Known follow-up: Veo returns a key-protected Google file URL, so the
default clip cannot be played directly by a browser until the provider
fetches it server-side.
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
When the chat fallback chain is exhausted, the driver already records
each attempt (model, provider, status, code, message, timeout) in the
error's `fields.attempts`, but the alarm keyed on the classified message
alone and the alarm client printed the error at inspect depth 2, so the
log and Slack line read `internal_error:All providers failed` with
nothing about which providers failed or why. Deduped repeats printed
only a count.
- The HTTP alarm gate now attaches an HttpError's `fields` to the alarm
under a single `details` key. One key can't shadow the gate's own
request fields, and a repeat from another thrower on a shared id
replaces it instead of merging into it.
- The chat driver logs one warn line per exhausted chain with the
completion id, the resolved route, the classified code and the
attempts as JSON, so every occurrence is greppable by trace even when
the alarm dedupes it. Chains marked `noAlarm` don't log.
- Docs: `fields` reaches both the client and the alarm, so it has to be
safe to show the caller.
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Members can already enumerate each other: `/teams/:uid/members` needs a user
actor and nothing more. The only thing this adds is admitting an app actor to
the same names, so an app can offer colleagues without the member driving it.
That is the whole risk, so it is off until the team owner turns it on.
`group.directory_enabled` defaults to 0, and a team that has not opted in
answers 404 rather than 403 -- whether a team has this on is not something an
app should be able to probe for either.
Three things bound what an app sees. The membership tested is always the
person's, never the app's, so an app installed by a member of one team can
never read another's. The page carries username and uuid and nothing else --
no email, activation state, usage or role. And suspended accounts and ones
that never took up their credential are left out, since offering someone who
cannot sign in is noise and their existence is not this list's to disclose.
Activation is the forced-change flag clearing, not the password existing: a
provisioned seat holds its temporary password from birth, so testing
`password IS NOT NULL` would have leaked exactly the accounts meant to be
excluded. A test covers that distinction.
Turning the directory on or off writes an audit row, because it changes who
can read the member list and that is not something a team should be able to
alter silently. Setting it to the value it already has records nothing.
The toggle lives in TabTeams, and turning it on asks for confirmation while
turning it off does not -- one grants access, the other only takes it away.
Closes PUT-1736.
Two places a team becomes visible to a person rather than an API: the
Dashboard tab that administers it, and the share dialog that shares with it.
Squashed because they are one change to a reviewer. Both are the first GUI
consumers of `puter.teams`, both add strings to the same `en.js`, and both had
to answer the same question -- what a team looks like to someone who has
never seen one. Splitting them would mean reviewing that answer twice and
resolving the same translation conflict twice.
TabTeams (PUT-1740)
Registered in `builtinTabs` after `TabFiles`, as a plain object with
`html()` and `init($el_window)` like every other tab. The member table
carries the operations the API allows and nothing it does not: provision,
resend an activation, disable, restore, delete.
Deleting asks for confirmation naming the account, because the API deletes
on a single call -- PUT-1732 places the reversible step at disable, not in
front of the request, so the client is where a confirmation belongs.
The wording says plainly that disabling stops the per-account charge and
keeps the storage one, and that deleting is the only thing that ends it.
This is the one place a person decides between the two, so leaving them to
infer the difference costs them money.
Rendering and the billing arithmetic are pure functions in
`teamsConsole.js`, with tests beside them; the tab file is the DOM.
Share dialog (PUT-1762)
`UIWindowShare` gains the teams the caller owns as recipients. A
team is one entry in the list, not its members expanded -- sharing
reaches every member, and the per-person wording would understate what the
grant does.
`shareWorkspaces.js` holds the resolution and the deduplication, so the
dialog does not gain a second source of truth about who a recipient is.
Closes PUT-1740 and PUT-1762.
Team administration reaches the backend through the `/teams` routes
rather than a driver interface, because the gates it needs are route-level:
a user actor, a verified account, and a dual-window rate limit.
The module follows `apps/` and `perms/` in layout -- one file per method,
a thin `index.js`, a JSDoc-only `types.js`, and the `METHODS` rebinding so a
destructured method keeps its `this`. It does not follow `perms/lib/req.js`:
those endpoints resolve `{ error: true }` for backward compatibility, and
nothing here has callers to keep compatible, so this throws `PuterJSError`
with the backend's own code.
Every method takes a team `uid`. A handle is a mutable label that
deleting the team releases, so a stored handle can later resolve to a
different team.
The list methods offer the three forms `puter.apps.list()` does -- an array
by default, the page envelope under `cursor`/`includeTotal`, an async
iterator under `stream`. They refuse `offset`: these routes are keyset-only
and would otherwise return page one however far you asked to skip.
The surface covers the routes that exist today. Usage totals and member-email
correction have no backend route yet and are deliberately absent rather than
shipped as methods that 404. `deleteMember()` is here because the route it
needs lands in the commit below this one.
A disabled account persists indefinitely. Removing it is an explicit request,
never a timer, and it is refused on a live account with
`account_must_be_disabled_first` — which puts a reversible step in front of the
only irreversible operation in the feature.
The audit row is written before `cascadeDelete` runs. The FKs are ON DELETE SET
NULL and the `_keep` columns carry the identifiers, so the record of what was
done survives the account it names.
No second billing emit here: `cascadeDelete` already captures the seat and
fires `team.account.deleted` through UserAccountService, and emitting again
would close the storage charge twice. Disabling closed the per-account charge;
this closes the storage one, and it is the only thing that does.
There is no restore window, and none was wanted: the reversible step already
exists earlier at disable, a disabled account costs only the bytes it holds so
nothing pressures a hasty delete, and a restore promise means retaining data
the team explicitly asked to be rid of.
Published in rate-limits-and-quotas.md alongside the team-deletion note,
since the two are easy to confuse and only one of them frees a seat.
Closes PUT-1732.
Two halves of the same question -- what did the team do to me, and how do
I find out. One is pull, the other push.
The activity view (PUT-1746) was already user-scoped and already 404'd a
non-member; what was missing is the load-bearing half. A member is told a reset
happened, but a reset only matters alongside who signed in afterwards, so
`SessionStore.listSignIns` merges their own sign-ins into the same stream,
newest first, with a per-stream cursor. Without that row the audit says a
credential was issued and never says whether it was used.
The notices (PUT-1733) cover the two things a member cannot discover for
themselves: their account being disabled, and their team being closed.
Deliberately one each -- closing a team disables every account in it, so
sending both would tell one person twice about one event. Both say plainly that
nothing was deleted; the accounts persist, suspended, holding their files.
Squashed because they are one change to a reviewer: same audience, same
purpose, seven files, overlapping only in TeamService. Neither touches shared
platform code.
`user.requires_password_change` shipped with the team columns but nothing
enforced it and nothing ever cleared it, so a provisioned seat kept its
administrator-issued password indefinitely and `reissueCredential`'s
"already activated" 409 was unreachable.
Adds the fourth clause to `assertVerifiedAccount`, the only place a
verification gate may live -- WebDAV builds its own actor and calls that
function directly, so a second implementation would bypass it the way the
phone and card gates once were bypassed.
A gate that refuses everything also refuses the endpoint that clears it,
so `/user-protected/change-password` opts out with `allowUnconfirmed`.
That widens the route: an account pending email, phone or card
verification can now change its password, which it could not before. The
caller is authenticated and proves the current password, so this is
benign, but it is a behaviour change to a shared route.
Also here, because the gate is worthless without them:
- change-password and the recovery-token path clear the flag, and record
an `activate` entry when the account is a seat.
- Reset takes a live account back with a fresh credential, capped at 20
per day and audited as `reset_member_password` with no credential in
the row. Re-issue is audited the same way; it stays closed once a seat
has chosen its own password.
- An issued credential expires after 24h (new `temp_password_expires_at`
column, three dialects) and login refuses it after that, so an unused
reset dies instead of becoming a standing credential.
- 2FA is untouched by a reset, so a reset alone is not takeover.
`notifyShared` keyed every map off `ResolvedShare.holderId`. A team share
has no individual holder -- the grant is one row against the group and
`holder_user_id` is NULL -- so it was skipped everywhere and the call returned
having told nobody. Nothing errored: the share was created, resolved and listed
correctly. The failure mode was silence.
`#recipientsOf` now expands a team share to its live members at
announcement time, so the grant stays one row and only the telling fans out.
`#announce` and `#emailHolder` are both inside the per-recipient loop, so
members get the in-app notification and the email digest.
Bounded by `NOTIFY_FANOUT_CAP`, which logs when it bites rather than truncating
silently -- a shortened fan-out otherwise reads as "everyone knows". The
existing per-recipient budgets absorb the volume from there.
Wording is unchanged: "shared N items with you", not the team name.
A member who joins later resolves the grant through the scan but is not
retroactively told; announcements describe a moment, not a state.
Covers PUT-1726, PUT-1727 and PUT-1729. Together because a recipient without
the listing work produces a share that is created correctly, resolves
correctly, and never appears in "Shared with me" — a state nobody would ship.
The recipient. `ShareRecipient` gains `team` (uid) and `teamHandle`, resolved
before the email and username branches and never falling through to them.
Passing both is an error rather than a precedence rule, so a call site always
shows which was chosen: handles are released on soft delete and can be
reclaimed by an unrelated team, and a scripted share to a handle would
silently retarget. There is no bare-string spelling, since that would change
how existing strings are interpreted.
ACLService.setUserGroup mirrors setUserUser: same read-modify-write, same
one-mode-per-node rule, under a node lock keyed on the group.
One grant against the team, not one per member, so membership changes
apply without touching the grant and a share spends one unit of the daily
quota however many members there are. A member who joins afterwards gets
access, which is asserted.
The index row carries `holder_group_id` and leaves `holder_user_id` NULL, so
`0077`'s group index constrains it rather than the user-holder one.
Listing. `listByHolder` and `countByHolder` union the caller's teams into
the same keyset page — `ORDER BY id` still holds — and `#grantEvidence` gains
group grants as a third source. Without that third source the share is
filtered out of every listing as dead: nothing errors, the share simply is not
there, which is the one place a user would look for it.
Unsharing revokes the grant as well as deleting the index row. Deleting the
row alone would hide the share while leaving every member holding real access.
Closes PUT-1726, PUT-1727 and PUT-1729.
Two tickets, together because the fixture has no behaviour of its own — the
grant tests are its first real use, and "a grant to team A must not
resolve for B's members" is exactly a two-team assertion.
PUT-1719. Every query resolving team-scoped data has to take the
team as a parameter rather than infer it. One that forgets returns the
right rows against a database holding a single team, so a one-team
fixture does not give weaker coverage — it gives false confidence.
The fixture provides two teams, each with its own master and two
activated seats, plus a user in neither. Each team having its own master
is also what the shipped `max_teams_per_user: 1` requires, so it runs
against the real cap rather than lifting it the way the existing team suites
have to. Seats are activated before use: provisioning leaves
`requires_email_confirmation` set and `requireVerified` rejects them outright,
so an unactivated seat cannot call anything and every authorization assertion
built on one would be vacuous.
PUT-1725. The write half of group grants; the read half already worked, since
`#scanUserGroup` joins `jct_user_group` itself and resolves for whoever is a
member at scan time.
PermissionStore resolveGroupId, listGroupMemberUuids,
upsertUserGroupPerm, deleteUserGroupPerm,
auditUserGroupPerm
PermissionService grantUserGroupPermission, revokeUserGroupPermission
Both rewrite the permission first, so `fs:/path:mode` collapses to
`fs:<uuid>:mode` and a revoke names the same string the grant wrote; both gate
on `canManagePermission`; both audit into the existing
`audit_user_to_group_permissions`.
The delete is scoped to the issuer, so one issuer's revoke cannot drop
another's identical grant — the PK is (user_id, group_id, permission) and
user_id is the issuer.
Cache invalidation goes through `bumpCacheGenerations` with every member uuid
at once. That already announces to peer regions in a single event, so the
fan-out costs one broadcast rather than one per member.
There is no flat-KV equivalent for group grants, so unlike the user-to-user
path there is no second write to keep consistent and no second invalidation
surface.
Closes PUT-1719 and PUT-1725.