Documentation menu

Documentation / Community engine / accounts

Accounts

Package
@wabbit/tome-accounts
Version
v0.5.3
npm install @wabbit/tome-accounts
First install from our registry?
  1. Get a free registry token from your credentials page. Every install from our registry needs one, free packages included.

  2. Add the registry and your token to the .npmrc at the root of your project, with your token in place of YOUR_TOKEN:

    @wabbit:registry=https://npm.wabbit.com/
    //npm.wabbit.com/:_authToken=YOUR_TOKEN
Community engineStable

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.

Overview

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 the repo's ARCHITECTURE.md § The Layer Model). 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.

Peer dependencies

Peer ranges, copied from package.json:

PeerRangeOptional?
payload>=3.67.0no
@wabbit/tome-core>=1.17.0 <2.0.0no

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()
  invitations: true,   // adds createAccountInvitationCollection()
})

Invitations (opt-in)

invitations: true adds an account-invitations collection and unlocks the invitation helpers. Pass an object to tune it: { slug, ttlDays /* default 7 */, ownerRoles /* default ['owner'] */, roles /* extra Membership role options */ }.

const { rawToken, invitation } = await createAccountInvitation(payload, {
  account, email, role: 'editor', invitedBy: memberId, req, // req = the owner's request
}) // rawToken is returned ONCE; only its SHA-256 digest is stored

await validateAccountInvitation(payload, token)   // { status: 'valid' | 'expired' | 'revoked' | 'accepted' | 'invalid' }
await acceptAccountInvitation(payload, { rawToken: token, member: { id, email } })
await resendAccountInvitation(payload, { invitationId, req }) // rotates the token, old link dies
await revokeAccountInvitation(payload, { invitationId, req })

Roles are consumer-defined: any role string is accepted (register its permissions with registerAccountRole). Only a seat holding one of ownerRoles can create, revoke, resend or list invitations; accepting is the token-holder path. Accepting is single-use (atomic compare-and-set), checks expiry and revocation, and by default requires the accepting member's email to equal the invited address; pass requireEmailMatch: false when you deliver the link yourself and vouch for the recipient. A removed seat is re-admitted on its existing row (removed -> invited -> active). The built-in owner role is refused unless allowOwnerRole: true. Delivery (email, copy-link) and the routes are yours.

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

The accounts slug and better-auth

With defaults, this layer's Account collection and better-auth's own account model both want the slug accounts. The platform's cross-layer slug guard refuses to boot when that happens (LayerCollectionSlugCollisionError), so you will see it at startup, not as mixed data. Resolve it on ONE side:

  • rename this layer's collection: createAccountsLayer({ memberSlug: 'members', slugs: { accounts: 'client-accounts' } }), or
  • rename better-auth's model in @wabbit/tome-core's better-auth factory: internalModelNames: { account: 'authAccount' } → slug authAccounts.

Renaming both only moves the collision. If the site is already live, pick the side whose collection is empty, or migrate the rows first — see the migration note on the auth factory option in @wabbit/tome-core.

API surface

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

