feat: a team seat needs no email address, and is never asked to confirm 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.
This commit is contained in:
Juan Castro
2026-09-10 15:55:50 -04:00
parent a8a78736bb
commit b19b312e30
8 changed files with 127 additions and 21 deletions
+6 -3
View File
@@ -28,9 +28,13 @@ The team's identifier.
The username for the new account. Usernames come from the same pool as ordinary sign-ups, so it must be free across the whole of Puter.
#### `options.email` (String) (required)
#### `options.email` (String) (optional)
The address the member is reachable at. It must not already own an account. The address came from the administrator rather than its holder, so the account is created needing email confirmation.
Where the team's notices about this account are delivered. These accounts sign in by **username**, so an address is not needed and the form does not ask for one.
Supply it only if you want `team_account_created`, `team_account_disabled` and `team_password_reset` to reach the member; if you leave it out, those notices are simply not sent and the temporary password in the return value is the only delivery. If given, it must not already own an account.
The account is never asked to confirm the address — the team creating it is the trust anchor — so it can be used immediately either way. An account with no address is recoverable only through its team's owner, via `resetPassword`.
## Return value
@@ -53,7 +57,6 @@ Rejects with `username_already_in_use` — with free alternatives in `fields.sug
const name = 'member' + Math.random().toString(36).slice(2, 8);
const account = await puter.teams.createMember(team.uid, {
username: name,
email: `${name}@example.com`,
});
// Shown once; hand it over out of band.
puter.print(`${account.username}: ${account.temporaryPassword}`);