Skip to content

πŸ—οΈ PUT-1704 + PUT-1707: TeamService β€” workspace lifecycle, disable and re-enable - #3712

Merged
jfcastro92 merged 1 commit into
juancastro/put-1703-22-teamstore-membership-reads-and-writes-single-writerfrom
juancastro/put-1704-23-teamservice-create-a-workspace-and-admit-the-master
Sep 3, 2026
Merged

πŸ—οΈ PUT-1704 + PUT-1707: TeamService β€” workspace lifecycle, disable and re-enable#3712
jfcastro92 merged 1 commit into
juancastro/put-1703-22-teamstore-membership-reads-and-writes-single-writerfrom
juancastro/put-1704-23-teamservice-create-a-workspace-and-admit-the-master

Conversation

@jfcastro92

@jfcastro92 jfcastro92 commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Sixth in the stack: #3704 β†’ #3705 β†’ #3708 β†’ #3709 β†’ #3710 β†’ this. Review bottom-up.

Covers PUT-1704 (create a workspace, admit the workspace owner) and PUT-1707 (disable and re-enable) together β€” both are TeamService methods and neither is large enough to review alone.

createWorkspace(ownerUserId, { name, handle? })
checkOwnerInvariant(teamUid)

requireMembership(teamUid, actorUserId)     -> 404 to a stranger
requireOwner(teamUid, actorUserId)         -> 403 to a non-master member
requireOrgAccount(teamUid, targetUserId)    -> 404 for the workspace owner

disableMember(teamUid, actorUserId, targetUserId)
enableMember(teamUid, actorUserId, targetUserId)

⚠ A spec bug worth reading before the code

PUT-1707 said to set user.suspended_at / suspended_reason (0061, 0063). Those are real columns β€” but neither of them enforces anything:

// core/http/middleware/userProtected.ts:175
if (user.suspended)
    throw new HttpError(403, 'Account is suspended', { … });

The enforced column is the older boolean suspended from 0001. suspended_at and suspended_reason are its siblings; 0061's own comment calls it "the timestamp sibling of the existing boolean suspended flag".

Implemented literally, disable would have written a perfect record of an offboarding that never happened β€” and since this design has no remove-from-workspace, no file transfer and no retention clock, disable is the entire offboarding control. All three columns are now written together, and the test is named for it: sets the column the request gate actually reads. Ticket and design doc corrected.

The invariant no dialect can express

createWorkspace admits the creator with org_owned = 0. That single value is what makes the workspace owner pay for itself and stay an invalid target of every member route.

checkOwnerInvariant asserts what the schema cannot: the owner is a member with org_owned = 0, and is the only such member. There's a test that deliberately breaks it by admitting a second org_owned = 0 account, because nothing in any dialect refuses that β€” which is the reason the check exists at all.

Authority

Three checks, and between them the whole authorization model:

Caller Result
Not a member 404 team_not_found β€” so the endpoint is not an existence oracle
Member, not the owner 403 not_the_workspace_owner
The master, targeting itself 404 not_an_org_account β€” org_owned is tested explicitly, never inferred from NULL

Adds those three values to the HttpError legacy-code union, which is a closed type. PUT-1708's routes need the same three.

What is deliberately not here

Stated rather than buried, since PUT-1707 lists them:

Deferred To
Billing events on disable / re-enable phase 3, PUT-1712
invalidateActorSubscription phase 3
Audit rows for both operations PUT-1708, which owns audit writes
outer.gui.* push + member notification needs a new event type and socket fan-out β€” design work belonging with the controller/GUI phase

Verification

$ npx vitest run --config src/backend/vitest.config.ts src/backend/services/team/
 Test Files  1 passed (1)
      Tests  13 passed (13)

$ PUTER_TEST_DB_ENGINE=postgres npx vitest run --config src/backend/vitest.config.ts src/backend/services/team/
 Test Files  1 passed (1)
      Tests  13 passed (13)

$ npm run test:backend
 Test Files  252 passed | 24 skipped (276)
      Tests  6743 passed | 26 skipped (6769)      # +13, no regressions

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

The disable tests assert the observable consequences rather than the write: sessions are gone, fsentries are untouched, re-enable clears all three columns, the workspace owner cannot be disabled even by itself, a non-master's disable order is refused, and a member of another workspace cannot be reached.

A detail that cost six red tests

HttpError exposes statusCode, not status. My first assertions used toMatchObject({ status: 404 }), which fails against every HttpError regardless of what it actually contains β€” so all six rejection tests failed while the seven behavioural ones passed. Worth knowing before writing the controller tests in PUT-1708, where nearly every assertion is a rejection.


Closes PUT-1704 and PUT-1707.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
πŸ”΅ Lines 93.93%
⬇️ -0.04%
27450 / 29222
πŸ”΅ Statements 92.04%
⬇️ -0.03%
29753 / 32326
πŸ”΅ Functions 90.4%
⬆️ +0.01%
4938 / 5462
πŸ”΅ Branches 80.89%
⬇️ -0.02%
19789 / 24462
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/backend/core/http/HttpError.ts 100%
🟰 ±0%
88.88%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/services/index.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/services/team/TeamService.ts 68.11% 54.76% 84.61% 71.87% 81-83, 86-88, 97-130, 159-162, 170, 176
Generated in workflow #1425 for commit aad4994 by the Vitest Coverage Report Action