GroupExports
Layer factorycreateAccountsLayer
Collection factoriescreateAccountCollection, createMembershipCollection, createProjectCollection
TypesACCOUNTS_DEFAULT_SLUGS, AccountsSlugs, AccountsIdentityConfig, BaseAccountsCollectionConfig, AccountCollectionConfig, MembershipCollectionConfig (adds extraRoles), ProjectCollectionConfig, AccountsLayerConfig, AccountBilling
Billing refsresolveAccountBillingRefs (+ AccountBillingRefsInput, AccountBillingRefsSource, ResolvedAccountBillingRefs) — see Billing refs
Permission taxonomyACCOUNT_PERMISSIONS, ACCOUNT_SUPER_PERMISSIONS, ALL_ACCOUNT_PERMISSION_VALUES, ACCOUNT_ROLES, ACCOUNT_ROLE_PERMISSIONS, ACCOUNT_OWNER_ROLE, registerAccountRole, getRolePermissionValues (+ AccountPermissionKey, AccountPermissionValue, AccountRole)
Permission registrationregisterAccountPermissions, buildAccountSuperPermissionMap
Authority resolver (Phase 1a)getAccountAuthority, accountHasPermission, authoritySatisfies, accountPermission, accountMember, accountScopedRead (+ AccountAuthority, AccountAuthorityOptions, AccountAccessConfig, and PlatformAdminOptions — the operator bypass that AccountAccessConfig extends: platformAdminRoles (default ['super-admin', 'admin'], read from the flat user.role), platformAdminCapability (default 'accounts:manage', checked with canAsync, so super-admin passes without a grant) and platformAdminBypass (default true; false gives strict tenant scoping with no operator bypass))
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 (a removed seat may move to invited), 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)
Invitations (optional)createAccountInvitationCollection, createAccountInvitation, validateAccountInvitation, acceptAccountInvitation, revokeAccountInvitation, resendAccountInvitation, normalizeInvitationEmail, generateInvitationToken, hashInvitationToken, safeEqualHex, INVITATION_TOKEN_BYTES, ACCOUNT_INVITATION_DEFAULT_SLUG, ACCOUNT_INVITATION_STATUSES, ACCOUNT_INVITATION_DEFAULT_TTL_MS (+ AccountInvitationStatus, AccountInvitationCollectionConfig, InvitationHelperOptions, InvitationFailureCode, InvitationResult, AccountInvitationDoc, CreateAccountInvitationArgs, ValidateInvitationResult, InvitationAcceptor, AcceptAccountInvitationArgs, AcceptInvitationResult)
HooksautoSlugHook, assertUniqueMembership, assertUniqueProjectSlug

Billing refs

The Account collection carries two shapes for provider billing refs:

  • Legacy (deprecated): stripeCustomerId / stripeSubscriptionId — Stripe-named, no provider tag. Kept for backward compatibility; not removed, since a rename would be a platform-wide breaking migration to unblock one consuming layer.
  • Current: an additive billing group — { provider: 'stripe' | 'authorizenet' | 'manual' | string, customerRef, subscriptionRef, updatedAt }. provider defaults to 'stripe' and is validated non-empty. Both the group and the legacy fields are admin-readOnly (server-stamped, never owner-editable).

Read either shape through resolveAccountBillingRefs(account), which reads billing first (when it carries a customerRef or subscriptionRef) and falls back to the legacy Stripe fields (tagged provider: 'stripe') when billing is unpopulated:

import { resolveAccountBillingRefs } from '@wabbit/tome-accounts'

const refs = resolveAccountBillingRefs(account)
// { provider, customerRef: string | null, subscriptionRef: string | null, source: 'billing' | 'legacy' | 'none' }

source tells the caller which shape answered — useful for a migration sweep that wants to know how many accounts still only have the legacy shape. A pure function: no Payload import, safe to call against a fixture in a test.

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.

  • CHANGELOG.md

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

Import path
@wabbit/tome-accounts

Changelog

0.5.xMinor

  • createAccountsLayer now accepts invitations: true, adding an account-invitations collection and helpers to invite, accept, revoke and resend email invitations.

    Invite tokens are generated from a CSPRNG, returned once and stored only as a SHA-256 digest. Accepting is single-use (atomic compare-and-set), checks expiry (7-day default, configurable with ttlDays) and revocation, and requires the invited email unless requireEmailMatch: false.

    Roles are consumer-defined; only seats holding the configured ownerRoles can create, revoke, resend or list invitations.

    A removed seat can now be re-invited: the seat machine allows removed -> invited, and accepting re-admits the existing row. createMembershipCollection gains an extraRoles option for consumer-defined roles.

    Every option is off by default and existing behavior is unchanged.

  • Security: owner seats and account billing state are now protected over the public API.

    Access-checked writes that used to succeed are now refused.

    MANAGE_OWNER was defined but never enforced, so any role with MANAGE_MEMBERS (admin by default) could PATCH its own seat to owner and then remove the real owner. Membership writes now need MANAGE_OWNER to create or update a seat to an owner role, to change, suspend, remove or delete a seat that holds one, and a seat can no longer be moved to another account.

    The last active owner seat cannot be removed, suspended, demoted or deleted through the API. Owner roles come from the new ownerRoles option on createMembershipCollection; createAccountsLayer reuses invitations.ownerRoles for it (default ['owner']).

    MANAGE_MEMBERS behavior for non-owner seats is unchanged, and so are platform operators.

    On the Account collection, MANAGE_ACCOUNT no longer covers lifecycle and billing state. billing, stripeCustomerId and stripeSubscriptionId are refused for every access-checked create and update, status is writable only by platform operators, and ownerMember needs MANAGE_OWNER.

    name, slug and type stay editable. Server code that writes these fields (billing webhooks, provisioning, lifecycle helpers) must use overrideAccess: true, which bypasses all of the above.

    Create-time gaps closed on the same fields. billing (the group and every subfield, including billing.provider) is now locked on create as well as update, so an API caller can no longer POST an account with billing.provider: 'manual' and have entitlement checks read it as paid; a denied provider falls back to the 'stripe' default.

    status on create is platform-operator-only (anyone else gets the 'active' default), and ownerMember on create must be the creating user's own member profile, or the caller must be a platform operator. A create naming another member is refused by the field's required check.

    Provisioning flows that create accounts on someone's behalf must use overrideAccess: true.

