Skip to content

πŸ—οΈ PUT-1705: TeamService β€” provision an org account - #3713

Merged
jfcastro92 merged 1 commit into
juancastro/put-1704-23-teamservice-create-a-workspace-and-admit-the-masterfrom
juancastro/put-1705-24-teamservice-provision-an-org-account
Sep 3, 2026
Merged

πŸ—οΈ PUT-1705: TeamService β€” provision an org account#3713
jfcastro92 merged 1 commit into
juancastro/put-1704-23-teamservice-create-a-workspace-and-admit-the-masterfrom
juancastro/put-1705-24-teamservice-provision-an-org-account

Conversation

@jfcastro92

@jfcastro92 jfcastro92 commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

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

PUT-1705 on its own. This is the operation the feature exists for, it creates real user accounts and sends mail, so it doesn't share a review with anything.

provisionAccount(teamUid, actorUserId, { username, email })
    -> { userId, username, activationLink }
suggestUsernames(username, count?)
resendActivation(teamUid, actorUserId, targetUserId)

Activation reuses password recovery

No new token machinery, no team_activation table, no unauthenticated endpoint on the team surface. Provisioning sets the same pass_recovery_token that /send-pass-recovery-email uses, signs the same one-hour otp JWT with purpose: 'pass-recovery', and hands back the same /action/set-new-password link.

That inherits, for free: a one-shot token cleared on use, expiry carried in a signed JWT rather than a DB column, atomic single-use consumption, purpose scoping so the link can't be replayed for another operation, and refusal for suspended accounts.

Activation state needs no column either. An unactivated account is one with no password β€” the predicate migration 0066 already uses. resendActivation refuses an account that has one, because re-sending then would be a password reset wearing the wrong name.

Usernames come from the global pool

Usernames are globally unique and case-insensitive (idx_user_username_nocase, 0055), so a workspace provisions from the same namespace as every other Puter user. Two companies cannot both have a juan.

What provisioning adds is a check and a suggestion β€” never a silent modification. A suffixed juan-castro-2 would appear in every share dialog that person ever sees, and they never agreed to it. So a taken name is refused with username_already_in_use plus alternatives that have been verified free, and the administrator chooses.

⚠ The check runs before any write. A taken name failing part-way through account setup would leave an orphaned user row and a half-provisioned account. There's a test asserting the workspace's member count is unchanged after a rejected provision.

Two smaller decisions

requires_email_confirmation is set on the new account. The address came from the administrator, not its holder, so it is unverified by definition β€” and Β§5.5 later depends on that address being the member's, which makes confirming it load-bearing rather than tidy.

The team_account_activation template states what the workspace can and cannot do:

  • This account belongs to {{team_name}}. They pay for it and can close it.
  • {{team_name}} cannot see your files, apps or data.
  • {{team_name}} can reset your password. You will be emailed each time that happens, and every reset is recorded where you can see it.

That last line is required, not padding. Stating only that admins cannot see the contents would describe a guarantee the system does not make.

The equivalent disclosure on the set-password screen is phase 6, since that's GUI.


Verification

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

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

$ npm run test:backend
 Test Files  252 passed | 24 skipped (276)
      Tests  6751 passed | 26 skipped (6777)      # +8, no regressions

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

