@playstack/accounts
Framework-independent tenancy, membership, ownership, invitations, and fail-closed account scoping.
Pro. Covered by the Playstack Pro License. Registry access is required; check preview availability before installing. See package access.
@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
npm install @playstack/accounts @playstack/auth @playstack/core @playstack/eventsCompose the account service
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
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
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.
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. For API lookup and partial-example conventions, see Reading the reference. Provider failures, lifecycle requirements and application responsibilities remain described in the guide above; types alone do not establish production safety.