3 fixes (0.5.1 – 0.5.3)
  • 0.5.3
    The changelog, README and license text now use American spellings.

    No code or behavior changes.

  • 0.5.2

    README: subpath and file mentions are written package-qualified (for example @wabbit/tome-x/render) or as code text, so crawlers no longer resolve them as site URLs.

  • 0.5.1
    Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps.

    What you install: one compiled .js (ESM) and .cjs (CommonJS) file per source module, its .d.ts / .d.cts declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license: /*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */.

    The .map files and the sourceMappingURL comments that pointed at them are gone, which roughly halves the size of each tarball.

    Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line.

    A 'use client' directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged.

    No API change, no runtime behavior change, and nothing to do on upgrade.

    In @wabbit/tome-blocks-gallery, the source snapshots extractGallerySource writes from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.

0.4.xMinor

  • Additive billing group on the Account collection (provider, customerRef, subscriptionRef, updatedAt), plus resolveAccountBillingRefs(account) to read it.

    Account.ts's stripeCustomerId/stripeSubscriptionId carry no provider tag, so a consumer resolving entitlements against a second billing provider (Authorize.net, a staff-stamped manual invoice) cannot tell which provider those refs belong to. The legacy fields are kept and marked deprecated (a rename would be a platform-wide breaking migration to unblock one consuming layer) rather than removed; resolveAccountBillingRefs reads billing first and falls back to the legacy Stripe-named fields, tagging the result source: 'billing' | 'legacy' | 'none' so a caller — or a future migration sweep — can tell which shape answered.

    New export, hence the minor.

5 fixes (0.4.1 – 0.4.4)
  • 0.4.4

    README: document the default accounts slug collision with better-auth's account model — what the boot-time guard reports and the two one-sided resolutions (slugs.accounts on this layer, or renaming the better-auth model in tome-core's auth factory).

  • 0.4.3
    The platform-operator bypass now reads legacy role slugs with core's role reader instead of the deprecated checkRole; who bypasses account scoping is unchanged.

    An empty platformAdminRoles still disables the legacy leg. Internal: collection slugs are typed through the shared typedSlug() helper instead of inline casts.

  • 0.4.2
    autoSlugHook is now a typed delegate to core's autoSlugFieldHook, with an identical body.

    Two domain layers cannot import each other, so the shared body moved down to core.

  • 0.4.2
    Local relationship-id helpers are replaced by @wabbit/tome-core/utilities/relationId.

    Each call site maps to the core reader with the same return shape (raw vs. stringified id, null vs. undefined, polymorphic), so behavior is unchanged. The @wabbit/tome-core peer floor goes up to >=1.17.0 because that is the first core version exporting relationIdRaw, relationIds and relationIdsRaw.

  • 0.4.1
    Fix a silent Payload collection-slug collision between createBetterAuth() and createAccountsLayer() that broke every sign-up on a consumer mounting both at their defaults.

    Root cause: better-auth's internal account model and @wabbit/tome-accounts' tenant Account collection both default to the Payload slug accounts. The collections plugin that assembled them merged the two definitions instead of failing, producing a collection whose required name/slug/ownerMember fields (from tome-accounts) were never supplied by better-auth's own linkAccount/createAccount calls — every POST /api/auth/sign-up/email returned a 500 naming those fields, not the collision that caused them.

    The fix (`@wabbit/tome-core`):

    • registerLayer (utilities/layerRegistry) now checks a newly-registering layer's collections against every already-registered layer's collections and throws a new LayerCollectionSlugCollisionError — naming the slug, both claimant layers, and a remedy — the moment two layers claim the same slug, regardless of which two layers or which mount order. Exported alongside a isLayerCollectionSlugCollisionError type guard so a layer factory's own try/catch (most exist only to swallow the pre-existing "already registered" HMR throw) can re-throw a genuine collision instead of silently eating it.
    • createBetterAuth() now registers the Payload collection slugs it mounts (@wabbit/tome-core-auth) with this guard, and its own try/catch re-throws a collision instead of swallowing it.
    • New opt-in internalModelNames.account on createBetterAuth() renames better-auth's internal account model (e.g. { account: 'authAccount' } → Payload slug authAccounts) — the auth-side resolution when a collision fires. Default is unchanged (accounts), so upgrading never renames an existing consumer's table. A consumer that opts in AFTER going live must rename or migrate the existing accounts table/collection first — see the option's JSDoc.

    The fix (`@wabbit/tome-accounts`): createAccountsLayer()'s existing registerLayer try/catch now re-throws a genuine LayerCollectionSlugCollisionError instead of swallowing it as a duplicate-registration no-op. The layer's existing slugs: { accounts: '<other-slug>' } override is the accounts-side resolution — pick ONE side, not both.

    Backward compatibility: a consumer mounting only one of the two layers is unaffected — both new tests and the existing suites confirm no throw and identical registered collections. Both defaults (accounts on each side) are unchanged; the guard only fires when two layers genuinely collide.

