Documentation menu
Documentation / Community engine / accounts
Accounts
npm install @wabbit/tome-accountsFirst install from our registry?
Get a free registry token from your credentials page. Every install from our registry needs one, free packages included.
Add the registry and your token to the
.npmrcat the root of your project, with your token in place ofYOUR_TOKEN:@wabbit:registry=https://npm.wabbit.com/ //npm.wabbit.com/:_authToken=YOUR_TOKEN
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:
| Peer | Range | Optional? |
|---|---|---|
payload | >=3.67.0 | no |
@wabbit/tome-core | >=1.17.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()
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' }→ slugauthAccounts.
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.
| Group | Exports |
|---|---|
| Layer factory | createAccountsLayer |
| Collection factories | createAccountCollection, createMembershipCollection, createProjectCollection |
| Types | ACCOUNTS_DEFAULT_SLUGS, AccountsSlugs, AccountsIdentityConfig, BaseAccountsCollectionConfig, AccountCollectionConfig, MembershipCollectionConfig (adds extraRoles), ProjectCollectionConfig, AccountsLayerConfig, AccountBilling |
| Billing refs | resolveAccountBillingRefs (+ AccountBillingRefsInput, AccountBillingRefsSource, ResolvedAccountBillingRefs) — see Billing refs |
| 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, 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) |
| Hooks | autoSlugHook, 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
billinggroup —{ provider: 'stripe' | 'authorizenet' | 'manual' | string, customerRef, subscriptionRef, updatedAt }.providerdefaults 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.
Links
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
createAccountsLayernow acceptsinvitations: true, adding anaccount-invitationscollection 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 unlessrequireEmailMatch: false.Roles are consumer-defined; only seats holding the configured
ownerRolescan 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.createMembershipCollectiongains anextraRolesoption 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_OWNERwas defined but never enforced, so any role withMANAGE_MEMBERS(adminby default) couldPATCHits own seat toownerand then remove the real owner. Membership writes now needMANAGE_OWNERto 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
ownerRolesoption oncreateMembershipCollection;createAccountsLayerreusesinvitations.ownerRolesfor it (default['owner']).MANAGE_MEMBERSbehavior for non-owner seats is unchanged, and so are platform operators.On the Account collection,
MANAGE_ACCOUNTno longer covers lifecycle and billing state.billing,stripeCustomerIdandstripeSubscriptionIdare refused for every access-checked create and update,statusis writable only by platform operators, andownerMemberneedsMANAGE_OWNER.name,slugandtypestay editable. Server code that writes these fields (billing webhooks, provisioning, lifecycle helpers) must useoverrideAccess: true, which bypasses all of the above.Create-time gaps closed on the same fields.
billing(the group and every subfield, includingbilling.provider) is now locked on create as well as update, so an API caller can no longer POST an account withbilling.provider: 'manual'and have entitlement checks read it as paid; a denied provider falls back to the'stripe'default.statuson create is platform-operator-only (anyone else gets the'active'default), andownerMemberon 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'srequiredcheck.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.ctsdeclarations, 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
.mapfiles and thesourceMappingURLcomments 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 snapshotsextractGallerySourcewrites 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
billinggroup on the Account collection (provider,customerRef,subscriptionRef,updatedAt), plusresolveAccountBillingRefs(account)to read it.Account.ts'sstripeCustomerId/stripeSubscriptionIdcarry no provider tag, so a consumer resolving entitlements against a second billing provider (Authorize.net, a staff-stampedmanualinvoice) 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;resolveAccountBillingRefsreadsbillingfirst and falls back to the legacy Stripe-named fields, tagging the resultsource: '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
accountsslug collision with better-auth'saccountmodel — what the boot-time guard reports and the two one-sided resolutions (slugs.accountson 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
platformAdminRolesstill disables the legacy leg. Internal: collection slugs are typed through the sharedtypedSlug()helper instead of inline casts. - 0.4.2
autoSlugHookis now a typed delegate to core'sautoSlugFieldHook, 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,
nullvs.undefined, polymorphic), so behavior is unchanged. The@wabbit/tome-corepeer floor goes up to>=1.17.0because that is the first core version exportingrelationIdRaw,relationIdsandrelationIdsRaw. - 0.4.1
Fix a silent Payload collection-slug collision between
createBetterAuth()andcreateAccountsLayer()that broke every sign-up on a consumer mounting both at their defaults.Root cause: better-auth's internal
accountmodel and@wabbit/tome-accounts' tenant Account collection both default to the Payload slugaccounts. The collections plugin that assembled them merged the two definitions instead of failing, producing a collection whose requiredname/slug/ownerMemberfields (from tome-accounts) were never supplied by better-auth's ownlinkAccount/createAccountcalls — everyPOST /api/auth/sign-up/emailreturned 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'scollectionsagainst every already-registered layer'scollectionsand throws a newLayerCollectionSlugCollisionError— 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 aisLayerCollectionSlugCollisionErrortype 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.accountoncreateBetterAuth()renames better-auth's internal account model (e.g.{ account: 'authAccount' }→ Payload slugauthAccounts) — 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 existingaccountstable/collection first — see the option's JSDoc.
The fix (`@wabbit/tome-accounts`):
createAccountsLayer()'s existingregisterLayertry/catch now re-throws a genuineLayerCollectionSlugCollisionErrorinstead of swallowing it as a duplicate-registration no-op. The layer's existingslugs: { 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 (
accountson each side) are unchanged; the guard only fires when two layers genuinely collide.
0.3.xMinor
Platform operators can see tenant accounts again —
accountScopedRead,accountMemberandaccountPermissionnow honor a super-admin bypass.Every gate in
access/accountAccess.tsasked 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/vendorScopedin@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 flatuser.roleleg.platformAdminCapability(default'accounts:manage') — checked viacanAsync, which hydrates ausers.rolesRELATIONSHIP at depth 1 and treats asuper-adminslug 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.
accountScopedReadreturnstruerather than aWherefor 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
LayerFactoryConfigevery 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 whatconfigmay 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 learnedadminGroupfrom org andhooksfrom 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/layerFactoryConfigexports theLayerFactoryConfiginterface —adminGroup,access(per-collection override map),hooks(appended viamergeHooks, never replacing),extraFields,fieldOverrides,omitFields,fieldOrder,slugs— andapplyLayerFactoryConfig(collections, config), which honors the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated tofields/fieldShape'sapplyFieldShapeso the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config.It is a separate subpath from
./utilities/layerRegistrydeliberately — that module is in core'ssideEffectsarray, 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'sRecord<string, string | undefined>), and named<name>Slug?: stringscalars 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 extendLayerFactoryConfigdirectly and APPLY it throughapplyLayerFactoryConfig(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-coreas an OPTIONAL peer and hold zero runtime imports of it — gamification reachesregisterLayerthrough a lazyrequire()in a try/catch for exactly this reason.applyLayerFactoryConfigis 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 byassert: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 againstCollectionConfig, 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 carryadmin.groupexactly as collections do. rpg extendsOmit<LayerFactoryConfig, 'access'>becauseCharacterSheetsConfigis a single collection's config that doubles as the layer factory's config, and its ownaccessalready 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 EMPTYCollectionConfig[]: 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
WorkflowLayerConfigomits every seam for the same reason, and exists as the stable place a real option will land.createGamificationLayerandcreateRpgLayerare pure aliases ofregisterGamificationLayer/registerRpgLayer.initWorkflow,registerGamificationLayerandregisterRpgLayerare all@deprecatedwith sunset at each package's next major; none is removed.Forcing function:
scripts/assert-layer-factory-contract.mjs+pnpm assert:layer-factory-contract, wired intoplatform-discipline.ymlafterassert:layer-version(source reading only, pre-build). Every exportedcreate*Layermust take a config parameter whose type resolves toLayerFactoryConfig— throughextends, an intersection, or an explicitOmit<…>— with verb aliases followed to theirregister*/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.mdgains a "The factory contract" section stating the rule and the three permitted responses.Three factories are ALLOWLISTED with a reason each:
createAiLayerreturns credential wiring and owns no collections, so every seam is meaningless to it;createFormsLayerandcreateIntakeLayerare 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-coreto>=1.14.0 <2.0.0. The new subpaths do not exist below that, and a too-low floor is howERR_PACKAGE_PATH_NOT_EXPORTEDreached crowdfund's consumers once already.These are marked
patchbecause 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.
homepagedeep-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.urlpoints at the repo issue tracker, so a paying customer has a place to report a defect that is not email.engines.nodeis>=22, matching the rootenginesand.nvmrcset the same day. This is a real floor, not decoration: CI on Node 20 could not expand the glob the block packs use fornode --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(rootpnpm assert:manifest-metadata, wired intoplatform-discipline.ymlbesideassert:license-metadata) fails when any publishable manifest lacksdescription,repository.directorymatching its own folder,homepage,bugs,engines.nodeequal to the repo floor,license,filesorsideEffects. 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
overrideAccessjustification is preserved; only the number of round-trips changes.`find({ limit: 0 })` → `payload.count()` —
limit: 0setspagination: falsein the Mongo adapter, so the query loads every matching row into memory to produce one number.@wabbit/tome-orgdocuments this as a production incident inhooks/attendance-count-sync.ts:6-11and had reintroduced it incollections/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 andAwardPresentation'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.allSettledwherever 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 thrownNegativeBalanceErrorstill 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-atomicmemberCountfallback; lms's certification-expiry sweep (now paced inWRITE_CHUNKchunks 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'smakeNullRefHandler. 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
beforeChangehooks each issuing afindByIDfor the SAME ship on every write; they are now one collection-level hook that reads the ship once and sets bothchassisNameandname. lms'scheckCertificationExpiryre-derivedrecountHoldersonce 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'ssettleCampaignpledge loop is untouched and now carries an expliciteslint-disableplus the reason: it captures money one pledge at a time against amaxCapturesPerRunbudget that only bounds anything if the iterations are serialized.@wabbit/tome-org,@wabbit/tome-lms,@wabbit/tome-workflowand@wabbit/tome-crowdfundraise their@wabbit/tome-corepeer 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
vngdsource 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
registerLayeris now statically imported (forms/intake pattern) instead of lazilyrequire()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'sadmin-nav/self-register.tsdeliberately keeps its subpathrequire()(documented ESM/CJS dual-cache fix — do not convert). - 0.2.3
Every package now declares an explicit
sideEffectsfield (38 added; motion/engine/forms already correct).Registration-bearing modules (render files'
registerRenderer,blocks/*/index.tsdefineBlockself-registration, widgetregister.tsfiles, 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 blanketfalseon a package with registration or CSS. - 0.2.2
Core runtime-floor sweep: each package's
@wabbit/tome-corepeer floor now matches the newest core runtime export it actually imports, instead of the platform-wide>=1.0.0baseline 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 buildfailure at import time (reproduced 2026-07-11: tome-starter locked core 1.0.12 + admin 0.6.3 —isAdminNavDomaindoes not exist in core 1.0.x, whereregistry/adminNavwas type-only).@wabbit/tome-admin→>=1.3.0 <2.0.0—nav/manifestResolverruntime-importsisAdminNavDomainfromregistry/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-importsauth/repScoping(buildRepWhereClauseet al.) andutilities/normalize(normalizeEmail), both introduced in core 1.1.0 (consolidation pass, a9801fe).@wabbit/tome-accounts→>=1.2.0 <2.0.0— runtime-importsauth/permissions(roleSatisfiesPermission, permission registration), introduced in core 1.2.0 (platform permission engine, 9238072).@wabbit/tome-org→>=1.2.0 <2.0.0— runtime-importsauth/permissions(checkPermissionHierarchicalet al.).@wabbit/tome-sc→>=1.2.0 <2.0.0— runtime-importsauth/permissionsacross access helpers and military collections.
Same defect class as the
tome-crmfloor raise to>=1.1.0(b027075);tome-crmis 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-corenow ships a standalone Node ESM CLI atscripts/emit-docs-manifests.mjsthat emits per-package documentation manifests (index.json, packages/<slug>.json, changelog.json) by reading what packages already carry — READMEs, the payload-free<pkg>/metablock-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
./metapayload-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'sBlockMetawas 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.mdto its publishedfilesarray 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).
approveAccountRequestanddenyAccountRequestclaimed a pending request viapayload.update({ where: { id, status: 'pending' } }), which the@payloadcms/db-mongodbadapter implements as FIND-then-updateMany(read-then-write) — so two concurrent approvers both readpendingand both win, double-running theexecutecallback. The claim now uses the adapter's atomicModel.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.