Accounts

Foundation
@wabbit/tome-accountsv0.3.0

Tome multi-tenant SaaS accounts layer — accounts, memberships (seats/roles), projects, and an account-scoped authority resolver built on the converged @wabbit/tome-core permission engine.

Installnpm install @wabbit/tome-accounts

Overview

@wabbit/tome-accounts

Tome multi-tenant SaaS accounts layer — accounts, memberships (seats/roles), projects, and an account-scoped authority resolver built on the converged @wabbit/tome-core permission engine. Description copied verbatim from package.json.

Layer: domain (per ARCHITECTURE.md). Standalone within this batch — no dependency on crm/lms/catalog/org/deals/economy/marketing; built entirely on @wabbit/tome-core's permission engine. Per its own barrel docblock: "It does NOT introduce a second permission system" — account permissions are membership-scoped (a user can own one account and be a member of another), but the resolution math is the shared @wabbit/tome-core/auth/permissions engine.

Install

pnpm add @wabbit/tome-accounts

Peer ranges, copied from package.json (v0.2.2):

| Peer | Range | Optional? | |---|---|---| | payload | >=3.67.0 | no | | @wabbit/tome-core | >=1.2.0 <2.0.0 | no |

No other peers. Notably no `lucide-react` — like deals, accounts passes a string (iconName: 'IdCard') to the admin-nav manifest rather than importing an icon component, so it carries no icon-package dependency (verified: zero lucide-react references in src/).

60-second quickstart

The current API is createAccountsLayer(config), returning the Phase-1a collection trio (Account, Membership, Project):

import { buildConfig } from 'payload'
import { createAccountsLayer } from '@wabbit/tome-accounts'

export default buildConfig({
  collections: [
    ...existingCollections, // incl. users + a members identity collection
    ...createAccountsLayer({ memberSlug: 'members' }),
  ],
})

Phase-1b governance/audit collections are opt-in via config flags, off by default:

createAccountsLayer({
  memberSlug: 'members',
  auditLog: true,      // adds createAuditLogCollection()
  approvals: true,     // adds createAccountApprovalCollection()
})

createAccountsLayer also calls registerAccountPermissions() to register the account permission namespace into the shared engine — idempotent, since the registration module also self-registers on import.

API surface

Single export subpath (. only) — the simplest exports map of the eight packages in this batch.

| Group | Exports | |---|---| | Layer factory | createAccountsLayer | | Collection factories | createAccountCollection, createMembershipCollection, createProjectCollection | | Types | ACCOUNTS_DEFAULT_SLUGS, AccountsSlugs, AccountsIdentityConfig, BaseAccountsCollectionConfig, AccountCollectionConfig, MembershipCollectionConfig, ProjectCollectionConfig, AccountsLayerConfig | | Permission taxonomy | ACCOUNT_PERMISSIONS, ACCOUNT_SUPER_PERMISSIONS, ALL_ACCOUNT_PERMISSION_VALUES, ACCOUNT_ROLES, ACCOUNT_ROLE_PERMISSIONS, ACCOUNT_OWNER_ROLE, registerAccountRole, getRolePermissionValues (+ AccountPermissionKey, AccountPermissionValue, AccountRole) | | Permission registration | registerAccountPermissions, buildAccountSuperPermissionMap | | Authority resolver (Phase 1a) | getAccountAuthority, accountHasPermission, authoritySatisfies, accountPermission, accountMember, accountScopedRead (+ AccountAuthority, AccountAuthorityOptions, AccountAccessConfig) | | Per-project ABAC (Phase 1b) | canActOnProject, authorityCanActOnProject, hasAccountWideProjectReach, projectScopedReadForAccount, projectScopedFieldAccess, projectScopedQuery (+ ProjectScopedAccessConfig) | | Membership lifecycle (Phase 1b) | MEMBERSHIP_STATUSES, ACTIVE_MEMBERSHIP_STATUSES, MEMBERSHIP_TRANSITIONS, isActiveMembershipStatus, canTransitionMembership, transitionMembership, acceptInvitation, suspendMembership, reactivateMembership, MembershipTransitionError, executeMembershipOffboarding (+ 6 related types) | | Approval pipeline (Phase 1b, optional) | ACCOUNT_APPROVAL_REQUEST_SLUG, APPROVAL_REQUEST_STATUSES, createAccountApprovalCollection, submitAccountRequest, approveAccountRequest, denyAccountRequest (+ 5 related types) | | Audit (Phase 1b, optional) | ACCOUNT_AUDIT_LOG_SLUG, createAuditLogCollection, writeAudit (fire-and-forget per source comment), auditStatusRoleChangeHook (+ AuditEntry, AuditLogCollectionConfig, WriteAuditOptions, AuditChangeHookConfig) | | Hooks | autoSlugHook, assertUniqueMembership, assertUniqueProjectSlug |