0.3.xMinor

  • Platform operators can see tenant accounts again — accountScopedRead, accountMember and accountPermission now honor 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 (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 behavior change for tenant users: non-admins are scoped exactly as before.

5 fixes (0.3.1 – 0.3.3)
  • 0.3.3
    One LayerFactoryConfig every layer factory's config extends, and one factory verb.

    Fourteen layer packages end in the same one call a consumer writes into payload.config.ts, and no two agreed on what config may contain: full seam vocabulary in three (org, lms, ledger), partial in six, NONE in six (2026-09-01 sale-readiness audit §5.3). A site that learned adminGroup from org and hooks from sc discovered, package by package, that six factories accept neither — not because the seam had been rejected, but because nothing said it existed.

    New in core (a NEW exports-map subpath, hence the minor): @wabbit/tome-core/utilities/layerFactoryConfig exports the LayerFactoryConfig interface — adminGroup, access (per-collection override map), hooks (appended via mergeHooks, never replacing), extraFields, fieldOverrides, omitFields, fieldOrder, slugs — and applyLayerFactoryConfig(collections, config), which honors the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to fields/fieldShape's applyFieldShape so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config.

    It is a separate subpath from ./utilities/layerRegistry deliberately — that module is in core's sideEffects array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph.

    The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed slugs?: Partial<XSlugs> map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's Record<string, string | undefined>), and named <name>Slug?: string scalars for relationship targets in OTHER layers (memberSlug, mediaSlug, eventSlug, rolesSlug) — those are pointers out of a layer, not entries in its key set.

    *Every `createLayer config now extends it.** Twelve extend LayerFactoryConfig directly and APPLY it through applyLayerFactoryConfig (accounts, catalog, crm, crowdfund, deals, fulfillment, lms, marketing, org, sc) or through a targeted application (chrome). Additive in every case: for the six that accepted none of the seams (deals, economy, gamification, marketing, plus forms/intake, see below), the fields are new; for the rest, adminGroup` and friends keep their existing meaning and the applier is a no-op when they are omitted.

    Two packages accept the vocabulary but do NOT yet apply it, and say so in their type's JSDoc in the required form ("accepted, not yet applied — trigger: …"). economy and gamification both declare @wabbit/tome-core as an OPTIONAL peer and hold zero runtime imports of it — gamification reaches registerLayer through a lazy require() in a try/catch for exactly this reason.

    applyLayerFactoryConfig is a runtime VALUE, so importing it at module scope would convert an optional peer into a required one and break every site that installs those packages without core; copying the applier locally is barred by assert:no-forked-primitives. The trigger is stated: the day core becomes a required peer, delete the note and add one line.

    Both take the type via import type, which is erased at runtime.

    Two packages drop seams EXPLICITLY rather than accept-and-ignore. chrome extends Omit<LayerFactoryConfig, 'access' | 'hooks' | 'extraFields' | 'fieldOverrides' | 'omitFields' | 'fieldOrder' | 'slugs'> because it returns Payload GLOBALS, not collections — those seven are keyed by collection slug and typed against CollectionConfig, and chrome's slugs already have direct per-surface knobs (header.slug, footer.slug) a parallel map could contradict.

    The one seam it keeps, adminGroup, IS applied: globals carry admin.group exactly as collections do. rpg extends Omit<LayerFactoryConfig, 'access'> because CharacterSheetsConfig is a single collection's config that doubles as the layer factory's config, and its own access already means "this collection's access object" — one level shallower than the base's slug-keyed map.

    Two meanings under one name is the confusion this interface exists to end.

    Factory-verb convergence. Three verbs were live. createWorkflowLayer(config?) is new in @wabbit/tome-workflow (a new export — hence the minor) and returns a spreadable, deliberately EMPTY CollectionConfig[]: this layer is an engine, not a collection set, so the empty array is the honest answer and lets ...createWorkflowLayer() compose exactly like every sibling.

    Its WorkflowLayerConfig omits every seam for the same reason, and exists as the stable place a real option will land. createGamificationLayer and createRpgLayer are pure aliases of registerGamificationLayer / registerRpgLayer.

    initWorkflow, registerGamificationLayer and registerRpgLayer are all @deprecated with sunset at each package's next major; none is removed.

    Forcing function: scripts/assert-layer-factory-contract.mjs + pnpm assert:layer-factory-contract, wired into platform-discipline.yml after assert:layer-version (source reading only, pre-build). Every exported create*Layer must take a config parameter whose type resolves to LayerFactoryConfig — through extends, an intersection, or an explicit Omit<…> — with verb aliases followed to their register*/init* target.

    Before this change it reported 12 violations and 0 conforming; it now reports 15 conforming, 0 violations. Deliberately NOT checked: whether a factory actually applies what it accepts, because a machine cannot tell a documented deferral from an accident, and a gate that forced silent application would be worse than one that forces a stated deferral. docs/guides/create-a-new-layer-package.md gains a "The factory contract" section stating the rule and the three permitted responses.

    Three factories are ALLOWLISTED with a reason each: createAiLayer returns credential wiring and owns no collections, so every seam is meaningless to it; createFormsLayer and createIntakeLayer are owned by the forms+intake access wave running in parallel, whose changes rewrite the same files.

    Peer floors: accounts, catalog, chrome, crm, deals, economy, fulfillment, gamification, marketing and rpg raise @wabbit/tome-core to >=1.14.0 <2.0.0. The new subpaths do not exist below that, and a too-low floor is how ERR_PACKAGE_PATH_NOT_EXPORTED reached crowdfund's consumers once already.

    These are marked patch because the config widening is purely additive; the raised required-peer floor is the reason a release manager may prefer to cut them as minors instead.

  • 0.3.3
    Manifest metadata: homepage, bugs, engines.

    All 46 publishable manifests were missing the three fields a consumer sees before any code (2026-09-01 sale-readiness audit §6). Metadata only — no source, no build, no runtime change.

    • homepage deep-links to that package README on GitHub (.../tree/main/packages/<dir>#readme). Without it a registry page links to the monorepo root and the reader has to guess which of 46 folders they want.
    • bugs.url points at the repo issue tracker, so a paying customer has a place to report a defect that is not email.
    • engines.node is >=22, matching the root engines and .nvmrc set the same day. This is a real floor, not decoration: CI on Node 20 could not expand the glob the block packs use for node --test, and a package installed on Node 20 fails at a runtime the installer cannot connect back to the version.

    The forcing function ships with the change: scripts/assert-manifest-metadata.mjs (root pnpm assert:manifest-metadata, wired into platform-discipline.yml beside assert:license-metadata) fails when any publishable manifest lacks description, repository.directory matching its own folder, homepage, bugs, engines.node equal to the repo floor, license, files or sideEffects. It reported 138 violations before this change and 0 after.

  • 0.3.3
    Collapse the audited serial-await fan-outs in Payload hooks, jobs and access checks.

    No behavior changes — every try/catch, failure reporter and overrideAccess justification is preserved; only the number of round-trips changes.

    `find({ limit: 0 })` → `payload.count()` — limit: 0 sets pagination: false in the Mongo adapter, so the query loads every matching row into memory to produce one number. @wabbit/tome-org documents this as a production incident in hooks/attendance-count-sync.ts:6-11 and had reintroduced it in collections/recruiting/JoinRequest.ts's one-pending-request guard; @wabbit/tome-sc's squadron member recount had the same shape.

    Independent lookups → `Promise.all` / `Promise.allSettled` — org's createGiverWindowAccess (a per-request access check that took two serial round-trips), EventAttendance's display-name composer and AwardPresentation's, sc's RSI profile+org page fetches (the hot path for handle validation, against a third-party host), squadron recalcs, and accounts' three offboarding teardown callbacks. allSettled wherever a branch had its own fallback, so a failed member lookup still cannot stop the event title from resolving. sc's RSI adapter keeps its 404 short-circuit exactly, and ledger's per-leg balance guard decides in leg order so the thrown NegativeBalanceError still names the same wallet the serial version did.

    Independent per-row writes → `batchWrite` (@wabbit/tome-core/utilities/batch) — org's notification open/resolve fan-outs, the division/team cleanup and sunset cascades (up to 1,000 rows each), the event cascade-delete, the non-atomic memberCount fallback; lms's certification-expiry sweep (now paced in WRITE_CHUNK chunks like its sibling reconciler) and the course-delete enrollment drop; workflow's deadline sweep; crowdfund's tier-claim reconcile.

    Same `data` for every row → one bulk `payload.update({ where, data })` — sc's asset-assignment auto-close and the transfer-request GDPR redaction, matching sc/src/gdpr.ts's makeNullRefHandler. The auto-close also drops a latent correctness hazard: its page cursor advanced while its own writes removed rows from the filter it was paging over, so a page boundary could skip assignments.

    Two collection-level fixes. sc's Fleet had two field-level beforeChange hooks each issuing a findByID for the SAME ship on every write; they are now one collection-level hook that reads the ship once and sets both chassisName and name. lms's checkCertificationExpiry re-derived recountHolders once per expired award with no cache; it now recounts once per affected certification, after the sweep — which is also more correct, since only the final count was ever right.

    @wabbit/tome-crowdfund's settleCampaign pledge loop is untouched and now carries an explicit eslint-disable plus the reason: it captures money one pledge at a time against a maxCapturesPerRun budget that only bounds anything if the iterations are serialized.

    @wabbit/tome-org, @wabbit/tome-lms, @wabbit/tome-workflow and @wabbit/tome-crowdfund raise their @wabbit/tome-core peer floor to >=1.14.0, the release that adds ./utilities/batch. org and lms were also understating their floor before this change — both already imported @wabbit/tome-core/jobs, added in core 1.7.0, while declaring >=1.2.0 / >=1.0.0.

  • 0.3.2

    Wave 4 I0 hygiene: dist ships extensioned specifiers (fix-dist-extensions --strict + assert-node-loadable preflight — both dists now raw-Node loadable), registerLayer versions corrected and test-pinned to package.json, accounts' full @wabbit/tome-core/auth barrel import replaced by the auth/guards leaf (the barrel drags the BetterAuth plugin factory).

  • 0.3.1
    Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed).
    • dispatch: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (old client- and universe-specific labels → neutral equivalents).
    • readout: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored.
    • blocks-signal-theme: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client or SC references; config examples de-lored. Pack positioning (SC-tier bundling per OQ-4) unchanged.
    • blocks-extras / blocks-content-writer: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized.
    • blocks-core: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized.
    • blocks-gallery: SourceBadge label for the vngd source value now renders "Legacy" (enum value unchanged).
    • accounts / core / lms / ui / org / admin / motion / longform / cop / blocks: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named a specific client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.

0.2.x

3 fixes (0.2.2 – 0.2.3)
  • 0.2.3
    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).

  • 0.2.3
    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.

  • 0.2.2
    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.

0.1.x

2 fixes (0.1.1 – 0.1.2)
  • 0.1.2
    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.

  • 0.1.1
    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.

Full changelog across all packages