@jfcastro92
jfcastro92 force-pushed the juancastro/put-1704-23-teamservice-create-a-workspace-and-admit-the-master branch from 4dfb179 to d4e8d74 Compare September 1, 2026 20:09
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1704-23-teamservice-create-a-workspace-and-admit-the-master branch from d4e8d74 to 05dade4 Compare September 1, 2026 21:13
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1704-23-teamservice-create-a-workspace-and-admit-the-master branch from 05dade4 to be53b88 Compare September 1, 2026 21:36
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1704-23-teamservice-create-a-workspace-and-admit-the-master branch from be53b88 to 246ecda Compare September 1, 2026 22:04
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1704-23-teamservice-create-a-workspace-and-admit-the-master branch from 246ecda to 3fffcce Compare September 1, 2026 22:51
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1704-23-teamservice-create-a-workspace-and-admit-the-master branch from 3fffcce to 008a236 Compare September 2, 2026 16:05
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1704-23-teamservice-create-a-workspace-and-admit-the-master branch from 008a236 to c769ca2 Compare September 2, 2026 16:51
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1704-23-teamservice-create-a-workspace-and-admit-the-master branch from c769ca2 to 0330cf3 Compare September 2, 2026 19:32
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1704-23-teamservice-create-a-workspace-and-admit-the-master branch from 0330cf3 to bf907a0 Compare September 2, 2026 19:39
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1704-23-teamservice-create-a-workspace-and-admit-the-master branch from bf907a0 to d304b15 Compare September 2, 2026 21:37

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

I'm a bit unsure about whether or not we should use suspended column for this. We've primarily used it for abuse prevention, so might cause some unwanted effects in gui/ prod metrics.

that said, the reason field might be enough to have that not be a problem, but well need to start checking and exposing that reason (only if its disabled by workspace).

i leave it to you to decide, but just make sure that this suspended reason is handled properly everywhre

@jfcastro92
jfcastro92 force-pushed the juancastro/put-1704-23-teamservice-create-a-workspace-and-admit-the-master branch from d304b15 to f8794e6 Compare September 3, 2026 13:46
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1704-23-teamservice-create-a-workspace-and-admit-the-master branch from f8794e6 to 0af13b3 Compare September 3, 2026 14:30
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1704-23-teamservice-create-a-workspace-and-admit-the-master branch from 0af13b3 to ba83e5b Compare September 3, 2026 15:42
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1704-23-teamservice-create-a-workspace-and-admit-the-master branch from ba83e5b to 1a4c526 Compare September 3, 2026 18:56
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1704-23-teamservice-create-a-workspace-and-admit-the-master branch from 1a4c526 to 765ae53 Compare September 3, 2026 19:07
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1704-23-teamservice-create-a-workspace-and-admit-the-master branch from 765ae53 to f9d9585 Compare September 3, 2026 19:17
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1704-23-teamservice-create-a-workspace-and-admit-the-master branch from f9d9585 to aad4994 Compare September 3, 2026 19:20
@jfcastro92

Copy link
Copy Markdown
Collaborator Author

Local validation

Engine: mysql 8 Β· teams_enabled: true

Disable suspends the account and kills the live session

before   suspended=NULL   /whoami -> 200
POST /teams/<uid>/members/acmeseat1/disable -> 200 {"success":true}
after    suspended=1      /whoami -> 401

Re-enable clears suspension, and deliberately does not resurrect the old
session

POST /teams/<uid>/members/acmeseat1/enable -> 200 {"success":true}
after    suspended=0      /whoami (old token) -> 401

The old token staying dead is the right behaviour β€” re-enabling an account should
not revive sessions that were revoked when it was disabled. A fresh sign-in works:

login as acmeseat1 -> 200, new session token
/whoami with it    -> 200

Both transitions are audited, with actor attribution:

{"action":"enable",  "username":"acmeseat1","actor_username":"tmowner","created_at":"...23:29:40.000Z"}
{"action":"disable", "username":"acmeseat1","actor_username":"tmowner","created_at":"...23:29:39.000Z"}

Lifecycle behaves as described, and the disable β†’ re-enable β†’ fresh-login round
trip works end to end against a real database and a real session store.

Covers PUT-1704 and PUT-1707: creating a workspace, admitting the master
account, and the whole of offboarding.

`createWorkspace` admits the creator with org_owned = 0, which is what makes
the master pay for itself and stay an invalid target of every member route.
`checkOwnerInvariant` asserts the rule no dialect can express -- the owner is
a member with org_owned = 0 and the only such member -- and a test breaks it
deliberately, since the schema cannot refuse a second one.

Three authority checks: 404 to a stranger so the endpoint is not an existence
oracle, 403 to a member who is not the master, and the master refused as a
target of member routes.

Handle problems surface as 400 (unusable) or 409 (taken), including the
unique-index race. `TeamStore` throws a bare Error, which the server would turn
into a 500 and a deduped critical alarm -- an uppercase handle should not page
on-call.

Disable writes `user.suspended` as well as suspended_at and suspended_reason.
PUT-1707 named only the latter two, but those are siblings added by 0061 and
0063 -- `userProtected` rejects on `if (user.suspended)` and reads neither.
Setting only the timestamp and reason would have recorded a disable that never
took effect, and disable is the whole of offboarding here.

Sessions are dropped through SessionStore.removeByUuid rather than a raw
DELETE. The store invalidates every composite cache key with its double-delete
pattern; without that a disabled member keeps authenticating from cache for the
session TTL, which is exactly the "next request, not after a cache TTL"
property disable is supposed to have. Revoking also preserves last_ip and
last_user_agent, which the member-facing audit view reads.

Files are untouched and re-enable restores the account.

Adds team_not_found, not_the_master_account and not_an_org_account to the
HttpError legacy codes, which the controller also needs.

Billing events, invalidateActorSubscription, audit rows and the GUI push are
deliberately not here -- they belong to phase 3 and PUT-1708.
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1704-23-teamservice-create-a-workspace-and-admit-the-master branch from aad4994 to da2b42d Compare September 3, 2026 20:02
@jfcastro92
jfcastro92 merged commit d855315 into main Sep 3, 2026
3 of 5 checks 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