Server / client posture

Fully server-side: Payload collection factories, access/authority resolvers, and permission registration — no React anywhere in the package. sideEffects: ["./dist/registerAccountPermissions.*"] — the permission-registration module self-registers on import and must survive tree-shaking, the same pattern as @wabbit/tome-org's registerOrgPermissions.

Links

  • Design spec: docs/superpowers/specs/2026-06-27-tome-accounts-and-token-management-design.md
  • Threat model: docs/superpowers/specs/2026-06-29-tome-accounts-token-project-caps-threat-model.md
  • CHANGELOG

Extending this package

Per the barrel's own docblock, this package stops at Phase 1a (Account/Membership/Project + authority resolver + access factories) with Phase 1b (lifecycle/governance/audit, already present and gated behind auditLog/approvals config flags). Phases 2-4 — the token model, the account-scoped proxy, account-scoped billing, and the /account dashboard — are explicitly not part of this package; the dashboard lives in wabbit-site-core. Custom account roles register via registerAccountRole rather than extending ACCOUNT_ROLES directly.

Exports

  • @wabbit/tome-accounts

Changelog

v0.3.0minor

3087d61: Platform operators can see tenant accounts again — `accountScopedRead`, `accountMember` and `accountPermission` now honour a super-admin bypass. Every gate in `access/accountAccess.ts` asked one question: does the viewer have standing INSIDE this account. That is correct for tenant users and wrong for the operator running the platform, who is a member of no customer account — so a super-admin's own admin panel filtered out every customer's Account, Membership and Project. The failure was silent: the list rendered EMPTY rather than forbidden, which reads as "the row was never created" and sends you debugging a provisioning hook that is working fine. The bypass mirrors `orgScoped` / `vendorScoped` in `@wabbit/tome-core` (R4 ruling #1 — capability OR legacy-role, additive), so the platform keeps one way of saying "an operator outranks tenant scoping": - `platformAdminRoles` (default `['super-admin', 'admin']`) — the legacy flat `user.role` leg. - `platformAdminCapability` (default `'accounts:manage'`) — checked via `canAsync`, which hydrates a `users.roles` RELATIONSHIP at depth 1 and treats a `super-admin` slug as an implicit `'*'` grant. Sites whose roles live in the relation (rather than the flat field) are covered by this leg with no configuration. - `platformAdminBypass: false` — opt out entirely, for deployments where operators must not read tenant data. `accountScopedRead` returns `true` rather than a `Where` for an operator, deliberately: an operator must also see rows whose account relationship is null or orphaned, which no `{account: {in: [...]}}` filter would ever match — and those are precisely the rows worth looking at when provisioning has gone wrong. No behaviour change for tenant users: non-admins are scoped exactly as before.

  • 3087d61: Platform operators can see tenant accounts again — `accountScopedRead`, `accountMember` and `accountPermission` now honour a super-admin bypass. Every gate in `access/accountAccess.ts` asked one question: does the viewer have standing INSIDE this account. That is correct for tenant users and wrong for the operator running the platform, who is a member of no customer account — so a super-admin's own admin panel filtered out every customer's Account, Membership and Project. The failure was silent: the list rendered EMPTY rather than forbidden, which reads as "the row was never created" and sends you debugging a provisioning hook that is working fine. The bypass mirrors `orgScoped` / `vendorScoped` in `@wabbit/tome-core` (R4 ruling #1 — capability OR legacy-role, additive), so the platform keeps one way of saying "an operator outranks tenant scoping": - `platformAdminRoles` (default `['super-admin', 'admin']`) — the legacy flat `user.role` leg. - `platformAdminCapability` (default `'accounts:manage'`) — checked via `canAsync`, which hydrates a `users.roles` RELATIONSHIP at depth 1 and treats a `super-admin` slug as an implicit `'*'` grant. Sites whose roles live in the relation (rather than the flat field) are covered by this leg with no configuration. - `platformAdminBypass: false` — opt out entirely, for deployments where operators must not read tenant data. `accountScopedRead` returns `true` rather than a `Where` for an operator, deliberately: an operator must also see rows whose account relationship is null or orphaned, which no `{account: {in: [...]}}` filter would ever match — and those are precisely the rows worth looking at when provisioning has gone wrong. No behaviour change for tenant users: non-admins are scoped exactly as before.
v0.2.3patch

36e537a: `registerLayer` is now statically imported (forms/intake pattern) instead of lazily `require()`d in ten layer packages' init/register paths. The lazy pattern silently no-ops under Payload's native-ESM CLI (`generate:types` / `generate:importmap`), so layer registration could vanish without error. Packages whose tome-core peer is genuinely optional (economy, ai, gamification) deliberately keep the guarded lazy path; tome-core's `admin-nav/self-register.ts` deliberately keeps its subpath `require()` (documented ESM/CJS dual-cache fix — do not convert).

  • 36e537a: `registerLayer` is now statically imported (forms/intake pattern) instead of lazily `require()`d in ten layer packages' init/register paths. The lazy pattern silently no-ops under Payload's native-ESM CLI (`generate:types` / `generate:importmap`), so layer registration could vanish without error. Packages whose tome-core peer is genuinely optional (economy, ai, gamification) deliberately keep the guarded lazy path; tome-core's `admin-nav/self-register.ts` deliberately keeps its subpath `require()` (documented ESM/CJS dual-cache fix — do not convert).
  • 36e537a: Every package now declares an explicit `sideEffects` field (38 added; motion/engine/forms already correct). Registration-bearing modules (render files' `registerRenderer`, `blocks/*/index.ts` `defineBlock` self-registration, widget `register.ts` files, productHooks, permission self-registrations, print templates, chrome built-in variants) are listed so bundlers can tree-shake everything else WITHOUT dropping import-time registrations — previously the field was unset, which blocked cross-module tree-shaking through the barrels entirely. Never blanket `false` on a package with registration or CSS.
v0.2.2patch

dca85a3: Core runtime-floor sweep: each package's `@wabbit/tome-core` peer floor now matches the newest core runtime export it actually imports, instead of the platform-wide `>=1.0.0` baseline from the original peer-range sweep. The stale floors let npm silently install a package next to a core version missing a module it runtime-imports, producing a hard `next build` failure at import time (reproduced 2026-07-11: tome-starter locked core 1.0.12 + admin 0.6.3 — `isAdminNavDomain` does not exist in core 1.0.x, where `registry/adminNav` was type-only). - `@wabbit/tome-admin` → `>=1.3.0 <2.0.0` — `nav/manifestResolver` runtime-imports `isAdminNavDomain` from `registry/adminNav`, first shipped as a runtime export in core 1.3.0 (Sidebar v2 Wave 0, d8ff1b2). - `@wabbit/tome-deals` → `>=1.1.0 <2.0.0` — runtime-imports `auth/repScoping` (`buildRepWhereClause` et al.) and `utilities/normalize` (`normalizeEmail`), both introduced in core 1.1.0 (consolidation pass, a9801fe). - `@wabbit/tome-accounts` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` (`roleSatisfiesPermission`, permission registration), introduced in core 1.2.0 (platform permission engine, 9238072). - `@wabbit/tome-org` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` (`checkPermissionHierarchical` et al.). - `@wabbit/tome-sc` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` across access helpers and military collections. Same defect class as the `tome-crm` floor raise to `>=1.1.0` (b027075); `tome-crm` is already correct and unchanged here.

  • dca85a3: Core runtime-floor sweep: each package's `@wabbit/tome-core` peer floor now matches the newest core runtime export it actually imports, instead of the platform-wide `>=1.0.0` baseline from the original peer-range sweep. The stale floors let npm silently install a package next to a core version missing a module it runtime-imports, producing a hard `next build` failure at import time (reproduced 2026-07-11: tome-starter locked core 1.0.12 + admin 0.6.3 — `isAdminNavDomain` does not exist in core 1.0.x, where `registry/adminNav` was type-only). - `@wabbit/tome-admin` → `>=1.3.0 <2.0.0` — `nav/manifestResolver` runtime-imports `isAdminNavDomain` from `registry/adminNav`, first shipped as a runtime export in core 1.3.0 (Sidebar v2 Wave 0, d8ff1b2). - `@wabbit/tome-deals` → `>=1.1.0 <2.0.0` — runtime-imports `auth/repScoping` (`buildRepWhereClause` et al.) and `utilities/normalize` (`normalizeEmail`), both introduced in core 1.1.0 (consolidation pass, a9801fe). - `@wabbit/tome-accounts` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` (`roleSatisfiesPermission`, permission registration), introduced in core 1.2.0 (platform permission engine, 9238072). - `@wabbit/tome-org` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` (`checkPermissionHierarchical` et al.). - `@wabbit/tome-sc` → `>=1.2.0 <2.0.0` — runtime-imports `auth/permissions` across access helpers and military collections. Same defect class as the `tome-crm` floor raise to `>=1.1.0` (b027075); `tome-crm` is already correct and unchanged here.
v0.1.2patch

bed3f90: Docs-manifest emitter pipeline (W3 ship-readiness). `@wabbit/tome-blocks-core` now ships a standalone Node ESM CLI at `scripts/emit-docs-manifests.mjs` that emits per-package documentation manifests (index.json, packages/<slug>.json, changelog.json) by reading what packages already carry — READMEs, the payload-free `<pkg>/meta` block-usage barrels, package.json exports maps, and CHANGELOG.md. It is the docs-pipeline sibling of the gallery source extractor and is consumed by host sites at prebuild: `node node_modules/@wabbit/tome-blocks-core/scripts/emit-docs-manifests.mjs --output-dir <dir> --scope <scope.json>`. To let the emitter import block metadata uniformly without dragging Payload config into a build script, the `./meta` payload-free subpath (BlockMetaEntry[]) is extended to the remaining offered blocks packs — agency-essentials, catalog-pack, lms-pack, org-pack, and signal-theme — mirroring the existing editorial-pack / marketing-starter / content-writer / extras barrels. Each block's `BlockMeta` was relocated verbatim into a payload-free sibling meta module and re-imported by its block config; no meta values changed. Every supported-core package additionally adds `CHANGELOG.md` to its published `files` array so the next publish cascade ships changelogs the emitter can read from installed tarballs at prebuild.

  • bed3f90: Docs-manifest emitter pipeline (W3 ship-readiness). `@wabbit/tome-blocks-core` now ships a standalone Node ESM CLI at `scripts/emit-docs-manifests.mjs` that emits per-package documentation manifests (index.json, packages/<slug>.json, changelog.json) by reading what packages already carry — READMEs, the payload-free `<pkg>/meta` block-usage barrels, package.json exports maps, and CHANGELOG.md. It is the docs-pipeline sibling of the gallery source extractor and is consumed by host sites at prebuild: `node node_modules/@wabbit/tome-blocks-core/scripts/emit-docs-manifests.mjs --output-dir <dir> --scope <scope.json>`. To let the emitter import block metadata uniformly without dragging Payload config into a build script, the `./meta` payload-free subpath (BlockMetaEntry[]) is extended to the remaining offered blocks packs — agency-essentials, catalog-pack, lms-pack, org-pack, and signal-theme — mirroring the existing editorial-pack / marketing-starter / content-writer / extras barrels. Each block's `BlockMeta` was relocated verbatim into a payload-free sibling meta module and re-imported by its block config; no meta values changed. Every supported-core package additionally adds `CHANGELOG.md` to its published `files` array so the next publish cascade ships changelogs the emitter can read from installed tarballs at prebuild.
v0.1.1patch

598611c: Fix non-atomic approval CAS (double-execution race). `approveAccountRequest` and `denyAccountRequest` claimed a pending request via `payload.update({ where: { id, status: 'pending' } })`, which the `@payloadcms/db-mongodb` adapter implements as FIND-then-`updateMany` (read-then-write) — so two concurrent approvers both read `pending` and both win, double-running the `execute` callback. The claim now uses the adapter's atomic `Model.findOneAndUpdate({ _id, status: 'pending' } -> next)` (the same primitive the adapter uses for its own job-queue claims), with a documented non-atomic bulk-update fallback for non-Mongo adapters. The test fake was upgraded to model the adapter's real (non-atomic bulk update vs. atomic findOneAndUpdate) behavior, giving the concurrent-approver test genuine teeth.

  • 598611c: Fix non-atomic approval CAS (double-execution race). `approveAccountRequest` and `denyAccountRequest` claimed a pending request via `payload.update({ where: { id, status: 'pending' } })`, which the `@payloadcms/db-mongodb` adapter implements as FIND-then-`updateMany` (read-then-write) — so two concurrent approvers both read `pending` and both win, double-running the `execute` callback. The claim now uses the adapter's atomic `Model.findOneAndUpdate({ _id, status: 'pending' } -> next)` (the same primitive the adapter uses for its own job-queue claims), with a documented non-atomic bulk-update fallback for non-Mongo adapters. The test fake was upgraded to model the adapter's real (non-atomic bulk update vs. atomic findOneAndUpdate) behavior, giving the concurrent-approver test genuine teeth.