---
title: "@playstack/accounts"
description: "Framework-independent tenancy, membership, ownership, invitations, and fail-closed account scoping."
tags: ["package","identity","accounts","tenancy","pro"]
---

{/* package-access:start */}

> **Pro.** Covered by the Playstack Pro License. Registry access is required; check preview availability before installing. See [package access](/docs/packages#access-policy).

{/* package-access:end */}

`@playstack/accounts` models the tenant a user is acting within, the membership that permits that scope, and the invitation and ownership lifecycle around it.

## Install

```sh
npm install @playstack/accounts @playstack/auth @playstack/core @playstack/events
```

## Compose the account service

```ts
import { createAccountsService } from '@playstack/accounts'

export const accounts = createAccountsService({
  persistence,
  users: {
    assertActive: (userId) => userDirectory.assertActive(userId),
    emailForUser: (userId) => userDirectory.emailForUser(userId),
  },
  crypto: applicationCrypto,
  delivery: accountInviteDelivery,
  ownershipTransferDelivery,
  events,
  clock,
  ids,
  invitationTtlMs: 24 * 60 * 60_000,
  ownershipTransferTtlMs: 24 * 60 * 60_000,
  deletionGraceMs: 30 * 24 * 60 * 60_000,
  deletionCascades: [removeProductWorkspace],
})

const resolution = await accounts.resolve(session.userId, session.accountId)
```

## Configuration reference

| `AccountsServiceOptions` property | Required | Purpose |
| --- | --- | --- |
| `persistence` | Yes | Transactional account, member, and invitation storage. |
| `users` | Yes | Validates active users and resolves invitation email addresses. |
| `crypto` | Yes | Generates and hashes opaque invitation tokens. |
| `delivery` | Yes | Delivers the one-time invitation secret. |
| `ownershipTransferDelivery` | Yes | Delivers the separately scoped ownership-transfer bearer. |
| `events` | Yes | Emits typed membership and account lifecycle events. |
| `clock`, `ids` | Yes | Application-owned time and identifiers. |
| `invitationTtlMs` | No | Positive invitation lifetime; defaults to 30 minutes. |
| `ownershipTransferTtlMs` | No | Positive proposal lifetime; defaults to 24 hours. |
| `deletionGraceMs` | No | Recoverable deletion window; defaults to 30 days. |
| `deletionCascades` | No | Uniquely named same-database purge hooks. |
| `ownership` | No | `single` by default; `multi` permits multiple owner memberships. |
| `administration` | No | `owners-and-admins` by default; `owners-only` restricts administration. |
| `mutationPolicy` | No | Transaction-aware restrictions for product-owned lifecycle rules. |

`AccountRole` includes `owner`, `admin`, and `member` while allowing application-defined role strings. Domain authorization remains application policy.

## Bridge contracts

| Contract | Key members |
| --- | --- |
| `AccountsPersistence` | Transaction, account lookup/save, member lookup/list/save/delete, and replay-safe invitation lookup/save. |
| `AccountsUserDirectory` | `assertActive(userId)` and `emailForUser(userId)`. |
| `AccountsCrypto` | `randomToken` and `digestSha256`; compatible with `@playstack/crypto`. |
| `AccountInviteDelivery` | `deliver({ invitationId, accountId, email, role, token, expiresAt })`. |
| `AccountSessionStrategy` | `switchAccount(token, accountId, context?)`; implemented by database auth sessions. |

An account switch validates current membership, creates a new account-scoped session, and revokes the old session through the supplied strategy. Register auth’s account-membership-removal handler for `accounts.member.removed` when both persistence adapters share a transaction kind.

## Management and ownership lifecycle

```ts
const members = await accounts.listMembers(accountId, actingUserId)
const invitations = await accounts.listInvitations(accountId, actingUserId)

await accounts.updateAccount(accountId, { name: 'Acme Studio' }, actingUserId)
await accounts.leaveAccount(accountId, actingUserId)

const proposal = await accounts.requestOwnershipTransfer(
  accountId,
  nextOwnerId,
  currentOwnerId,
)
await accounts.acceptOwnershipTransfer(deliveredToken, nextOwnerId)
```

The target must already be a member. A proposal is single-use, owner-only to
request or cancel, and expires after 24 hours by default. Acceptance updates
the owner pointer and both membership roles atomically. The current owner
cannot leave or be removed through generic membership operations in the default
single-owner mode.

### Optional multiple owners

Set `ownership: 'multi'` to give every owner membership equal owner authority.
`Account.ownerId` remains a primary-owner pointer, not the only administrator.
Account-locked role changes/removal/leave protect the last owner; if the primary
leaves, the remaining owner with the lexicographically smallest user ID becomes
primary in the same transaction. Required ownership and membership events must
commit with the change.

Owners can invite/promote owners; admins cannot grant or revoke ownership. A
transfer moves the requesting owner's role to a non-owner member, preserving
other owners. Personal-account membership restrictions remain application policy,
checked on direct mutations and invitation acceptance. Conversion to a team must
be explicit and audited, not inferred from an invitation.

### Resolve within a protected write

`resolve(userId, accountId, { transaction, lock: 'update' })` joins the caller's
transaction and requests an authorization lock. Resolution invokes the configured
`users.assertActive` with the transaction as well as checking account and
membership state. Pass the actual shared handle through user-directory and
persistence ports; do not open independent transactions for protected writes.
Without options, resolution retains its own transaction boundary.

## Registration and recoverable deletion

Register `accounts.userRegistrationHandler()` on the transactional
`auth.user.registered` event when auth and accounts share a transaction kind.
The handler creates the account and owner membership before the new auth user
commits; returning `null` deliberately skips provisioning.

`requestAccountDeletion()` hides an account immediately and returns its purge
deadline. `cancelAccountDeletion()` restores it during the grace period.
`purgeDeletedAccounts({ limit })` locks due accounts, invokes the configured
same-database cascades on the shared transaction, and removes package-owned
rows. Billing cancellation and cross-database erasure consume the lifecycle
events as application workflows; Playstack never starts a scheduler.

## Fail-closed Prisma scoping

```ts
import { withAccountScope } from '@playstack/accounts'

const db = withAccountScope(rootPrisma, resolution.accountId, {
  tenantModels: ['project', 'document'],
  nonTenantModels: ['country'],
})

const projects = await db.project.findMany({ where: { archived: false } })
```

| `AccountScopeOptions` property | Purpose |
| --- | --- |
| `tenantModels` | Allowlist of models that receive enforced `accountId` constraints. |
| `nonTenantModels` | Allowlist of explicitly safe global models. |

The scoped client denies unknown models, raw SQL, root transactions, unsupported operations, account-filter overrides, mismatched compound keys, and nested writes. Cross-tenant jobs must use the explicitly named root client instead of weakening the scoped client.

## Persistence and testing

`@playstack/accounts/prisma` exports `prismaAccountsPersistence(prisma)`. It serializes account mutations, uses advisory locks for slug allocation, locks invitation rows during acceptance, and normalizes dates. The managed schema fragment remains application-migrated. In-memory adapters are available from `@playstack/accounts/testing`.

## Boundary

Accounts depends on authentication but does not replace it. It does not own arbitrary resource authorization, billing policy, or cross-tenant administration. Accounts/team management and its bindings remain paid as an explicit policy exception; reconsideration for Free requires a later adoption decision. Authentication does not require Accounts.

## Restrict service policy without replacing the core

`administration: 'owners-only'` restricts membership administration; the default remains owners-and-admins. `mutationPolicy.assertAllowed(input, transaction)` can enforce product-owned personal-account rules before writes. Hooks restrict built-in permissions rather than grant extra authority, and must use the supplied transaction.

`slugPolicy` supports normalization and reserved-slug decisions. `createAccountPolicy()` provides named permissions for the Nest guard; service policy and resource authorization remain separate. Single-owner remains the default; choose `ownership: 'multi'` explicitly rather than trying to grant ownership through a restrictive hook. Personal-to-team conversion is never automatic.

An optional Nest `resolveRequestAccount` strategy supports user-wide sessions with current membership checks. A session already scoped to an account cannot select another; ordinary account switching retains its session-rotation behavior.

{/* package-reference:start */}

## API entry points and requirements

Reference snapshot: `@playstack/accounts@0.1.0-beta.1`. Import only the entry point your runtime needs. Paths below are relative to the installed package; use **Go to Definition** in your editor to inspect exact parameters, return types and overloads. Do not import the declaration-file paths directly.

| Public entry point | Declaration file |
| --- | --- |
| `@playstack/accounts` | `./dist/index.d.ts` |
| `@playstack/accounts/errors` | `./dist/errors.d.ts` |
| `@playstack/accounts/prisma` | `./dist/prisma.d.ts` |
| `@playstack/accounts/testing` | `./dist/testing.d.ts` |
| `@playstack/accounts/playstack.artifacts.json` | No TypeScript declaration (asset or metadata export). |
| `@playstack/accounts/package.json` | No TypeScript declaration (asset or metadata export). |

Node.js engine requirement: `>=20`. This is not a claim that every entry point works in browsers or Workers.

### Peer dependencies

This package declares no peer dependencies. Its ordinary dependencies are resolved by the package manager.

For a complete first program, start with [Getting started](/docs/getting-started). For API lookup and partial-example conventions, see [Reading the reference](/docs/packages#reading-the-reference). Provider failures, lifecycle requirements and application responsibilities remain described in the guide above; types alone do not establish production safety.

{/* package-reference:end */}
