From afb5b0d82aba570c497fb68baee8ce920d92f01c Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Thu, 3 Sep 2026 18:45:59 -0400 Subject: [PATCH] feat: add the `puter.teams` SDK module 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. --- src/docs/src/Teams.md | 156 +++++++++++ src/docs/src/Teams/create.md | 54 ++++ src/docs/src/Teams/createMember.md | 64 +++++ src/docs/src/Teams/delete.md | 49 ++++ src/docs/src/Teams/deleteMemberAccount.md | 66 +++++ src/docs/src/Teams/disableMember.md | 59 ++++ src/docs/src/Teams/enableMember.md | 53 ++++ src/docs/src/Teams/get.md | 47 ++++ src/docs/src/Teams/list.md | 58 ++++ src/docs/src/Teams/listAudit.md | 55 ++++ src/docs/src/Teams/listMembers.md | 53 ++++ src/docs/src/Teams/listOwnAudit.md | 55 ++++ src/docs/src/Teams/resendActivation.md | 63 +++++ src/docs/src/Teams/resetPassword.md | 74 +++++ src/docs/src/Teams/update.md | 59 ++++ src/docs/src/rate-limits-and-quotas.md | 4 + src/docs/src/sidebar.js | 121 ++++++++ src/puter-js/index.d.ts | 12 + src/puter-js/src/index.js | 4 + src/puter-js/src/modules/teams/create.js | 22 ++ .../src/modules/teams/createMember.js | 35 +++ src/puter-js/src/modules/teams/delete.js | 15 + .../src/modules/teams/deleteMemberAccount.js | 25 ++ .../src/modules/teams/disableMember.js | 21 ++ .../src/modules/teams/enableMember.js | 18 ++ src/puter-js/src/modules/teams/get.js | 14 + src/puter-js/src/modules/teams/index.js | 87 ++++++ .../src/modules/teams/lib/listRoute.js | 61 +++++ src/puter-js/src/modules/teams/lib/req.js | 77 ++++++ .../src/modules/teams/lib/req.test.js | 82 ++++++ src/puter-js/src/modules/teams/lib/shapes.js | 72 +++++ src/puter-js/src/modules/teams/list.js | 40 +++ src/puter-js/src/modules/teams/listAudit.js | 41 +++ src/puter-js/src/modules/teams/listMembers.js | 41 +++ .../src/modules/teams/listOwnAudit.js | 41 +++ .../src/modules/teams/resendActivation.js | 32 +++ .../src/modules/teams/resetPassword.js | 34 +++ src/puter-js/src/modules/teams/teams.test.js | 258 ++++++++++++++++++ src/puter-js/src/modules/teams/types.js | 71 +++++ src/puter-js/src/modules/teams/update.js | 22 ++ .../tests/api/harness/capabilities.ts | 7 + src/puter-js/tests/api/suites/index.ts | 2 + src/puter-js/tests/api/suites/teams.suite.ts | 226 +++++++++++++++ 43 files changed, 2450 insertions(+) create mode 100644 src/docs/src/Teams.md create mode 100644 src/docs/src/Teams/create.md create mode 100644 src/docs/src/Teams/createMember.md create mode 100644 src/docs/src/Teams/delete.md create mode 100644 src/docs/src/Teams/deleteMemberAccount.md create mode 100644 src/docs/src/Teams/disableMember.md create mode 100644 src/docs/src/Teams/enableMember.md create mode 100644 src/docs/src/Teams/get.md create mode 100644 src/docs/src/Teams/list.md create mode 100644 src/docs/src/Teams/listAudit.md create mode 100644 src/docs/src/Teams/listMembers.md create mode 100644 src/docs/src/Teams/listOwnAudit.md create mode 100644 src/docs/src/Teams/resendActivation.md create mode 100644 src/docs/src/Teams/resetPassword.md create mode 100644 src/docs/src/Teams/update.md create mode 100644 src/puter-js/src/modules/teams/create.js create mode 100644 src/puter-js/src/modules/teams/createMember.js create mode 100644 src/puter-js/src/modules/teams/delete.js create mode 100644 src/puter-js/src/modules/teams/deleteMemberAccount.js create mode 100644 src/puter-js/src/modules/teams/disableMember.js create mode 100644 src/puter-js/src/modules/teams/enableMember.js create mode 100644 src/puter-js/src/modules/teams/get.js create mode 100644 src/puter-js/src/modules/teams/index.js create mode 100644 src/puter-js/src/modules/teams/lib/listRoute.js create mode 100644 src/puter-js/src/modules/teams/lib/req.js create mode 100644 src/puter-js/src/modules/teams/lib/req.test.js create mode 100644 src/puter-js/src/modules/teams/lib/shapes.js create mode 100644 src/puter-js/src/modules/teams/list.js create mode 100644 src/puter-js/src/modules/teams/listAudit.js create mode 100644 src/puter-js/src/modules/teams/listMembers.js create mode 100644 src/puter-js/src/modules/teams/listOwnAudit.js create mode 100644 src/puter-js/src/modules/teams/resendActivation.js create mode 100644 src/puter-js/src/modules/teams/resetPassword.js create mode 100644 src/puter-js/src/modules/teams/teams.test.js create mode 100644 src/puter-js/src/modules/teams/types.js create mode 100644 src/puter-js/src/modules/teams/update.js create mode 100644 src/puter-js/tests/api/suites/teams.suite.ts diff --git a/src/docs/src/Teams.md b/src/docs/src/Teams.md new file mode 100644 index 000000000..852b3db2a --- /dev/null +++ b/src/docs/src/Teams.md @@ -0,0 +1,156 @@ +--- +title: Teams +description: Administer a Puter team and the accounts it pays for with the Teams API +platforms: [websites, apps] +--- + +
The Teams API is in beta. Method shapes, limits, and behavior may change between releases.
+ +A team is an account that pays for other accounts. One owner account creates it, provisions member accounts, and can suspend or restore them. Members are ordinary Puter accounts — there are no roles to assign, and the team never gains access to a member's files. + +`puter.teams` is the administrative surface for that. Every method takes a team `uid`. + +```js +const team = await puter.teams.create({ name: 'Acme', handle: 'acme' }); +await puter.teams.createMember(team.uid, { username: 'ann', email: 'ann@example.com' }); +const members = await puter.teams.listMembers(team.uid); +``` + +## Availability + +Teams are an opt-in deployment feature. Where they are turned off, the routes behind `puter.teams` do not exist and every method rejects with `not_found`. + +`puter.teams.list()` is how an app tells the two apart: it rejects when the feature is off, and resolves to an empty array when it is on and the caller has no team. + +```js +let teams = []; +try { + teams = await puter.teams.list(); +} catch (e) { + // Teams are unavailable here; show nothing. +} +``` + +## `uid`, not `handle` + +A team has both a `uid` and an optional `handle`. Only the `uid` is stable. + +A `handle` is a label: [`update()`](/Teams/update/) can change it, and deleting the team releases it for anyone else to take. A stored handle can therefore stop resolving — or, worse, start resolving to a different team. Display the `name` and `handle`; pass the `uid`. + +## Methods + +### Teams + +| Method | Who can call it | +| -- | -- | +| [`create(options)`](/Teams/create/) | Any verified account | +| [`list(options)`](/Teams/list/) | Any member, for their own teams | +| [`get(uid)`](/Teams/get/) | Any member | +| [`update(uid, attributes)`](/Teams/update/) | Owner account | +| [`delete(uid)`](/Teams/delete/) | Owner account | + +### Accounts + +| Method | Who can call it | +| -- | -- | +| [`listMembers(uid, options)`](/Teams/listMembers/) | Any member | +| [`createMember(uid, options)`](/Teams/createMember/) | Owner account | +| [`resendActivation(uid, username)`](/Teams/resendActivation/) | Owner account | +| [`disableMember(uid, username)`](/Teams/disableMember/) | Owner account | +| [`enableMember(uid, username)`](/Teams/enableMember/) | Owner account | +| [`resetPassword(uid, username)`](/Teams/resetPassword/) | Owner account | +| [`deleteMemberAccount(uid, username)`](/Teams/deleteMemberAccount/) | Owner account | + +### Audit + +| Method | Who can call it | +| -- | -- | +| [`listAudit(uid, options)`](/Teams/listAudit/) | Owner account | +| [`listOwnAudit(uid, options)`](/Teams/listOwnAudit/) | Any member, for their own entries | + +## Pagination + +`list()`, `listMembers()`, `listAudit()` and `listOwnAudit()` all take the same options and offer the same three forms: + +| Call | Resolves to | +| -- | -- | +| No options | The whole set as an array, fetched page by page under the hood | +| `{ cursor }` or `{ includeTotal: true }` | One `{ items, cursor? }` page. `cursor` is absent on the last page | +| `{ stream: true }` | An async iterator of `{ items, cursor? }` pages | + +`{ limit }` on its own still resolves to an array, capped at one page. + +These routes are keyset-paginated, so `offset` is not accepted — passing it throws `invalid_request`. Pass `cursor` to resume from a position. + +```js +// Every member, however many pages it takes. +const all = await puter.teams.listMembers(uid); + +// One page at a time. +let cursor = null; +do { + const page = await puter.teams.listMembers(uid, { limit: 50, cursor }); + cursor = page.cursor; +} while (cursor); + +// Or as a stream. +for await (const page of puter.teams.listMembers(uid, { stream: true })) { + console.log(page.items); +} +``` + +## Objects + +#### `Team` + +| Field | Type | Description | +| -- | -- | -- | +| `uid` | `string` | The team's stable identifier. | +| `name` | `string \| null` | Its display name. | +| `handle` | `string \| null` | Its short handle, unique while it exists. | +| `isOwner` | `boolean` | Whether the caller is the owner account. | +| `createdAt` | `string` | When it was created. | + +#### `TeamMember` + +| Field | Type | Description | +| -- | -- | -- | +| `username` | `string` | The member's Puter username. | +| `orgOwned` | `boolean` | Whether the team provisioned and pays for this account. | +| `createdAt` | `string` | When the account joined the team. | + +#### `TeamAuditEntry` + +| Field | Type | Description | +| -- | -- | -- | +| `action` | `string` | What was done, e.g. `provision`, `disable`, `enable`, `delete_team`. | +| `reason` | `string \| null` | The reason recorded with the action, when one was given. | +| `username` | `string \| null` | The account it was about. | +| `actorUsername` | `string \| null` | Who did it. `null` when Puter itself did. | +| `createdAt` | `string` | When it happened. | + +## Errors + +Every method rejects with an `Error` carrying a stable `code`: + +| Code | Meaning | +| -- | -- | +| `invalid_request` | The call was refused before reaching the server — a missing `name`, a blank `uid`, an `offset` on a keyset list. | +| `bad_request` | The server refused the input, e.g. an invalid username or email. | +| `unauthorized` | Not signed in, or signing in with an app or API token rather than a user session. | +| `account_is_not_verified` | The caller's email has not been confirmed. Every `/teams` route requires it. | +| `permission_denied` | Signed in, but not the owner account of this team. | +| `not_found` | No such team, or teams are turned off on this deployment. | +| `team_not_found` | No such team, or the caller is not a member of it. | +| `not_an_org_account` | The named account is not a member of this team. | +| `conflict` | The account has already been activated, so its credential cannot be reissued. | +| `username_already_in_use` | The requested username is taken. The error carries `fields.suggestions` with free alternatives. | +| `email_already_in_use` | The address already owns an account. | +| `too_many_requests` | The rate limit was exceeded. See [Rate Limits & Quotas](/rate-limits-and-quotas/). | + +## What is deliberately absent + +- **No roles.** The owner account is the sole administrator; every other account is an ordinary member. +- **No way to change a member's email or username.** After activation a member changes their own email, which re-verifies the holder. An administrator able to move the address could redirect a credential to themselves. +- **No per-app usage breakdown.** Which applications a person uses is a fact about them, not about the bill. +- **No sharing-policy controls.** A team cannot restrict who its members share with: there is no external-sharing policy, no domain allowlist, and no control over public links. A member shares exactly as any other Puter user does, with anyone. This is the assumption most teams bring the other way round, so it is worth stating plainly before you rely on it. diff --git a/src/docs/src/Teams/create.md b/src/docs/src/Teams/create.md new file mode 100644 index 000000000..7308040c1 --- /dev/null +++ b/src/docs/src/Teams/create.md @@ -0,0 +1,54 @@ +--- +title: puter.teams.create() +description: Create a team that pays for other Puter accounts. +platforms: [websites, apps] +--- + +
The Teams API is in beta. Method shapes, limits, and behavior may change between releases.
+ +Creates a team owned by the caller, who becomes its owner account. + +The caller's email must be confirmed. Teams must be turned on for the deployment; where they are not, this rejects with `not_found`. + +## Syntax + +```js +puter.teams.create(options) +``` + +## Parameters + +#### `options.name` (String) (required) + +The team's display name. + +#### `options.handle` (String | null) (optional) + +A short handle, made of lowercase letters and digits separated by single hyphens, 3 to 64 characters. It must be free across the whole deployment, and a set of reserved words is refused. Omit it or pass `null` for none. + +## Return value + +A `Promise` that resolves to a [`Team`](/Teams/#team). + +Rejects with `invalid_request` if `name` is blank, `bad_request` if the handle is malformed or reserved, and `conflict` if the handle is taken. + +## Examples + +Create a team + +```html + + + + + + +``` diff --git a/src/docs/src/Teams/createMember.md b/src/docs/src/Teams/createMember.md new file mode 100644 index 000000000..0cd559b23 --- /dev/null +++ b/src/docs/src/Teams/createMember.md @@ -0,0 +1,64 @@ +--- +title: puter.teams.createMember() +description: Provision a new Puter account owned by a team. +platforms: [websites, apps] +--- + +
The Teams API is in beta. Method shapes, limits, and behavior may change between releases.
+ +Provisions a new Puter account that the team owns and pays for. Owner account only. + +There is no role to pick: every provisioned account is an ordinary member. + +**The password comes back once.** It is not stored anywhere retrievable and there is no second chance to read it — deliver it to the member out of band. The member must change it at first sign-in. If it is lost before then, [`resendActivation()`](/Teams/resendActivation/) issues a fresh one. + +## Syntax + +```js +puter.teams.createMember(uid, options) +``` + +## Parameters + +#### `uid` (String) (required) + +The team's identifier. + +#### `options.username` (String) (required) + +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) + +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. + +## Return value + +A `Promise` that resolves to `{ username, temporaryPassword }`. + +Rejects with `username_already_in_use` — with free alternatives in `fields.suggestions` — or `email_already_in_use`. + +## Examples + +Add an account to a team + +```html + + + + + + +``` diff --git a/src/docs/src/Teams/delete.md b/src/docs/src/Teams/delete.md new file mode 100644 index 000000000..f4c4091b6 --- /dev/null +++ b/src/docs/src/Teams/delete.md @@ -0,0 +1,49 @@ +--- +title: puter.teams.delete() +description: Delete a team. The accounts it paid for keep existing. +platforms: [websites, apps] +--- + +
The Teams API is in beta. Method shapes, limits, and behavior may change between releases.
+ +Deletes a team. Owner account only. + +**The accounts the team provisioned are not deleted.** What stops is the team paying for them, so the per-account charges end and the storage charges do not. Its handle is released, and its [audit log](/Teams/listAudit/) stays readable to the owner account afterwards. + +## Syntax + +```js +puter.teams.delete(uid) +``` + +## Parameters + +#### `uid` (String) (required) + +The team's identifier. + +## Return value + +A `Promise` that resolves to nothing once the team is gone. + +Rejects with `not_the_team_owner` if the caller is not the owner account. + +## Examples + +Create a team then delete it + +```html + + + + + + +``` diff --git a/src/docs/src/Teams/deleteMemberAccount.md b/src/docs/src/Teams/deleteMemberAccount.md new file mode 100644 index 000000000..2859aa7ca --- /dev/null +++ b/src/docs/src/Teams/deleteMemberAccount.md @@ -0,0 +1,66 @@ +--- +title: puter.teams.deleteMemberAccount() +description: Permanently remove an account a team owns. +platforms: [websites, apps] +--- + +
The Teams API is in beta. Method shapes, limits, and behavior may change between releases.
+ +Permanently removes an account the team owns. Its files are deleted, its username returns to the pool, and every credential is invalidated. Owner account only. + +**This is irreversible, and there is no restore window.** The account must already be suspended with [`disableMember()`](/Teams/disableMember/) — a live account is refused. That ordering is deliberate: it puts a reversible step in front of the only irreversible operation in the API, so nothing here deletes a working account in a single call. + +Disabling already stopped the per-account charge. This is what stops the charge for the bytes the account held, and it is the only thing that does. Nothing removes a suspended account on a timer — it persists, costing only its storage, until you ask for it to go. + +## Syntax + +```js +puter.teams.deleteMemberAccount(uid, username) +``` + +## Parameters + +#### `uid` (String) (required) + +The team's identifier. + +#### `username` (String) (required) + +The member's username. It must be an account this team provisioned, and it must already be suspended. + +## Return value + +A `Promise` that resolves to nothing once the account is gone. + +Rejects with `account_must_be_disabled_first` if the account is still live, `not_an_org_account` if it does not belong to this team, and `not_the_team_owner` if the caller is not the owner account. + +## Examples + +Suspend, then remove for good + +```html + + + + + + +``` diff --git a/src/docs/src/Teams/disableMember.md b/src/docs/src/Teams/disableMember.md new file mode 100644 index 000000000..151a59059 --- /dev/null +++ b/src/docs/src/Teams/disableMember.md @@ -0,0 +1,59 @@ +--- +title: puter.teams.disableMember() +description: Suspend an account a team owns. +platforms: [websites, apps] +--- + +
The Teams API is in beta. Method shapes, limits, and behavior may change between releases.
+ +Suspends an account the team owns, ending its sessions. Owner account only, and reversible with [`enableMember()`](/Teams/enableMember/). + +**A disabled account still costs the team money.** The per-account charge stops; the charge for the bytes it holds does not. To stop that, its files have to go. + +## Syntax + +```js +puter.teams.disableMember(uid, username) +``` + +## Parameters + +#### `uid` (String) (required) + +The team's identifier. + +#### `username` (String) (required) + +The member's username. It must be an account this team provisioned — a pre-existing account that joined cannot be suspended by the team. + +## Return value + +A `Promise` that resolves to nothing once the account is suspended. + +Rejects with `not_an_org_account` if the account does not belong to this team, and `not_the_team_owner` if the caller is not the owner account. + +## Examples + +Suspend and restore an account + +```html + + + + + + +``` diff --git a/src/docs/src/Teams/enableMember.md b/src/docs/src/Teams/enableMember.md new file mode 100644 index 000000000..0deac0e1c --- /dev/null +++ b/src/docs/src/Teams/enableMember.md @@ -0,0 +1,53 @@ +--- +title: puter.teams.enableMember() +description: Restore an account previously suspended. +platforms: [websites, apps] +--- + +
The Teams API is in beta. Method shapes, limits, and behavior may change between releases.
+ +Restores an account previously suspended with [`disableMember()`](/Teams/disableMember/). Owner account only. The account can sign in again and the team resumes paying its per-account charge. + +## Syntax + +```js +puter.teams.enableMember(uid, username) +``` + +## Parameters + +#### `uid` (String) (required) + +The team's identifier. + +#### `username` (String) (required) + +The member's username. + +## Return value + +A `Promise` that resolves to nothing once the account is restored. + +Rejects with `not_an_org_account` if the account does not belong to this team, and `not_the_team_owner` if the caller is not the owner account. + +## Examples + +Restore a suspended account + +```html + + + + + + +``` diff --git a/src/docs/src/Teams/get.md b/src/docs/src/Teams/get.md new file mode 100644 index 000000000..7659f8077 --- /dev/null +++ b/src/docs/src/Teams/get.md @@ -0,0 +1,47 @@ +--- +title: puter.teams.get() +description: Get one team by its uid. +platforms: [websites, apps] +--- + +
The Teams API is in beta. Method shapes, limits, and behavior may change between releases.
+ +Returns one team the caller belongs to. + +## Syntax + +```js +puter.teams.get(uid) +``` + +## Parameters + +#### `uid` (String) (required) + +The team's identifier, as returned by [`create()`](/Teams/create/) or [`list()`](/Teams/list/). Handles are not accepted — see [`uid`, not `handle`](/Teams/#uid-not-handle). + +## Return value + +A `Promise` that resolves to a [`Team`](/Teams/#team). + +Rejects with `team_not_found` if the team does not exist or the caller is not a member. The two are not distinguished, so this cannot be used to probe for teams the caller has nothing to do with. + +## Examples + +Read a team back + +```html + + + + + + +``` diff --git a/src/docs/src/Teams/list.md b/src/docs/src/Teams/list.md new file mode 100644 index 000000000..c050f98c1 --- /dev/null +++ b/src/docs/src/Teams/list.md @@ -0,0 +1,58 @@ +--- +title: puter.teams.list() +description: List the teams you belong to. +platforms: [websites, apps] +--- + +
The Teams API is in beta. Method shapes, limits, and behavior may change between releases.
+ +Returns the teams the caller belongs to — both those they own and those they were provisioned into. + +This is also how an app discovers whether teams exist on this deployment at all: it rejects with `not_found` where the feature is off, and resolves to an empty array where it is on and the caller has no team. + +## Syntax + +```js +puter.teams.list() +puter.teams.list(options) +``` + +## Parameters + +#### `options` (Object) (optional) + +The standard list options — `limit`, `cursor`, `includeTotal` and `stream`. See [Pagination](/Teams/#pagination) for what each form returns. `offset` is not accepted. + +## Return value + +A `Promise` that resolves to an array of [`Team`](/Teams/#team) objects, or to a `{ items, cursor? }` page when a pagination option is given. With `stream: true` it returns an async iterator of pages instead. + +## Examples + +Show the caller's teams, or nothing where the feature is off + +```html + + + + + + +``` diff --git a/src/docs/src/Teams/listAudit.md b/src/docs/src/Teams/listAudit.md new file mode 100644 index 000000000..67e0d8448 --- /dev/null +++ b/src/docs/src/Teams/listAudit.md @@ -0,0 +1,55 @@ +--- +title: puter.teams.listAudit() +description: Read a team's record of what it did to its accounts. +platforms: [websites, apps] +--- + +
The Teams API is in beta. Method shapes, limits, and behavior may change between releases.
+ +Returns everything the team has done to its accounts, newest first. Owner account only. + +The log is insert-only and survives the team: after [`delete()`](/Teams/delete/) the owner account can still read it, which is the point of keeping it. + +## Syntax + +```js +puter.teams.listAudit(uid) +puter.teams.listAudit(uid, options) +``` + +## Parameters + +#### `uid` (String) (required) + +The team's identifier. + +#### `options` (Object) (optional) + +The standard list options — `limit`, `cursor`, `includeTotal` and `stream`. See [Pagination](/Teams/#pagination). `offset` is not accepted. + +## Return value + +A `Promise` that resolves to an array of [`TeamAuditEntry`](/Teams/#teamauditentry) objects, or to a `{ items, cursor? }` page when a pagination option is given. With `stream: true` it returns an async iterator of pages instead. + +Rejects with `not_the_team_owner` if the caller is a member rather than the owner account. Members read their own entries with [`listOwnAudit()`](/Teams/listOwnAudit/). + +## Examples + +Show what a team has done + +```html + + + + + + +``` diff --git a/src/docs/src/Teams/listMembers.md b/src/docs/src/Teams/listMembers.md new file mode 100644 index 000000000..17e0fa9f1 --- /dev/null +++ b/src/docs/src/Teams/listMembers.md @@ -0,0 +1,53 @@ +--- +title: puter.teams.listMembers() +description: List the accounts belonging to a team. +platforms: [websites, apps] +--- + +
The Teams API is in beta. Method shapes, limits, and behavior may change between releases.
+ +Returns the accounts belonging to a team, including the owner account. + +Any member may call it. The response carries no email address, activation state or usage — those stay on the administrative methods. + +## Syntax + +```js +puter.teams.listMembers(uid) +puter.teams.listMembers(uid, options) +``` + +## Parameters + +#### `uid` (String) (required) + +The team's identifier. + +#### `options` (Object) (optional) + +The standard list options — `limit`, `cursor`, `includeTotal` and `stream`. See [Pagination](/Teams/#pagination). `offset` is not accepted. + +## Return value + +A `Promise` that resolves to an array of [`TeamMember`](/Teams/#teammember) objects, or to a `{ items, cursor? }` page when a pagination option is given. With `stream: true` it returns an async iterator of pages instead. + +## Examples + +List everyone in the first team + +```html + + + + + + +``` diff --git a/src/docs/src/Teams/listOwnAudit.md b/src/docs/src/Teams/listOwnAudit.md new file mode 100644 index 000000000..64a8875f6 --- /dev/null +++ b/src/docs/src/Teams/listOwnAudit.md @@ -0,0 +1,55 @@ +--- +title: puter.teams.listOwnAudit() +description: Read what a team did to your own account. +platforms: [websites, apps] +--- + +
The Teams API is in beta. Method shapes, limits, and behavior may change between releases.
+ +Returns the caller's own entries in a team's audit log — what the team did to their account, and who did it. Any member may call it. + +It exists so that being administered is not something that happens invisibly. It shows only the caller's entries; the whole log is [`listAudit()`](/Teams/listAudit/), which is owner-account only. + +## Syntax + +```js +puter.teams.listOwnAudit(uid) +puter.teams.listOwnAudit(uid, options) +``` + +## Parameters + +#### `uid` (String) (required) + +The team's identifier. + +#### `options` (Object) (optional) + +The standard list options — `limit`, `cursor`, `includeTotal` and `stream`. See [Pagination](/Teams/#pagination). `offset` is not accepted. + +## Return value + +A `Promise` that resolves to an array of [`TeamAuditEntry`](/Teams/#teamauditentry) objects, or to a `{ items, cursor? }` page when a pagination option is given. With `stream: true` it returns an async iterator of pages instead. + +## Examples + +Show what was done to your own account + +```html + + + + + + +``` diff --git a/src/docs/src/Teams/resendActivation.md b/src/docs/src/Teams/resendActivation.md new file mode 100644 index 000000000..7461deb52 --- /dev/null +++ b/src/docs/src/Teams/resendActivation.md @@ -0,0 +1,63 @@ +--- +title: puter.teams.resendActivation() +description: Issue a fresh one-time credential for an account that has never signed in. +platforms: [websites, apps] +--- + +
The Teams API is in beta. Method shapes, limits, and behavior may change between releases.
+ +Issues a fresh one-time credential for an account that has never signed in, invalidating the previous one. Owner account only. Use it when the password from [`createMember()`](/Teams/createMember/) was lost before the member used it. + +**It refuses once the account has been activated**, rejecting with `conflict`. After activation the member owns their own password, and an administrator able to replace it would be able to reach their files. An activated member resets their own password through the normal Puter flow. + +The credential comes back once and is not retrievable afterwards. The member is emailed a notice that the account was set up; the notice carries no credential. + +## Syntax + +```js +puter.teams.resendActivation(uid, username) +``` + +## Parameters + +#### `uid` (String) (required) + +The team's identifier. + +#### `username` (String) (required) + +The member's username. It must be an account this team provisioned. + +## Return value + +A `Promise` that resolves to `{ username, temporaryPassword }`. + +Rejects with `conflict` if the account has already been activated, and `not_an_org_account` if it does not belong to this team. + +## Examples + +Reissue a credential, and see it refused after activation + +```html + + + + + + +``` diff --git a/src/docs/src/Teams/resetPassword.md b/src/docs/src/Teams/resetPassword.md new file mode 100644 index 000000000..f07866839 --- /dev/null +++ b/src/docs/src/Teams/resetPassword.md @@ -0,0 +1,74 @@ +--- +title: puter.teams.resetPassword() +description: Issue a new temporary password for an account a team owns. +platforms: [websites, apps] +--- + +
The Teams API is in beta. Method shapes, limits, and behavior may change between releases.
+ +Issues a new temporary password for an account the team owns and ends its sessions. Owner account only. + +Unlike [`resendActivation()`](/Teams/resendActivation/), which only works before an account has ever been used, this works on a live account. **That makes it the one route from a team to a member's data**, so it is bounded in two ways that cannot be turned off: an audit row is written, and the member is emailed. Both happen on every call. + +Two-factor authentication is left alone. A team can replace a member's password and cannot clear their second factor. + +The temporary password is returned **once** and is not retrievable afterwards — deliver it out of band. It stops working 24 hours after it is issued, so an unused reset expires rather than becoming a standing credential. Until the member chooses their own password, they can sign in and do nothing else: every other request fails with `password_change_required`. + +## Syntax + +```js +puter.teams.resetPassword(uid, username) +``` + +## Parameters + +#### `uid` (String) (required) + +The team's identifier. + +#### `username` (String) (required) + +The member's username. It must be an account this team provisioned. + +## Return value + +A `Promise` that resolves to an object with: + +#### `username` (String) + +The account the credential belongs to. + +#### `temporaryPassword` (String) + +The new password. Shown once — this response is the only place it appears. + +Rejects with `not_an_org_account` if the account does not belong to this team, and `not_the_team_owner` if the caller is not the owner account. + +## Rate limit + +20 per day, separate from the general administrative budget. See [Rate Limits and Quotas](/rate-limits-and-quotas/). + +## Examples + +Reset a member's password + +```html + + + + + + +``` diff --git a/src/docs/src/Teams/update.md b/src/docs/src/Teams/update.md new file mode 100644 index 000000000..f06ecbf17 --- /dev/null +++ b/src/docs/src/Teams/update.md @@ -0,0 +1,59 @@ +--- +title: puter.teams.update() +description: Rename a team or change its handle. +platforms: [websites, apps] +--- + +
The Teams API is in beta. Method shapes, limits, and behavior may change between releases.
+ +Renames a team or changes its handle. Owner account only. + +Changing a handle frees the old one for anyone else to claim, so nothing should store a handle as a reference to a team. + +## Syntax + +```js +puter.teams.update(uid, attributes) +``` + +## Parameters + +#### `uid` (String) (required) + +The team's identifier. + +#### `attributes.name` (String) (optional) + +A new display name. + +#### `attributes.handle` (String | null) (optional) + +A new handle, or `null` to release the current one. Omitting the field leaves the handle alone; that is different from passing `null`. + +## Return value + +A `Promise` that resolves to the updated [`Team`](/Teams/#team). + +Rejects with `not_the_team_owner` if the caller is a member rather than the owner account, and `conflict` if the handle is taken. + +## Examples + +Rename a team and release its handle + +```html + + + + + + +``` diff --git a/src/docs/src/rate-limits-and-quotas.md b/src/docs/src/rate-limits-and-quotas.md index 3595dd1b8..ccf755f10 100644 --- a/src/docs/src/rate-limits-and-quotas.md +++ b/src/docs/src/rate-limits-and-quotas.md @@ -206,6 +206,10 @@ Lowering the seat limit never disables anyone. A team already above a reduced li Both limits are per deployment (`max_teams_per_user`, `max_seats_per_team`) rather than per team, so raising them moves every team at once. +A team's whole configuration is its name, its handle, and whether its directory is open to apps. In particular there is **no sharing policy**: a team cannot restrict who its members share with, by domain or otherwise, and there is no control over public links. Members share exactly as any other Puter account does. + +Both buckets are per account, not per team, so administering several teams spends one budget, and the read limit is one bucket shared by every listing call. Where a deployment has teams off, `puter.teams` rejects with `not_found` rather than `too_many_requests`. + ### Events One write can reach many subscriptions, so events are bounded on both halves: how much you may register, and how much any one event may turn into. diff --git a/src/docs/src/sidebar.js b/src/docs/src/sidebar.js index 630411ce4..70496222d 100755 --- a/src/docs/src/sidebar.js +++ b/src/docs/src/sidebar.js @@ -1119,6 +1119,127 @@ let sidebar = [ }, ], }, + { + title: 'Teams', + title_tag: 'Teams', + icon: '/assets/img/auth.svg', + source: '/Teams.md', + path: '/Teams', + children: [ + { + title: 'create()', + page_title: 'puter.teams.create()', + title_tag: 'puter.teams.create()', + icon: '/assets/img/function.svg', + source: '/Teams/create.md', + path: '/Teams/create', + }, + { + title: 'list()', + page_title: 'puter.teams.list()', + title_tag: 'puter.teams.list()', + icon: '/assets/img/function.svg', + source: '/Teams/list.md', + path: '/Teams/list', + }, + { + title: 'get()', + page_title: 'puter.teams.get()', + title_tag: 'puter.teams.get()', + icon: '/assets/img/function.svg', + source: '/Teams/get.md', + path: '/Teams/get', + }, + { + title: 'update()', + page_title: 'puter.teams.update()', + title_tag: 'puter.teams.update()', + icon: '/assets/img/function.svg', + source: '/Teams/update.md', + path: '/Teams/update', + }, + { + title: 'delete()', + page_title: 'puter.teams.delete()', + title_tag: 'puter.teams.delete()', + icon: '/assets/img/function.svg', + source: '/Teams/delete.md', + path: '/Teams/delete', + }, + { + title: 'listMembers()', + page_title: 'puter.teams.listMembers()', + title_tag: 'puter.teams.listMembers()', + icon: '/assets/img/function.svg', + source: '/Teams/listMembers.md', + path: '/Teams/listMembers', + }, + { + title: 'createMember()', + page_title: 'puter.teams.createMember()', + title_tag: 'puter.teams.createMember()', + icon: '/assets/img/function.svg', + source: '/Teams/createMember.md', + path: '/Teams/createMember', + }, + { + title: 'resendActivation()', + page_title: 'puter.teams.resendActivation()', + title_tag: 'puter.teams.resendActivation()', + icon: '/assets/img/function.svg', + source: '/Teams/resendActivation.md', + path: '/Teams/resendActivation', + }, + { + title: 'disableMember()', + page_title: 'puter.teams.disableMember()', + title_tag: 'puter.teams.disableMember()', + icon: '/assets/img/function.svg', + source: '/Teams/disableMember.md', + path: '/Teams/disableMember', + }, + { + title: 'enableMember()', + page_title: 'puter.teams.enableMember()', + title_tag: 'puter.teams.enableMember()', + icon: '/assets/img/function.svg', + source: '/Teams/enableMember.md', + path: '/Teams/enableMember', + }, + { + title: 'resetPassword()', + page_title: 'puter.teams.resetPassword()', + title_tag: 'puter.teams.resetPassword()', + icon: '/assets/img/function.svg', + source: '/Teams/resetPassword.md', + path: '/Teams/resetPassword', + }, + { + title: 'deleteMemberAccount()', + page_title: 'puter.teams.deleteMemberAccount()', + title_tag: 'puter.teams.deleteMemberAccount()', + icon: '/assets/img/function.svg', + source: '/Teams/deleteMemberAccount.md', + path: '/Teams/deleteMemberAccount', + }, + { + title: 'listAudit()', + page_title: 'puter.teams.listAudit()', + title_tag: 'puter.teams.listAudit()', + icon: '/assets/img/function.svg', + source: '/Teams/listAudit.md', + path: '/Teams/listAudit', + }, + { + title: 'listOwnAudit()', + page_title: 'puter.teams.listOwnAudit()', + title_tag: 'puter.teams.listOwnAudit()', + icon: '/assets/img/function.svg', + source: '/Teams/listOwnAudit.md', + path: '/Teams/listOwnAudit', + }, + ], + }, { title: 'Utilities', title_tag: 'Utilities', diff --git a/src/puter-js/index.d.ts b/src/puter-js/index.d.ts index 3bc8414c2..841caf03c 100644 --- a/src/puter-js/index.d.ts +++ b/src/puter-js/index.d.ts @@ -217,6 +217,17 @@ export type { PermsResource, } from './types/modules/perms/types.js'; +// -- puter.teams -- +export type { + CreateMemberOptions, + CreateTeamOptions, + Team, + TeamAuditEntry, + TeamMember, + TemporaryCredential, + UpdateTeamAttributes, +} from './types/modules/teams/types.js'; + // -- puter.ui -- export type { AppConnection } from './types/modules/UI.js'; export type { @@ -270,5 +281,6 @@ export type KV = InstanceType; export type Peer = InstanceType; export type Perms = InstanceType; +export type Teams = InstanceType; export type UI = InstanceType; export type WorkersHandler = InstanceType; diff --git a/src/puter-js/src/index.js b/src/puter-js/src/index.js index 7ea023d17..03a72d049 100644 --- a/src/puter-js/src/index.js +++ b/src/puter-js/src/index.js @@ -24,6 +24,7 @@ import { pFetch } from './modules/networking/requests.js'; import { OS } from './modules/os/index.js'; import { Perms } from './modules/perms/index.js'; import PuterDialog from './modules/PuterDialog.js'; +import { Teams } from './modules/teams/index.js'; import UI from './modules/UI.js'; import Util from './modules/Util.js'; import { WorkersHandler } from './modules/Workers.js'; @@ -177,6 +178,8 @@ export class Puter { events; /** @type {InstanceType} */ perms; + /** @type {InstanceType} */ + teams; /** @type {InstanceType} */ drivers; /** @type {InstanceType} */ @@ -313,6 +316,7 @@ export class Puter { this.email = this.registerModule('email', Email); this.events = this.registerModule('events', Events); this.perms = this.registerModule('perms', Perms); + this.teams = this.registerModule('teams', Teams); this.drivers = this.registerModule('drivers', Drivers); this.debug = this.registerModule('debug', Debug); this.peer = this.registerModule('peer', Peer); diff --git a/src/puter-js/src/modules/teams/create.js b/src/puter-js/src/modules/teams/create.js new file mode 100644 index 000000000..c607c99c8 --- /dev/null +++ b/src/puter-js/src/modules/teams/create.js @@ -0,0 +1,22 @@ +import { PuterJSError } from '../../lib/PuterJSError.js'; +import { req } from './lib/req.js'; +import { toTeam } from './lib/shapes.js'; + +/** @typedef {import('./types.js').Team} Team */ + +/** + * Creates a team owned by the caller, who becomes its owner account. + * + * @this {import('./index.js').TeamsModule} + * @param {import('./types.js').CreateTeamOptions} options + * @returns {Promise} + */ +export async function create (options) { + if ( typeof options?.name !== 'string' || options.name.trim() === '' ) { + throw new PuterJSError('`name` is required', 'invalid_request'); + } + const body = { name: options.name }; + if ( options.handle !== undefined ) body.handle = options.handle; + + return toTeam(await req(this.puter, 'POST', '/teams', { body, operation: 'create' })); +} diff --git a/src/puter-js/src/modules/teams/createMember.js b/src/puter-js/src/modules/teams/createMember.js new file mode 100644 index 000000000..4fcbd2459 --- /dev/null +++ b/src/puter-js/src/modules/teams/createMember.js @@ -0,0 +1,35 @@ +import { PuterJSError } from '../../lib/PuterJSError.js'; +import { req, requireSegment } from './lib/req.js'; + +/** + * Provisions a new account owned by the team. Owner account only. + * + * There is no role: the owner account is the sole administrator and every + * provisioned account is an ordinary member. + * + * The returned password is shown once and is not retrievable afterwards — + * deliver it out of band. The member must change it at first sign-in. + * + * @this {import('./index.js').TeamsModule} + * @param {string} uid + * @param {import('./types.js').CreateMemberOptions} options + * @returns {Promise} + */ +export async function createMember (uid, options) { + const segment = requireSegment(uid, 'uid'); + if ( typeof options?.username !== 'string' || options.username.trim() === '' ) { + throw new PuterJSError('`username` is required', 'invalid_request'); + } + if ( typeof options?.email !== 'string' || options.email.trim() === '' ) { + throw new PuterJSError('`email` is required', 'invalid_request'); + } + + const result = /** @type {Record} */ (await req(this.puter, 'POST', `/teams/${segment}/members`, { + body: { username: options.username, email: options.email }, + operation: 'createMember', + })); + return { + username: /** @type {string} */ (result.username), + temporaryPassword: /** @type {string} */ (result.temporary_password), + }; +} diff --git a/src/puter-js/src/modules/teams/delete.js b/src/puter-js/src/modules/teams/delete.js new file mode 100644 index 000000000..907ee3ccd --- /dev/null +++ b/src/puter-js/src/modules/teams/delete.js @@ -0,0 +1,15 @@ +import { req, requireSegment } from './lib/req.js'; + +/** + * Deletes a team. Owner account only. The accounts it provisioned keep + * existing; what stops is the team paying for them. Its audit log stays + * readable to the owner account afterwards. + * + * @this {import('./index.js').TeamsModule} + * @param {string} uid + * @returns {Promise} + */ +export async function del (uid) { + const segment = requireSegment(uid, 'uid'); + await req(this.puter, 'DELETE', `/teams/${segment}`, { operation: 'delete' }); +} diff --git a/src/puter-js/src/modules/teams/deleteMemberAccount.js b/src/puter-js/src/modules/teams/deleteMemberAccount.js new file mode 100644 index 000000000..6a77d3b3a --- /dev/null +++ b/src/puter-js/src/modules/teams/deleteMemberAccount.js @@ -0,0 +1,25 @@ +import { req, requireSegment } from './lib/req.js'; + +/** + * Permanently removes an account the team owns: its files go, its username + * returns to the pool, and every credential is invalidated. Owner account only. + * + * Irreversible, and there is no restore window. The account must already be + * disabled — a live one is refused with `account_must_be_disabled_first`, so + * `disableMember()` is always the step before this one. + * + * Disabling already ended the per-account charge; this is what ends the charge + * for the bytes it held. + * + * @this {import('./index.js').TeamsModule} + * @param {string} uid + * @param {string} username + * @returns {Promise} + */ +export async function deleteMemberAccount (uid, username) { + const teamSegment = requireSegment(uid, 'uid'); + const userSegment = requireSegment(username, 'username'); + await req(this.puter, 'DELETE', `/teams/${teamSegment}/members/${userSegment}`, { + operation: 'deleteMemberAccount', + }); +} diff --git a/src/puter-js/src/modules/teams/disableMember.js b/src/puter-js/src/modules/teams/disableMember.js new file mode 100644 index 000000000..3212cc53f --- /dev/null +++ b/src/puter-js/src/modules/teams/disableMember.js @@ -0,0 +1,21 @@ +import { req, requireSegment } from './lib/req.js'; + +/** + * Suspends an account the team owns, ending its sessions. Owner account + * only, and reversible with `enableMember()`. + * + * A disabled account no longer costs a per-account charge, but the team is + * still billed for the bytes it holds. + * + * @this {import('./index.js').TeamsModule} + * @param {string} uid + * @param {string} username + * @returns {Promise} + */ +export async function disableMember (uid, username) { + const teamSegment = requireSegment(uid, 'uid'); + const userSegment = requireSegment(username, 'username'); + await req(this.puter, 'POST', `/teams/${teamSegment}/members/${userSegment}/disable`, { + operation: 'disableMember', + }); +} diff --git a/src/puter-js/src/modules/teams/enableMember.js b/src/puter-js/src/modules/teams/enableMember.js new file mode 100644 index 000000000..2775f4631 --- /dev/null +++ b/src/puter-js/src/modules/teams/enableMember.js @@ -0,0 +1,18 @@ +import { req, requireSegment } from './lib/req.js'; + +/** + * Restores an account previously suspended with `disableMember()`. Owner + * account only. + * + * @this {import('./index.js').TeamsModule} + * @param {string} uid + * @param {string} username + * @returns {Promise} + */ +export async function enableMember (uid, username) { + const teamSegment = requireSegment(uid, 'uid'); + const userSegment = requireSegment(username, 'username'); + await req(this.puter, 'POST', `/teams/${teamSegment}/members/${userSegment}/enable`, { + operation: 'enableMember', + }); +} diff --git a/src/puter-js/src/modules/teams/get.js b/src/puter-js/src/modules/teams/get.js new file mode 100644 index 000000000..9dd31310e --- /dev/null +++ b/src/puter-js/src/modules/teams/get.js @@ -0,0 +1,14 @@ +import { req, requireSegment } from './lib/req.js'; +import { toTeam } from './lib/shapes.js'; + +/** + * Returns one team the caller belongs to. + * + * @this {import('./index.js').TeamsModule} + * @param {string} uid + * @returns {Promise} + */ +export async function get (uid) { + const segment = requireSegment(uid, 'uid'); + return toTeam(await req(this.puter, 'GET', `/teams/${segment}`, { operation: 'get' })); +} diff --git a/src/puter-js/src/modules/teams/index.js b/src/puter-js/src/modules/teams/index.js new file mode 100644 index 000000000..c40e5e324 --- /dev/null +++ b/src/puter-js/src/modules/teams/index.js @@ -0,0 +1,87 @@ +import { PuterModule } from '../../lib/PuterModule.js'; +import { create } from './create.js'; +import { createMember } from './createMember.js'; +import { del } from './delete.js'; +import { deleteMemberAccount } from './deleteMemberAccount.js'; +import { disableMember } from './disableMember.js'; +import { enableMember } from './enableMember.js'; +import { get } from './get.js'; +import { list } from './list.js'; +import { listAudit } from './listAudit.js'; +import { listMembers } from './listMembers.js'; +import { listOwnAudit } from './listOwnAudit.js'; +import { resendActivation } from './resendActivation.js'; +import { resetPassword } from './resetPassword.js'; +import { update } from './update.js'; + +/** @typedef {import('../../index.js').Puter} Puter */ + +// Every `this`-context method exposed on the module, rebound in the +// constructor so both `puter.teams.create(...)` and destructured +// `const { create } = puter.teams` calls keep the right `this`. +const METHODS = [ + 'create', 'list', 'get', 'update', 'delete', + 'listMembers', 'createMember', 'resendActivation', + 'disableMember', 'enableMember', 'resetPassword', 'deleteMemberAccount', + 'listAudit', 'listOwnAudit', +]; + +/** + * The `puter.teams` module — team administration. + * + * Every method takes a team `uid`, never a handle: a handle is a mutable + * label that deleting the team releases, so a stored one can later resolve + * to a different team. + * + * Method implementations live in the sibling files as `this`-context functions + * whose JSDoc (including the per-form `@overload` declarations) is the source + * of truth for the public signatures — `types/` is generated from it, never + * edited by hand. + */ +export class TeamsModule extends PuterModule { + // The fields hold the unbound functions so they keep the full overloaded + // types (`bind` erases overloads); the constructor rebinds them at runtime + // so destructured calls keep working. + create = create; + list = list; + get = get; + update = update; + delete = del; + + listMembers = listMembers; + createMember = createMember; + resendActivation = resendActivation; + disableMember = disableMember; + enableMember = enableMember; + resetPassword = resetPassword; + deleteMemberAccount = deleteMemberAccount; + + listAudit = listAudit; + listOwnAudit = listOwnAudit; + + /** @param {Puter} puter */ + constructor (puter) { + super(puter); + + const methods = /** @type {Record unknown>} */ ( + /** @type {unknown} */ (this) + ); + for ( const name of METHODS ) { + methods[name] = methods[name].bind(this); + } + } +} + +/** + * The public face of the module: derived from the class, with the internal + * `puter` handle and the legacy `authToken` accessor omitted. + * + * @typedef {import('../../lib/types.js').OmitMembers< + * typeof TeamsModule, + * 'puter' | 'authToken' + * >} TeamsConstructor + */ + +export const Teams = /** @type {TeamsConstructor} */ (TeamsModule); + +export default Teams; diff --git a/src/puter-js/src/modules/teams/lib/listRoute.js b/src/puter-js/src/modules/teams/lib/listRoute.js new file mode 100644 index 000000000..e941e3006 --- /dev/null +++ b/src/puter-js/src/modules/teams/lib/listRoute.js @@ -0,0 +1,61 @@ +import { fetchAllPages, iteratePages } from '../../../lib/pagination.js'; +import { PuterJSError } from '../../../lib/PuterJSError.js'; +import { req } from './req.js'; + +/** + * The three list forms every `puter.teams` list method shares, over a `/teams` + * route instead of a driver call: a plain array by default, the + * `{ items, cursor? }` envelope once `cursor` or `includeTotal` is passed, and + * an async iterator of envelopes under `stream: true`. Matches + * `puter.apps.list()`. + * + * The `/teams` routes are keyset-only, so `offset` is refused rather than sent + * and quietly ignored — a rejected call is easier to diagnose than page one + * returned four times. + * + * @param {import('../../../index.js').Puter} puter + * @param {string} route + * @param {import('../../../lib/types.js').ListPaginationOptions | import('../../../lib/types.js').ListStreamOptions} [options] + * @param {string} [operation] + * @returns {Promise | Promise> | AsyncIterableIterator>} + */ +export function listRoute (puter, route, options, operation) { + const opts = typeof options === 'object' && options !== null ? options : {}; + const { limit, offset, cursor, includeTotal, stream } = opts; + const hasCursor = Object.prototype.hasOwnProperty.call(opts, 'cursor'); + + if ( offset !== undefined ) { + throw new PuterJSError( + '`offset` is not supported here; pass `cursor` to resume from a position.', + 'invalid_request', + ); + } + + const toPage = result => (Array.isArray(result) ? { items: result } : (result ?? { items: [] })); + const fetchPage = pageParams => req(puter, 'GET', route, { + query: { limit, ...pageParams }, + operation, + }); + + if ( stream === true ) { + return iteratePages(fetchPage, { cursor, includeTotal: includeTotal === true }); + } + + if ( hasCursor || includeTotal !== undefined ) { + return (async () => toPage(await req(puter, 'GET', route, { + query: { + limit, + ...(hasCursor ? { cursor } : {}), + ...(includeTotal !== undefined ? { includeTotal } : {}), + }, + operation, + })))(); + } + + // `limit` alone still resolves to an array, capped at one page. + if ( limit !== undefined ) { + return (async () => toPage(await fetchPage({ cursor: null })).items)(); + } + + return fetchAllPages(fetchPage); +} diff --git a/src/puter-js/src/modules/teams/lib/req.js b/src/puter-js/src/modules/teams/lib/req.js new file mode 100644 index 000000000..7c632b5cd --- /dev/null +++ b/src/puter-js/src/modules/teams/lib/req.js @@ -0,0 +1,77 @@ +import { fetchUrl } from '../../../lib/networkUtils.js'; +import { PuterJSError } from '../../../lib/PuterJSError.js'; + +/** Fallback codes for a failure the backend did not name itself. */ +const STATUS_CODES = { + 400: 'bad_request', + 401: 'unauthorized', + 403: 'permission_denied', + 404: 'not_found', + 409: 'conflict', + 429: 'too_many_requests', +}; + +/** + * Request helper for the `/teams` routes. Unlike `perms/lib/req.js` these + * reject on failure rather than resolving `{ error: true }` — nothing depends + * on the older shape here, so the module throws like the rest of the SDK. + * + * @param {import('../../../index.js').Puter} puter + * @param {string} method + * @param {string} route + * @param {{ body?: Record, query?: Record, operation?: string }} [opts] + * @returns {Promise} + */ +export async function req (puter, method, route, opts = {}) { + const { body, query, operation } = opts; + + const search = new URLSearchParams(); + for ( const [key, value] of Object.entries(query ?? {}) ) { + if ( value !== undefined && value !== null ) search.set(key, String(value)); + } + const qs = search.toString(); + + let resp; + try { + resp = await fetchUrl(puter.APIOrigin + route + (qs ? `?${qs}` : ''), { + method, + includePuterAuth: true, + headers: { 'Content-Type': 'application/json' }, + ...(body ? { body: JSON.stringify(body) } : {}), + logContext: { service: 'teams', operation: operation ?? `${method} ${route}`, params: {} }, + }); + } catch (e) { + throw PuterJSError.from(e); + } + + const isJSON = resp.headers.get('content-type')?.includes('application/json'); + const payload = isJSON ? await resp.json() : await resp.text(); + + if ( resp.status < 200 || resp.status >= 300 ) { + const fallback = STATUS_CODES[resp.status] ?? 'unknown_error'; + if ( isJSON && payload !== null && typeof payload === 'object' ) { + // Backend errors pass through unchanged; only a missing code is filled in. + const error = PuterJSError.from(payload); + if ( error.code === undefined ) error.code = fallback; + throw error; + } + throw new PuterJSError(typeof payload === 'string' && payload ? payload : `Request failed with status ${resp.status}`, fallback); + } + + return payload; +} + +/** + * Rejects a blank or non-string path segment before it reaches the wire, where + * an empty `uid` or `username` would silently address a different route. + * + * @param {unknown} value + * @param {string} name + * @returns {string} + */ +export function requireSegment (value, name) { + if ( typeof value !== 'string' || value.trim() === '' ) { + throw new PuterJSError(`\`${name}\` is required`, 'invalid_request'); + } + return encodeURIComponent(value); +} diff --git a/src/puter-js/src/modules/teams/lib/req.test.js b/src/puter-js/src/modules/teams/lib/req.test.js new file mode 100644 index 000000000..4c95a7bc1 --- /dev/null +++ b/src/puter-js/src/modules/teams/lib/req.test.js @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { PuterJSError } from '../../../lib/PuterJSError.js'; + +const mockFetchUrl = vi.fn(); +vi.mock('../../../lib/networkUtils.js', async (importOriginal) => ({ + ...await importOriginal(), + fetchUrl: (...args) => mockFetchUrl(...args), +})); + +const { req, requireSegment } = await import('./req.js'); + +const puter = { APIOrigin: 'https://api.test' }; + +/** @param {{ status?: number, body?: unknown, json?: boolean }} opts */ +const respond = ({ status = 200, body = {}, json = true } = {}) => { + mockFetchUrl.mockResolvedValue({ + status, + headers: { get: name => (name === 'content-type' && json ? 'application/json' : 'text/plain') }, + json: async () => body, + text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), + }); +}; + +beforeEach(() => { + mockFetchUrl.mockReset(); +}); + +describe('teams req', () => { + it('returns the parsed body on success', async () => { + respond({ body: { uid: 't-1' } }); + await expect(req(puter, 'GET', '/teams')).resolves.toEqual({ uid: 't-1' }); + expect(mockFetchUrl).toHaveBeenCalledWith('https://api.test/teams', expect.objectContaining({ + method: 'GET', + includePuterAuth: true, + })); + }); + + it('serializes a body and appends only the query params that have a value', async () => { + respond(); + await req(puter, 'POST', '/teams', { + body: { name: 'Acme' }, + query: { limit: 5, cursor: null, includeTotal: undefined }, + }); + const [url, opts] = mockFetchUrl.mock.calls[0]; + expect(url).toBe('https://api.test/teams?limit=5'); + expect(opts.body).toBe('{"name":"Acme"}'); + }); + + it('throws rather than resolving an error object, keeping the backend code', async () => { + respond({ status: 404, body: { message: 'Team not found', code: 'team_not_found' } }); + const error = await req(puter, 'GET', '/teams/t-1').catch(e => e); + expect(error).toBeInstanceOf(PuterJSError); + expect(error.code).toBe('team_not_found'); + expect(error.message).toBe('Team not found'); + }); + + it('falls back to a status-derived code when the backend names none', async () => { + respond({ status: 403, body: { message: 'nope' } }); + await expect(req(puter, 'GET', '/teams/t-1')).rejects.toMatchObject({ code: 'permission_denied' }); + }); + + it('throws on a non-JSON failure', async () => { + respond({ status: 502, body: 'bad gateway', json: false }); + await expect(req(puter, 'GET', '/teams')).rejects.toMatchObject({ + code: 'unknown_error', + message: 'bad gateway', + }); + }); +}); + +describe('requireSegment', () => { + it('percent-encodes the value', () => { + expect(requireSegment('a b/c', 'uid')).toBe('a%20b%2Fc'); + }); + + it.each([['', 'empty'], [' ', 'blank'], [undefined, 'missing'], [null, 'null']])( + 'rejects an %s segment', (value) => { + expect(() => requireSegment(value, 'uid')).toThrow(PuterJSError); + expect(() => requireSegment(value, 'uid')).toThrowError(/`uid` is required/); + }, + ); +}); diff --git a/src/puter-js/src/modules/teams/lib/shapes.js b/src/puter-js/src/modules/teams/lib/shapes.js new file mode 100644 index 000000000..7915a4c6d --- /dev/null +++ b/src/puter-js/src/modules/teams/lib/shapes.js @@ -0,0 +1,72 @@ +// The `/teams` routes answer in the backend's `snake_case` wire keys. Mapping +// happens here so the published SDK shapes stay camelCase and any wire change +// lands in one file. + +/** @typedef {import('../types.js').Team} Team */ +/** @typedef {import('../types.js').TeamMember} TeamMember */ +/** @typedef {import('../types.js').TeamAuditEntry} TeamAuditEntry */ + +/** + * @param {Record} row + * @returns {Team} + */ +export function toTeam (row) { + return { + uid: /** @type {string} */ (row.uid), + name: /** @type {string | null} */ (row.name ?? null), + handle: /** @type {string | null} */ (row.handle ?? null), + isOwner: row.is_owner === true, + createdAt: /** @type {string} */ (row.created_at), + }; +} + +/** + * @param {Record} row + * @returns {TeamMember} + */ +export function toMember (row) { + return { + username: /** @type {string} */ (row.username), + orgOwned: row.org_owned === true, + createdAt: /** @type {string} */ (row.created_at), + }; +} + +/** + * @param {Record} row + * @returns {TeamAuditEntry} + */ +export function toAuditEntry (row) { + return { + action: /** @type {string} */ (row.action), + reason: /** @type {string | null} */ (row.reason ?? null), + username: /** @type {string | null} */ (row.username ?? null), + actorUsername: /** @type {string | null} */ (row.actor_username ?? null), + createdAt: /** @type {string} */ (row.created_at), + }; +} + +/** + * Applies `map` to whichever of the three list forms `result` is, leaving the + * form itself alone. + * + * @template T + * @param {unknown} result + * @param {(row: Record) => T} map + * @returns {unknown} + */ +export function mapListResult (result, map) { + if ( result !== null && typeof result === 'object' && Symbol.asyncIterator in result ) { + const pages = /** @type {AsyncIterableIterator<{ items: Record[] }>} */ (result); + return (async function* () { + for await ( const page of pages ) { + yield { ...page, items: (page.items ?? []).map(map) }; + } + })(); + } + return Promise.resolve(result).then(value => { + if ( Array.isArray(value) ) return value.map(map); + const page = /** @type {{ items?: Record[] }} */ (value); + return { ...page, items: (page?.items ?? []).map(map) }; + }); +} diff --git a/src/puter-js/src/modules/teams/list.js b/src/puter-js/src/modules/teams/list.js new file mode 100644 index 000000000..36c506fa7 --- /dev/null +++ b/src/puter-js/src/modules/teams/list.js @@ -0,0 +1,40 @@ +import { listRoute } from './lib/listRoute.js'; +import { mapListResult, toTeam } from './lib/shapes.js'; + +/** @typedef {import('./types.js').Team} Team */ +/** @typedef {import('../../lib/types.js').ListPage} TeamPage */ +/** @typedef {Omit} TeamListOptions */ + +/** + * @overload + * @param {import('../../lib/types.js').ListStreamOptions} options + * @returns {AsyncIterableIterator} + */ +/** + * @overload + * @param {TeamListOptions & ({ cursor: string | null } | { includeTotal: true })} options + * @returns {Promise} + */ +/** + * @overload + * @param {{ limit?: number }} [options] + * @returns {Promise} + */ +/** + * Returns the teams the caller belongs to, resolving to a plain array by + * default. `cursor` or `includeTotal` switches to the `{ items, cursor? }` + * envelope, and `stream: true` returns an async iterator of envelopes. + * + * A deployment with teams turned off has no `/teams` route at all, so this + * rejects with `not_found` rather than returning an empty list — which is how a + * caller tells "turned off" from "none yet". + * + * @this {import('./index.js').TeamsModule} + * @param {TeamListOptions | import('../../lib/types.js').ListStreamOptions} [options] + * @returns {Promise | Promise | AsyncIterableIterator} + */ +export function list (options) { + return /** @type {Promise} */ ( + mapListResult(listRoute(this.puter, '/teams', options, 'list'), toTeam) + ); +} diff --git a/src/puter-js/src/modules/teams/listAudit.js b/src/puter-js/src/modules/teams/listAudit.js new file mode 100644 index 000000000..65403cbf3 --- /dev/null +++ b/src/puter-js/src/modules/teams/listAudit.js @@ -0,0 +1,41 @@ +import { listRoute } from './lib/listRoute.js'; +import { requireSegment } from './lib/req.js'; +import { mapListResult, toAuditEntry } from './lib/shapes.js'; + +/** @typedef {import('./types.js').TeamAuditEntry} TeamAuditEntry */ +/** @typedef {import('../../lib/types.js').ListPage} TeamAuditPage */ +/** @typedef {Omit} TeamListOptions */ + +/** + * @overload + * @param {string} uid + * @param {import('../../lib/types.js').ListStreamOptions} options + * @returns {AsyncIterableIterator} + */ +/** + * @overload + * @param {string} uid + * @param {TeamListOptions & ({ cursor: string | null } | { includeTotal: true })} options + * @returns {Promise} + */ +/** + * @overload + * @param {string} uid + * @param {{ limit?: number }} [options] + * @returns {Promise} + */ +/** + * Returns everything the team has done to its accounts, newest first. + * Owner account only, and still readable after the team is deleted. + * + * @this {import('./index.js').TeamsModule} + * @param {string} uid + * @param {TeamListOptions | import('../../lib/types.js').ListStreamOptions} [options] + * @returns {Promise | Promise | AsyncIterableIterator} + */ +export function listAudit (uid, options) { + const segment = requireSegment(uid, 'uid'); + return /** @type {Promise} */ ( + mapListResult(listRoute(this.puter, `/teams/${segment}/audit`, options, 'listAudit'), toAuditEntry) + ); +} diff --git a/src/puter-js/src/modules/teams/listMembers.js b/src/puter-js/src/modules/teams/listMembers.js new file mode 100644 index 000000000..5d4dad73a --- /dev/null +++ b/src/puter-js/src/modules/teams/listMembers.js @@ -0,0 +1,41 @@ +import { listRoute } from './lib/listRoute.js'; +import { requireSegment } from './lib/req.js'; +import { mapListResult, toMember } from './lib/shapes.js'; + +/** @typedef {import('./types.js').TeamMember} TeamMember */ +/** @typedef {import('../../lib/types.js').ListPage} TeamMemberPage */ +/** @typedef {Omit} TeamListOptions */ + +/** + * @overload + * @param {string} uid + * @param {import('../../lib/types.js').ListStreamOptions} options + * @returns {AsyncIterableIterator} + */ +/** + * @overload + * @param {string} uid + * @param {TeamListOptions & ({ cursor: string | null } | { includeTotal: true })} options + * @returns {Promise} + */ +/** + * @overload + * @param {string} uid + * @param {{ limit?: number }} [options] + * @returns {Promise} + */ +/** + * Returns the accounts belonging to a team. Any member may call it; the + * response carries no email, activation state or usage. + * + * @this {import('./index.js').TeamsModule} + * @param {string} uid + * @param {TeamListOptions | import('../../lib/types.js').ListStreamOptions} [options] + * @returns {Promise | Promise | AsyncIterableIterator} + */ +export function listMembers (uid, options) { + const segment = requireSegment(uid, 'uid'); + return /** @type {Promise} */ ( + mapListResult(listRoute(this.puter, `/teams/${segment}/members`, options, 'listMembers'), toMember) + ); +} diff --git a/src/puter-js/src/modules/teams/listOwnAudit.js b/src/puter-js/src/modules/teams/listOwnAudit.js new file mode 100644 index 000000000..c6e9a5f57 --- /dev/null +++ b/src/puter-js/src/modules/teams/listOwnAudit.js @@ -0,0 +1,41 @@ +import { listRoute } from './lib/listRoute.js'; +import { requireSegment } from './lib/req.js'; +import { mapListResult, toAuditEntry } from './lib/shapes.js'; + +/** @typedef {import('./types.js').TeamAuditEntry} TeamAuditEntry */ +/** @typedef {import('../../lib/types.js').ListPage} TeamAuditPage */ +/** @typedef {Omit} TeamListOptions */ + +/** + * @overload + * @param {string} uid + * @param {import('../../lib/types.js').ListStreamOptions} options + * @returns {AsyncIterableIterator} + */ +/** + * @overload + * @param {string} uid + * @param {TeamListOptions & ({ cursor: string | null } | { includeTotal: true })} options + * @returns {Promise} + */ +/** + * @overload + * @param {string} uid + * @param {{ limit?: number }} [options] + * @returns {Promise} + */ +/** + * Returns the caller's own entries in a team's audit log — what the + * team did to their account. Any member may call it. + * + * @this {import('./index.js').TeamsModule} + * @param {string} uid + * @param {TeamListOptions | import('../../lib/types.js').ListStreamOptions} [options] + * @returns {Promise | Promise | AsyncIterableIterator} + */ +export function listOwnAudit (uid, options) { + const segment = requireSegment(uid, 'uid'); + return /** @type {Promise} */ ( + mapListResult(listRoute(this.puter, `/teams/${segment}/audit/me`, options, 'listOwnAudit'), toAuditEntry) + ); +} diff --git a/src/puter-js/src/modules/teams/resendActivation.js b/src/puter-js/src/modules/teams/resendActivation.js new file mode 100644 index 000000000..41a27ecc1 --- /dev/null +++ b/src/puter-js/src/modules/teams/resendActivation.js @@ -0,0 +1,32 @@ +import { req, requireSegment } from './lib/req.js'; + +/** + * Issues a fresh one-time credential for an account that has never signed in, + * invalidating the previous one. Owner account only. + * + * It refuses with `conflict` once the account has been activated: after that + * the member owns their own password, and an administrator able to replace it + * would be able to reach their data. + * + * The returned password is shown once and is not retrievable afterwards. + * + * @this {import('./index.js').TeamsModule} + * @param {string} uid + * @param {string} username + * @returns {Promise} + */ +export async function resendActivation (uid, username) { + const teamSegment = requireSegment(uid, 'uid'); + const userSegment = requireSegment(username, 'username'); + + const result = /** @type {Record} */ (await req( + this.puter, + 'POST', + `/teams/${teamSegment}/members/${userSegment}/activation`, + { operation: 'resendActivation' }, + )); + return { + username, + temporaryPassword: /** @type {string} */ (result.temporary_password), + }; +} diff --git a/src/puter-js/src/modules/teams/resetPassword.js b/src/puter-js/src/modules/teams/resetPassword.js new file mode 100644 index 000000000..e2fba4e39 --- /dev/null +++ b/src/puter-js/src/modules/teams/resetPassword.js @@ -0,0 +1,34 @@ +import { req, requireSegment } from './lib/req.js'; + +/** + * Issues a new temporary password for an account the team owns, ending its + * sessions. Owner account only. 2FA is left alone — a reset does not clear it. + * + * Unlike `resendActivation()`, this works on a live account, which is what makes + * it the one route from a team to a member's data. What bounds it is the + * audit row and the email the member is sent, both of which are unconditional. + * + * The credential is shown once and is not retrievable afterwards. It stops + * working 24 hours after it is issued, and until the member chooses their own + * password they can sign in and do nothing else. + * + * @this {import('./index.js').TeamsModule} + * @param {string} uid + * @param {string} username + * @returns {Promise} + */ +export async function resetPassword (uid, username) { + const teamSegment = requireSegment(uid, 'uid'); + const userSegment = requireSegment(username, 'username'); + + const result = /** @type {Record} */ (await req( + this.puter, + 'POST', + `/teams/${teamSegment}/members/${userSegment}/password-reset`, + { operation: 'resetPassword' }, + )); + return { + username, + temporaryPassword: /** @type {string} */ (result.temporary_password), + }; +} diff --git a/src/puter-js/src/modules/teams/teams.test.js b/src/puter-js/src/modules/teams/teams.test.js new file mode 100644 index 000000000..e014ee3c0 --- /dev/null +++ b/src/puter-js/src/modules/teams/teams.test.js @@ -0,0 +1,258 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Every method reaches the backend through the shared helper, so mocking it +// exercises the real module against a scripted `/teams` without a server. +const mockReq = vi.fn(); +vi.mock('./lib/req.js', async (importOriginal) => ({ + ...await importOriginal(), + req: (...args) => mockReq(...args), +})); + +const { TeamsModule } = await import('./index.js'); + +const TEAM_ROW = { + uid: 't-1', + name: 'Acme', + handle: 'acme', + is_owner: true, + created_at: '2026-01-01T00:00:00Z', +}; + +const TEAM = { + uid: 't-1', + name: 'Acme', + handle: 'acme', + isOwner: true, + createdAt: '2026-01-01T00:00:00Z', +}; + +const member = username => ({ username, org_owned: true, created_at: '2026-01-02T00:00:00Z' }); + +let teams; + +/** Replies per route, so a test says what the backend holds, not call order. */ +const routes = (table) => { + mockReq.mockImplementation(async (_puter, method, route, opts = {}) => { + const key = `${method} ${route}`; + const handler = table[key]; + if ( ! handler ) throw new Error(`unexpected request: ${key}`); + return typeof handler === 'function' ? handler(opts) : handler; + }); +}; + +/** The route and options of the nth request the module made. */ +const call = (n = 0) => { + const [, method, route, opts] = mockReq.mock.calls[n]; + return { method, route, ...opts }; +}; + +beforeEach(() => { + mockReq.mockReset(); + teams = new TeamsModule({ APIOrigin: 'https://api.test' }); +}); + +describe('teams', () => { + it('creates a team and returns it in camelCase', async () => { + routes({ 'POST /teams': TEAM_ROW }); + await expect(teams.create({ name: 'Acme', handle: 'acme' })).resolves.toEqual(TEAM); + expect(call().body).toEqual({ name: 'Acme', handle: 'acme' }); + }); + + it('omits `handle` from the body when it was not given', async () => { + routes({ 'POST /teams': TEAM_ROW }); + await teams.create({ name: 'Acme' }); + expect(call().body).toEqual({ name: 'Acme' }); + }); + + it('refuses a blank name without making a request', async () => { + await expect(teams.create({ name: ' ' })).rejects.toMatchObject({ code: 'invalid_request' }); + expect(mockReq).not.toHaveBeenCalled(); + }); + + it('gets, updates and deletes by uid', async () => { + routes({ + 'GET /teams/t-1': TEAM_ROW, + 'PUT /teams/t-1': { ...TEAM_ROW, name: 'Acme Inc' }, + 'DELETE /teams/t-1': { success: true }, + }); + await expect(teams.get('t-1')).resolves.toEqual(TEAM); + await expect(teams.update('t-1', { name: 'Acme Inc' })).resolves.toEqual({ ...TEAM, name: 'Acme Inc' }); + await expect(teams.delete('t-1')).resolves.toBeUndefined(); + expect(call(1).body).toEqual({ name: 'Acme Inc' }); + }); + + it('sends `handle: null` on update, which releases the handle', async () => { + routes({ 'PUT /teams/t-1': { ...TEAM_ROW, handle: null } }); + await expect(teams.update('t-1', { handle: null })).resolves.toMatchObject({ handle: null }); + expect(call().body).toEqual({ handle: null }); + }); + + it('encodes a uid into the path', async () => { + routes({ 'GET /teams/t%2F1': TEAM_ROW }); + await expect(teams.get('t/1')).resolves.toEqual(TEAM); + }); + + it('refuses a blank uid without making a request', async () => { + await expect(teams.get('')).rejects.toMatchObject({ code: 'invalid_request' }); + expect(mockReq).not.toHaveBeenCalled(); + }); +}); + +describe('list forms', () => { + const page1 = { items: [member('ann'), member('bob')], cursor: 'c-2' }; + const page2 = { items: [member('cat')] }; + + const paged = () => routes({ + 'GET /teams/t-1/members': ({ query }) => (query.cursor === 'c-2' ? page2 : page1), + }); + + it('returns a plain array with no options, following the cursor itself', async () => { + paged(); + const result = await teams.listMembers('t-1'); + expect(Array.isArray(result)).toBe(true); + expect(result.map(m => m.username)).toEqual(['ann', 'bob', 'cat']); + expect(mockReq).toHaveBeenCalledTimes(2); + }); + + it('returns the page envelope when a cursor is passed', async () => { + paged(); + const result = await teams.listMembers('t-1', { cursor: null }); + expect(result).toEqual({ + cursor: 'c-2', + items: [ + { username: 'ann', orgOwned: true, createdAt: '2026-01-02T00:00:00Z' }, + { username: 'bob', orgOwned: true, createdAt: '2026-01-02T00:00:00Z' }, + ], + }); + expect(mockReq).toHaveBeenCalledTimes(1); + }); + + it('resumes from a cursor', async () => { + paged(); + const result = await teams.listMembers('t-1', { cursor: 'c-2' }); + expect(result.items.map(m => m.username)).toEqual(['cat']); + expect(result.cursor).toBeUndefined(); + }); + + it('streams page envelopes under `stream: true`', async () => { + paged(); + const seen = []; + for await ( const page of teams.listMembers('t-1', { stream: true }) ) { + seen.push(page.items.map(m => m.username)); + } + expect(seen).toEqual([['ann', 'bob'], ['cat']]); + }); + + it('caps at one page and still returns an array when only `limit` is given', async () => { + paged(); + const result = await teams.listMembers('t-1', { limit: 2 }); + expect(result.map(m => m.username)).toEqual(['ann', 'bob']); + expect(mockReq).toHaveBeenCalledTimes(1); + expect(call().query.limit).toBe(2); + }); + + it('refuses `offset`, which these keyset routes would ignore', () => { + expect(() => teams.listMembers('t-1', { offset: 10 })).toThrow(/offset/); + expect(mockReq).not.toHaveBeenCalled(); + }); + + it('lists teams from `/teams`', async () => { + routes({ 'GET /teams': { items: [TEAM_ROW] } }); + await expect(teams.list()).resolves.toEqual([TEAM]); + }); + + it('maps audit entries and reads the caller-only route separately', async () => { + const row = { + action: 'disable_member', + reason: null, + username: 'bob', + actor_username: 'ann', + created_at: '2026-01-03T00:00:00Z', + }; + routes({ + 'GET /teams/t-1/audit': { items: [row] }, + 'GET /teams/t-1/audit/me': { items: [] }, + }); + await expect(teams.listAudit('t-1')).resolves.toEqual([{ + action: 'disable_member', + reason: null, + username: 'bob', + actorUsername: 'ann', + createdAt: '2026-01-03T00:00:00Z', + }]); + await expect(teams.listOwnAudit('t-1')).resolves.toEqual([]); + }); +}); + +describe('members', () => { + it('provisions an account and surfaces the one-time credential', async () => { + routes({ 'POST /teams/t-1/members': { username: 'bob', temporary_password: 'hunter2' } }); + await expect(teams.createMember('t-1', { username: 'bob', email: 'bob@example.com' })) + .resolves.toEqual({ username: 'bob', temporaryPassword: 'hunter2' }); + expect(call().body).toEqual({ username: 'bob', email: 'bob@example.com' }); + }); + + it('refuses a member without an email without making a request', async () => { + await expect(teams.createMember('t-1', { username: 'bob' })) + .rejects.toMatchObject({ code: 'invalid_request' }); + expect(mockReq).not.toHaveBeenCalled(); + }); + + it('reissues an activation credential', async () => { + routes({ 'POST /teams/t-1/members/bob/activation': { temporary_password: 'hunter3' } }); + await expect(teams.resendActivation('t-1', 'bob')) + .resolves.toEqual({ username: 'bob', temporaryPassword: 'hunter3' }); + }); + + it('passes an activated account\'s refusal through untouched', async () => { + routes({ + 'POST /teams/t-1/members/bob/activation': () => { + throw Object.assign(new Error('That account is already activated'), { code: 'conflict' }); + }, + }); + await expect(teams.resendActivation('t-1', 'bob')).rejects.toMatchObject({ code: 'conflict' }); + }); + + it('disables and enables an account', async () => { + routes({ + 'POST /teams/t-1/members/bob/disable': { success: true }, + 'POST /teams/t-1/members/bob/enable': { success: true }, + }); + await expect(teams.disableMember('t-1', 'bob')).resolves.toBeUndefined(); + await expect(teams.enableMember('t-1', 'bob')).resolves.toBeUndefined(); + }); + + it('refuses a blank username without making a request', async () => { + await expect(teams.disableMember('t-1', '')).rejects.toMatchObject({ code: 'invalid_request' }); + expect(mockReq).not.toHaveBeenCalled(); + }); + + it('returns the reset credential once, and only that', async () => { + routes({ 'POST /teams/t-1/members/bob/password-reset': { temporary_password: 'tmp-abc123' } }); + await expect(teams.resetPassword('t-1', 'bob')).resolves.toEqual({ + username: 'bob', + temporaryPassword: 'tmp-abc123', + }); + }); + + it('deletes a member, and surfaces the disable-first refusal', async () => { + routes({ 'DELETE /teams/t-1/members/bob': { success: true } }); + await expect(teams.deleteMemberAccount('t-1', 'bob')).resolves.toBeUndefined(); + + routes({ + 'DELETE /teams/t-1/members/bob': () => { + throw Object.assign(new Error('Disable the account before deleting it'), { code: 'account_must_be_disabled_first' }); + }, + }); + await expect(teams.deleteMemberAccount('t-1', 'bob')).rejects.toMatchObject({ code: 'account_must_be_disabled_first' }); + }); +}); + +describe('binding', () => { + it('keeps `this` when a method is destructured off the module', async () => { + routes({ 'GET /teams': { items: [TEAM_ROW] }, 'POST /teams': TEAM_ROW }); + const { create, list } = teams; + await expect(create({ name: 'Acme' })).resolves.toEqual(TEAM); + await expect(list()).resolves.toEqual([TEAM]); + }); +}); diff --git a/src/puter-js/src/modules/teams/types.js b/src/puter-js/src/modules/teams/types.js new file mode 100644 index 000000000..cc3c70a97 --- /dev/null +++ b/src/puter-js/src/modules/teams/types.js @@ -0,0 +1,71 @@ +// Shapes shared across the `puter.teams` operations. JSDoc-only; no runtime exports. + +/** + * A team. `uid` is the only stable reference: `handle` is a label that + * `update()` can change and deleting the team releases, so a stored + * handle can later resolve to a different team. + * + * @typedef {Object} Team + * @property {string} uid The team's unique identifier. Pass this to every other `puter.teams` method. + * @property {string | null} name The team's display name. + * @property {string | null} handle The team's short handle, unique while it exists. `null` when unset. + * @property {boolean} isOwner Whether the caller is the owner account of this team. + * @property {string} createdAt When the team was created, in `YYYY-MM-DDTHH:MM:SSZ` format. + */ + +/** + * Options for `Teams.create()`. + * + * @typedef {Object} CreateTeamOptions + * @property {string} name The team's display name. + * @property {string | null} [handle] A short handle, lowercase letters, digits and single hyphens. + * Omit or pass `null` for none. + */ + +/** + * Attributes to change with `Teams.update()`. Omitted fields are left alone. + * + * @typedef {Object} UpdateTeamAttributes + * @property {string} [name] The team's new display name. + * @property {string | null} [handle] A new handle, or `null` to release the current one. + */ + +/** + * An account belonging to a team. + * + * @typedef {Object} TeamMember + * @property {string} username The member's Puter username. + * @property {boolean} orgOwned Whether the team provisioned and pays for this account, as opposed + * to a pre-existing account that joined it. + * @property {string} createdAt When the account joined the team, in `YYYY-MM-DDTHH:MM:SSZ` format. + */ + +/** + * Details for the account `Teams.createMember()` provisions. + * + * @typedef {Object} CreateMemberOptions + * @property {string} username The username for the new account. Must be free across all of Puter. + * @property {string} email The address the member is reachable at. It must not already own an account. + */ + +/** + * The one-time credential for an account that has not been used yet. It is + * shown once and cannot be retrieved afterwards — deliver it out of band. + * + * @typedef {Object} TemporaryCredential + * @property {string} username The account the credential is for. + * @property {string} temporaryPassword The password the member signs in with once, then must change. + */ + +/** + * One entry in a team's record of what it did to its accounts. + * + * @typedef {Object} TeamAuditEntry + * @property {string} action What was done, e.g. `create_member`, `disable_member`, `delete_team`. + * @property {string | null} reason The reason recorded with the action, when one was given. + * @property {string | null} username The account the action was about. `null` if that account is gone. + * @property {string | null} actorUsername Who performed it. `null` when Puter itself did. + * @property {string} createdAt When it happened, in `YYYY-MM-DDTHH:MM:SSZ` format. + */ + +export {}; diff --git a/src/puter-js/src/modules/teams/update.js b/src/puter-js/src/modules/teams/update.js new file mode 100644 index 000000000..e62086066 --- /dev/null +++ b/src/puter-js/src/modules/teams/update.js @@ -0,0 +1,22 @@ +import { req, requireSegment } from './lib/req.js'; +import { toTeam } from './lib/shapes.js'; + +/** + * Renames a team or changes its handle. Owner account only. A released + * handle becomes available to other teams, so anything holding one should + * hold the `uid` instead. + * + * @this {import('./index.js').TeamsModule} + * @param {string} uid + * @param {import('./types.js').UpdateTeamAttributes} attributes + * @returns {Promise} + */ +export async function update (uid, attributes) { + const segment = requireSegment(uid, 'uid'); + + const body = {}; + if ( attributes?.name !== undefined ) body.name = attributes.name; + if ( attributes?.handle !== undefined ) body.handle = attributes.handle; + + return toTeam(await req(this.puter, 'PUT', `/teams/${segment}`, { body, operation: 'update' })); +} diff --git a/src/puter-js/tests/api/harness/capabilities.ts b/src/puter-js/tests/api/harness/capabilities.ts index 724d0fcf0..4c39b2c1e 100644 --- a/src/puter-js/tests/api/harness/capabilities.ts +++ b/src/puter-js/tests/api/harness/capabilities.ts @@ -102,6 +102,13 @@ export const loadPuterJsTestOptions = ( // half switched on. Off, every subscribe answers `events_disabled` and // the suite would only ever cover that one branch. events: { enabled: true }, + // Off, `/teams` isn't registered at all and the whole suite would only + // ever cover the 404 branch. + teams_enabled: true, + // The shipped default is 1, and the suite creates a team per case + // against one shared account, so every case after the first would + // fail on `team_limit_reached` rather than on what it tests. + max_teams_per_user: 100, }; for (const mapping of MAPPINGS) { diff --git a/src/puter-js/tests/api/suites/index.ts b/src/puter-js/tests/api/suites/index.ts index bab77f3b8..110a46dea 100644 --- a/src/puter-js/tests/api/suites/index.ts +++ b/src/puter-js/tests/api/suites/index.ts @@ -12,6 +12,7 @@ import os from './os.suite.ts'; import perms from './perms.suite.ts'; import sharing from './sharing.suite.ts'; import system from './system.suite.ts'; +import teams from './teams.suite.ts'; import util from './util.suite.ts'; import workers from './workers.suite.ts'; @@ -33,6 +34,7 @@ export const suites: Suite[] = [ perms, sharing, system, + teams, util, workers, ]; diff --git a/src/puter-js/tests/api/suites/teams.suite.ts b/src/puter-js/tests/api/suites/teams.suite.ts new file mode 100644 index 000000000..aca395776 --- /dev/null +++ b/src/puter-js/tests/api/suites/teams.suite.ts @@ -0,0 +1,226 @@ +import { suite } from '../harness/types.ts'; +import type { TestContext } from '../harness/types.ts'; + +/** + * Handles and usernames are unique across the whole deployment, and the suites + * share one server, so every fixture carries a random tag. + */ +const tag = () => Math.random().toString(36).slice(2, 8); + +type Team = { uid: string; name: string | null; handle: string | null; isOwner: boolean }; + +const makeTeam = async (t: TestContext, label: string): Promise => + (await t.puter.teams.create({ name: `Teams Suite ${label}`, handle: `ts-${label}-${tag()}` })) as Team; + +export default suite('teams', { + 'create returns the team with the caller as owner': async (t) => { + const team = await makeTeam(t, 'create'); + t.assert.ok(typeof team.uid === 'string' && team.uid.length > 0, 'uid should be set'); + t.assert.equal(team.name, 'Teams Suite create'); + t.assert.equal(team.isOwner, true); + }, + + 'create without a handle leaves it null': async (t) => { + const team = (await t.puter.teams.create({ name: `Teams Suite nohandle ${tag()}` })) as Team; + t.assert.equal(team.handle, null); + }, + + 'create with a blank name rejects before reaching the server': async (t) => { + await t.assert.rejects( + () => t.puter.teams.create({ name: ' ' }), + 'a blank name should reject', + ); + }, + + 'get returns a team the caller belongs to': async (t) => { + const team = await makeTeam(t, 'get'); + const fetched = (await t.puter.teams.get(team.uid)) as Team; + t.assert.equal(fetched.uid, team.uid); + t.assert.equal(fetched.handle, team.handle); + }, + + 'get on an unknown uid rejects': async (t) => { + await t.assert.rejects( + () => t.puter.teams.get(`t-missing-${tag()}`), + 'an unknown team should reject', + ); + }, + + 'list returns an array by default and the envelope with a cursor': async (t) => { + const team = await makeTeam(t, 'list'); + + const all = (await t.puter.teams.list()) as Team[]; + t.assert.ok(Array.isArray(all), 'list with no options should return an array'); + t.assert.ok(all.some((x) => x.uid === team.uid), 'created team should appear in list'); + + const page = (await t.puter.teams.list({ cursor: null })) as { items: Team[] }; + t.assert.ok(Array.isArray(page.items), 'list with a cursor should return the page envelope'); + t.assert.ok(page.items.some((x) => x.uid === team.uid), 'envelope should carry the team'); + }, + + 'list streams page envelopes': async (t) => { + const team = await makeTeam(t, 'stream'); + const seen: string[] = []; + for await (const page of t.puter.teams.list({ stream: true }) as AsyncIterableIterator<{ items: Team[] }>) { + for (const x of page.items) seen.push(x.uid); + } + t.assert.ok(seen.includes(team.uid), 'streamed pages should carry the team'); + }, + + 'update renames a team and releases its handle': async (t) => { + const team = await makeTeam(t, 'update'); + const renamed = (await t.puter.teams.update(team.uid, { name: 'Teams Suite renamed' })) as Team; + t.assert.equal(renamed.name, 'Teams Suite renamed'); + t.assert.equal(renamed.handle, team.handle); + + const released = (await t.puter.teams.update(team.uid, { handle: null })) as Team; + t.assert.equal(released.handle, null); + }, + + 'delete removes the team from list': async (t) => { + const team = await makeTeam(t, 'delete'); + await t.puter.teams.delete(team.uid); + const all = (await t.puter.teams.list()) as Team[]; + t.assert.ok(!all.some((x) => x.uid === team.uid), 'deleted team should be gone from list'); + }, + + 'listMembers includes the owner account': async (t) => { + const team = await makeTeam(t, 'members'); + const members = (await t.puter.teams.listMembers(team.uid)) as Array<{ username: string }>; + t.assert.ok(Array.isArray(members), 'listMembers with no options should return an array'); + t.assert.ok( + members.some((m) => m.username === t.env.users.user.username), + 'the owner should be a member of their own team', + ); + }, + + 'listMembers rejects offset, which the keyset route would ignore': async (t) => { + const team = await makeTeam(t, 'offset'); + await t.assert.rejects( + async () => t.puter.teams.listMembers(team.uid, { offset: 1 } as never), + 'offset should be refused', + ); + }, + + 'createMember provisions an account that then appears as a member': async (t) => { + const team = await makeTeam(t, 'provision'); + const username = `tsm${tag()}`; + const created = (await t.puter.teams.createMember(team.uid, { + username, + email: `${username}@example.com`, + })) as { username: string; temporaryPassword: string }; + + t.assert.equal(created.username, username); + t.assert.ok( + typeof created.temporaryPassword === 'string' && created.temporaryPassword.length > 0, + 'a one-time credential should come back', + ); + + const members = (await t.puter.teams.listMembers(team.uid)) as Array<{ username: string; orgOwned: boolean }>; + const member = members.find((m) => m.username === username); + t.assert.ok(!!member, 'the provisioned account should be a member'); + t.assert.equal(member!.orgOwned, true); + }, + + 'createMember with a taken username rejects': async (t) => { + const team = await makeTeam(t, 'taken'); + await t.assert.rejects( + () => + t.puter.teams.createMember(team.uid, { + username: t.env.users.other.username, + email: `taken-${tag()}@example.com`, + }), + 'a username already in use should reject', + ); + }, + + 'resendActivation issues a different credential before first sign-in': async (t) => { + const team = await makeTeam(t, 'reissue'); + const username = `tsr${tag()}`; + const first = (await t.puter.teams.createMember(team.uid, { + username, + email: `${username}@example.com`, + })) as { temporaryPassword: string }; + + const again = (await t.puter.teams.resendActivation(team.uid, username)) as { + username: string; + temporaryPassword: string; + }; + t.assert.equal(again.username, username); + t.assert.ok(again.temporaryPassword !== first.temporaryPassword, 'the credential should be replaced'); + }, + + 'disable then enable a provisioned account': async (t) => { + const team = await makeTeam(t, 'disable'); + const username = `tsd${tag()}`; + await t.puter.teams.createMember(team.uid, { username, email: `${username}@example.com` }); + + await t.puter.teams.disableMember(team.uid, username); + await t.puter.teams.enableMember(team.uid, username); + }, + + 'deleting a member is refused until it is disabled': async (t) => { + const team = await makeTeam(t, 'harddelete'); + const username = `tsx${tag()}`; + await t.puter.teams.createMember(team.uid, { username, email: `${username}@example.com` }); + + await t.assert.rejects( + () => t.puter.teams.deleteMemberAccount(team.uid, username), + 'a live account should not be deletable', + ); + + await t.puter.teams.disableMember(team.uid, username); + await t.puter.teams.deleteMemberAccount(team.uid, username); + + const members = (await t.puter.teams.listMembers(team.uid)) as Array<{ username: string }>; + t.assert.ok( + !members.some((m) => m.username === username), + 'the deleted account should be gone from the member list', + ); + }, + + 'disabling an account that is not a member rejects': async (t) => { + const team = await makeTeam(t, 'notamember'); + await t.assert.rejects( + () => t.puter.teams.disableMember(team.uid, t.env.users.other.username), + 'an account outside the team should reject', + ); + }, + + 'the audit log records what the team did': async (t) => { + const team = await makeTeam(t, 'audit'); + const username = `tsa${tag()}`; + await t.puter.teams.createMember(team.uid, { username, email: `${username}@example.com` }); + await t.puter.teams.disableMember(team.uid, username); + + const entries = (await t.puter.teams.listAudit(team.uid)) as Array<{ + action: string; + username: string | null; + actorUsername: string | null; + }>; + t.assert.ok(Array.isArray(entries), 'listAudit with no options should return an array'); + const disabled = entries.find((e) => e.action === 'disable' && e.username === username); + t.assert.ok(!!disabled, 'disabling should be recorded'); + t.assert.equal(disabled!.actorUsername, t.env.users.user.username); + }, + + 'listOwnAudit is scoped to the caller': async (t) => { + const team = await makeTeam(t, 'ownaudit'); + const username = `tso${tag()}`; + await t.puter.teams.createMember(team.uid, { username, email: `${username}@example.com` }); + + const mine = (await t.puter.teams.listOwnAudit(team.uid)) as Array<{ username: string | null }>; + t.assert.ok( + mine.every((e) => e.username !== username), + "another account's entries should not be in the caller's own audit", + ); + }, + + 'another user cannot read a team they do not belong to': async (t) => { + const team = await makeTeam(t, 'isolation'); + const resp = await fetch(`${t.env.apiOrigin}/teams/${team.uid}`, { + headers: { Authorization: `Bearer ${t.env.users.other.token}` }, + }); + t.assert.ok(resp.status >= 400, `a non-member read should be refused, got ${resp.status}`); + }, +});