Eight tests added: the account is created password-less and owned by the workspace, it gets its filesystem tree (trash_uuid set, which is also generateDefaultFsentries' own idempotency guard), the activation link is returned and a recovery token stored, a taken username writes nothing, suggestions are verified free and never equal the taken name, a non-master is refused, re-issuing rotates the token, and an activated account cannot be re-sent.

Not here

Deferred To
user.free_storage stamped from a team policy deleted, not deferred β€” a seat is an ordinary account on the common tier, so its ceiling is config.storage_capacity like any other. See TEAMS-BILLING-SPLIT.md
The billing event that starts the per-account charge phase 3, PUT-1712
Audit row for provision PUT-1708
Disclosure on the set-password screen phase 6

Closes PUT-1705.

@jfcastro92
jfcastro92 force-pushed the juancastro/put-1705-24-teamservice-provision-an-org-account branch from c121eae to 9e12175 Compare September 1, 2026 20:09
@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%
27502 / 29278
πŸ”΅ Statements 92.03%
⬇️ -0.05%
29808 / 32386
πŸ”΅ Functions 90.42%
⬆️ +0.03%
4946 / 5470
πŸ”΅ Branches 80.9%
⬇️ -0.01%
19819 / 24498
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/backend/clients/email/templates.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/controllers/auth/AuthController.ts 90.78%
⬆️ +0.01%
79.25%
⬆️ +0.02%
77.77%
🟰 ±0%
91.73%
⬆️ +0.01%
6, 24-26, 32, 33, 34, 38-39, 44, 45, 47, 474-476, 490-492, 620-621, 695-697, 714-716, 858, 920-924, 942, 984-989, 998, 1009, 1024-1029, 1035, 1079-1080, 1083-1088, 1231, 1262-1263, 1282-1286, 1324-1326, 1328-1330, 1378, 1406-1417, 1492-1494, 1507, 1567-1569, 1603, 1616-1618, 1644-1646, 1677, 1681-1683, 1781, 1867, 1926, 1971, 2030, 2043-2046, 2088-2090, 2108-2111, 2165-2167, 2179-2181, 2197-2199, 2213-2216, 2222, 2241, 2252, 2373-2377, 2406, 2437-2439, 2451-2457, 2517-2522, 2596-2598, 2661, 2699, 2740-2742, 2808, 2838, 2917-2919, 2921-2923, 2927-2930, 2943-2946, 3011-3016, 3019-3021, 3048, 3067, 3085, 3103, 3116, 3139, 3151, 3168, 3178, 3189, 3243, 3376-3380, 3402-3404, 3418, 3421-3425, 3500
src/backend/services/team/TeamService.ts 77.77% 65.33% 90% 81.19% 107-109, 112-114, 123-156, 185-188, 196, 202, 239, 299-302, 334-336, 356, 371
Generated in workflow #1446 for commit 673f7fe by the Vitest Coverage Report Action

@jfcastro92
jfcastro92 force-pushed the juancastro/put-1705-24-teamservice-provision-an-org-account branch from 9e12175 to 3916dd5 Compare September 1, 2026 21:14
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1705-24-teamservice-provision-an-org-account branch from 3916dd5 to b138c3d Compare September 1, 2026 21:36
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1705-24-teamservice-provision-an-org-account branch 2 times, most recently from 7ade8d3 to bbf43f2 Compare September 1, 2026 22:27
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1705-24-teamservice-provision-an-org-account branch from bbf43f2 to 28920a1 Compare September 1, 2026 22:51
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1705-24-teamservice-provision-an-org-account branch from 28920a1 to d6fdc5e Compare September 2, 2026 16:05
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1705-24-teamservice-provision-an-org-account branch from d6fdc5e to 99fbef5 Compare September 2, 2026 16:51
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1705-24-teamservice-provision-an-org-account branch from 99fbef5 to 9be96b6 Compare September 2, 2026 19:32
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1705-24-teamservice-provision-an-org-account branch from 9be96b6 to 1e8bef8 Compare September 2, 2026 19:39
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1705-24-teamservice-provision-an-org-account branch from 1e8bef8 to b73b048 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.

one comment there

Comment thread src/backend/controllers/auth/AuthController.ts Outdated
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1705-24-teamservice-provision-an-org-account branch from b73b048 to d05627d Compare September 3, 2026 13:46
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1705-24-teamservice-provision-an-org-account branch from d05627d to 6192c79 Compare September 3, 2026 14:30
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1705-24-teamservice-provision-an-org-account branch from 6192c79 to 917531d Compare September 3, 2026 15:42
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1705-24-teamservice-provision-an-org-account branch from 917531d to 01df2b6 Compare September 3, 2026 18:56
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1705-24-teamservice-provision-an-org-account branch from 01df2b6 to eaac8cd Compare September 3, 2026 19:07
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1705-24-teamservice-provision-an-org-account branch from eaac8cd to e885ed2 Compare September 3, 2026 19:17
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1705-24-teamservice-provision-an-org-account branch from e885ed2 to f3547cb Compare September 3, 2026 19:20
@jfcastro92

Copy link
Copy Markdown
Collaborator Author

Local validation

Engine: mysql 8 Β· teams_enabled: true Β· max_seats_per_workspace: 3

Provisioning three seats, then hitting the cap

seat1 [HTTP 200] {"username": "acmeseat1", "temporary_password": "<redacted len=16>"}
seat2 [HTTP 200] {"username": "acmeseat2", "temporary_password": "<redacted len=16>"}
seat3 [HTTP 200] {"username": "acmeseat3", "temporary_password": "<redacted len=16>"}
seat4 [HTTP 409] {"error": "This workspace is limited to 3 seats",
                  "code": "seat_limit_reached", "limit": 3}

The rows that result β€” seats are org_owned=1, the owner is not:

username     email_confirmed  req_email  req_pw  suspended  org_owned
tmowner            1              0        0       NULL         0
acmeseat1          0              1        1       NULL         1
acmeseat2          0              1        1       NULL         1
acmeseat3          0              1        1       NULL         1

Provisioning mail actually delivered (mailhog, real SMTP β€” not a spy), carrying the
workspace's current name:

to=['acmeseat3@example.test'] subj='Your Acme Renamed account on Puter'
to=['acmeseat2@example.test'] subj='Your Acme Renamed account on Puter'
to=['acmeseat1@example.test'] subj='Your Acme Renamed account on Puter'

Body confirms no credential is included, as intended β€” the password goes out of band.

Credential reissue returns a fresh one-time password

POST /teams/:uid/members/acmeseat1/activation -> 200, temporary_password len=16
login as acmeseat1 with it                    -> 200, session token issued

Provisioning, the seat cap, org_owned, and the mail path all behave as described.


⚠ One follow-up, tracked in PUT-1745 rather than here: reissueCredential's
requires_password_change guard does not currently become effective, so the
"already activated" 409 is not reachable and reissue is not rate-bounded by
activation state. Detail and suggested Done-when changes are on that ticket. It
is not reachable in any deployment β€” teams_enabled is false by default.

@jfcastro92
jfcastro92 force-pushed the juancastro/put-1705-24-teamservice-provision-an-org-account branch from f3547cb to f026da6 Compare September 3, 2026 19:45
Covers PUT-1705. The master account supplies { username, email }; the account
is created with no password, gets the default filesystem tree, joins with
org_owned = 1, and receives a one-shot activation link.

Activation reuses password recovery rather than new token machinery: the same
pass_recovery_token, the same one-hour purpose-scoped JWT, the same
/action/set-new-password link. No team_activation table, no new token type,
and no unauthenticated endpoint on the team surface. Activation state needs no
column either -- an unactivated account is one with no password.

Applies the same username and email rules as signup rather than its own:
USERNAME_REGEX, USERNAME_MAX_LENGTH, RESERVED_USERNAMES and validator.isEmail,
now exported from AuthController. Without them a workspace could mint accounts
signup would refuse -- the username becomes the /username home-directory
segment -- claim unregistered reserved names, and send activation mail to
arbitrary unvalidated addresses at the route's daily limit.

Usernames come from Puter's global pool, so a taken one is refused with free
alternatives rather than silently modified: a suffixed name would appear in
every share dialog that person ever sees, and they never agreed to it. The
check runs before any write, so a rejected provision leaves no orphaned user
row -- asserted by a test on the workspace's member count.

The new account carries requires_email_confirmation, since the address came
from the administrator rather than its holder.

Adds a team_account_activation email template stating what the workspace can
and cannot do -- including that it can reset the password, which the design
requires be said rather than only claiming files are private.

free_storage stamping and the billing event are phase 3.
@jfcastro92
jfcastro92 force-pushed the juancastro/put-1705-24-teamservice-provision-an-org-account branch from f026da6 to 673f7fe Compare September 3, 2026 20:02
@jfcastro92
jfcastro92 merged commit d855315 into main Sep 3, 2026
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