Documentation menu

Documentation / Foundation / core

Core

Package
@wabbit/tome-core
Version
v1.35.0
npm install @wabbit/tome-core
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
FoundationStable

Foundation package for the Tome platform — auth, access control, field factories, data utilities, and infrastructure presets for Payload CMS + Next.js sites; every other @wabbit/tome-* layer builds on this one.

Overview

Foundation package for the Tome platform — auth, access control, field factories, data utilities, and infrastructure presets for Payload CMS + Next.js sites. Every other @wabbit/tome-* layer builds on this one.

Peer dependencies

Published to the private registry at https://npm.wabbit.com/:

Required peer dependencies: payload >=3.67.0, next >=15.0.0, react >=19.0.0, @payloadcms/richtext-lexical >=3.67.0. Optional peers, each gating one feature: posthog-js >=1.0.0 (/infra/posthog); the better-auth stack better-auth >=1.6.0 + @better-auth/passkey >=1.6.0 + @delmaredigital/payload-better-auth >=0.7.0 (auth/betterAuthFactory); lucide-react >=0.460.0 (/utilities/optionalIcon, admin sidebar icons); and the optional plugins @payloadcms/plugin-form-builder >=3.67.0 / @payloadcms/plugin-nested-docs >=3.67.0 / @payloadcms/plugin-redirects >=3.67.0 / @payloadcms/plugin-search >=3.67.0 / @payloadcms/plugin-seo >=3.67.0 (/infra/plugins' basePlugins).

Peer dependencies

Generated from package.json#peerDependencies (the README gate fails if this table and the manifest disagree).

PeerRangeRequired
@better-auth/passkey>=1.6.0no (optional)
@delmaredigital/payload-better-auth>=0.7.0no (optional)
@payloadcms/plugin-form-builder>=3.67.0no (optional)
@payloadcms/plugin-nested-docs>=3.67.0no (optional)
@payloadcms/plugin-redirects>=3.67.0no (optional)
@payloadcms/plugin-search>=3.67.0no (optional)
@payloadcms/plugin-seo>=3.67.0no (optional)
@payloadcms/ui>=3.67.0no (optional)
@payloadcms/richtext-lexical>=3.67.0yes
better-auth>=1.6.0no (optional)
lucide-react>=0.460.0no (optional)
next>=15.0.0yes
payload>=3.67.0yes
posthog-js>=1.0.0no (optional)
react>=19.0.0yes

Quickstart

tome-core has no root barrel: every import names a subpath. A minimal Payload config that uses it for auth, access and the member profile looks like this (shapes taken from tome-starter's payload.config.ts):

// payload.config.ts
import { buildConfig } from 'payload'
import {
  Roles,
  cleanupExpiredSessionsEndpoint,
  createBetterAuth,
  initializeRoles,
} from '@wabbit/tome-core/auth'
import { seedTomeCoreCapabilities } from '@wabbit/tome-core/auth/capabilities'
import { createMemberCollection } from '@wabbit/tome-core/identity'
import { registerDefaultBetterAuthCollections } from '@wabbit/tome-core/gdpr/registry'
import { validateEnv } from '@wabbit/tome-core/infra/env-scaffold'
import { licenseHeartbeat } from '@wabbit/tome-core/license'

// Throws on a missing required variable (DATABASE_URI, PAYLOAD_SECRET, NEXT_PUBLIC_SERVER_URL, ...).
// Pass the optional layers you install, e.g. validateEnv({ layers: ['@wabbit/tome-economy'] }).
validateEnv()

export default buildConfig({
  // db, secret, editor, admin: as in any Payload config
  collections: [
    Roles,
    createMemberCollection(), // 'members', linked 1:1 to 'users'; see "Hosting an existing member table"
    // your Users collection, Pages, Media, ...
  ],
  plugins: [
    // BetterAuth: sessions, OAuth providers, and the session enricher that populates
    // user.role[] / activeOrganizationId / orgRole for the access helpers.
    ...createBetterAuth({
      serverUrl: process.env.NEXT_PUBLIC_SERVER_URL!,
      secret: process.env.BETTER_AUTH_SECRET!,
    }),
  ],
  endpoints: [cleanupExpiredSessionsEndpoint], // CRON_SECRET-guarded
  onInit: async (payload) => {
    registerDefaultBetterAuthCollections() // before any GDPR export or erasure runs
    await initializeRoles(payload) // idempotent default roles
    seedTomeCoreCapabilities() // default capability vocabulary and role grants; after initializeRoles
    void licenseHeartbeat()({ payload }) // report-only, never blocks boot
  },
})

Gate runtime decisions with the capability engine, not role strings:

import type { Access } from 'payload'
import { can, canAsync } from '@wabbit/tome-core/auth/capabilities'

// In a Payload access rule (roles may not be populated yet, so use the async form):
const canReviewDrafts: Access = ({ req }) => canAsync(req.user, req, 'review:drafts')

// Where the user's roles are already populated (e.g. after the session enricher):
if (can(user, 'admin:view')) {
  // ...
}

can(user, ...capabilities) is true only when the user holds every capability listed; super-admin holds all of them. authenticated (from @wabbit/tome-core/auth) is a ready-made Access for "any signed-in user".

Module Surface

All modules are subpath exports (no root barrel). Highlights:

  • `/access` — collection access guards (authenticated, admins, adminsOrSelf) plus orgScoped / vendorScoped multi-tenant wrappers with force-overwrite hooks
  • `/auth/rbac` — role + permission checks; use hasAdminAccessAsync inside Payload access rules for a flat admin gate — sync variants require pre-populated roles and fail closed without them. checkRoleAsync (and every other flat checker in this module) is @deprecated: the one runtime-gate API for new code is can/canAsync from /auth/capabilities
  • `/auth/capabilities` — capability engine (can/canAsync, defineCapabilities, grantCapabilities, seedTomeCoreCapabilities); /auth/capabilities/client ships the React hooks (useCapability, CapabilityGate)
  • `/auth/betterAuthFactory` — createBetterAuth() plugin pair including the customSession enricher that populates user.role[] / activeOrganizationId / orgRole (required by orgScoped)
  • `/fields/slug`, `/fields/link`, `/fields/defaultLexical` — field factories with consumer slug/naming overrides
  • `/data/errors`, `/data/cacheTags` — server-action error hierarchy; cache-tag builders + safeRevalidateTag (safe outside Next.js request contexts)
  • `/content/pageBuilder`, `/content/richTextRenderer` — layout field + RenderBlocks; Lexical → JSX renderer
  • `/identity` — createMemberCollection (see "Hosting an existing member table" below for the seam options), resolveMemberFromSession (request-scoped cache)
  • `/hooks/mergeHooks` — hook-array append utility (per-key, not a wholesale spread) shared by every factory that accepts config.hooks
  • `/lms`, *`/lms/** — **deprecated** v1 LMS collections, hooks, and guards, kept for existing sites until core 2.0; new work uses @wabbit/tome-lms` (data-layer filters; enrollment gating is currently frontend-owned — see guards' jsdoc)
  • `/infra/health`, `/infra/env-scaffold`, `/infra/plugins`, `/infra/posthog` — healthRoute, validateEnv + SHARED_ENV_VARS, basePlugins, PostHog provider + rewrites
  • `/config/siteIdentity` — initSiteConfig() (call once, early, in the app entry) + getSiteConfig()
  • `/registry`, `/utilities/layerRegistry` — layer detection + slot claims (register via lazy require(), never static import — ESM/CJS hold separate caches)
  • `/media/cdnConfig` — Bunny CDN storage + Next image patterns
  • *`/gdpr/`** — cookie consent, data export and erasure, retention, and compliance records; see GDPR subpaths
  • `/verification` — external-profile-verification primitives: createProfileVerificationFlow (HMAC ownership-proof codes + signed tokens), createDistributedRateLimiter (Redis-shaped semaphore, injected client), createResilientFetcher (retry/backoff, explicit retryOn status policy — default 429/>=500, Retry-After honored — never throws on HTTP status), ExternalProfileAdapter<TProfile>, createProfileCacheCollection + createProfileCacheInvalidator, and runProfileReconcile with its two adapters, createProfileReconcileTask (Payload task queue) and createProfileReconcileEndpoint (CRON_SECRET-guarded endpoint)
  • `/field-reports` — subject watchlist + scored risk assessment + free-text field reports: createFieldReportCollection, createSubjectAssessmentCollection (stored field names fully overridable via fieldNames — zero migration for a consumer with an existing table), createWatchlistGlobal (category → score-weight list; pass the SAME categories array to createRiskScorer; stored field names fully overridable via fieldNames.{organizations,externalId,name,category,notes} — e.g. an existing orgs array keyed by sid, not just the id — externalIdField kept as a deprecated alias), createRiskScorer (score/assessAndStore, case-insensitive upsert, injectable weights/bands/thresholds; watchlistFieldNames reads the watchlist global through the SAME names createWatchlistGlobal was given — pass one resolved config to both; flagNames overrides every emitted flag string, including categoryFlag: (categoryValue) => string for the <category>_org mapping) + inputFromProfile (bridges an ExternalProfileAdapter fetch into a RiskScoreInput), createReassessRiskTask/createReassessRiskEndpoint (cache-only rescore — re-reads the watchlist, never re-fetches externally; also takes flagNames so a prior row's flags are recognized under the SAME scorer config), and registerFieldReportsGdpr (reports null-ref on reportedBy; assessments redact handle+notes ONLY on a matching erasure identity, score row kept — see the module's gdpr.ts header for why that is retain-with-basis in effect, not literally mode: 'retain'; reports/assessments each take false to skip registering that collection — e.g. a consumer already registers it elsewhere at a pinned position — or { phase?, order? } to reposition it, instead of relying on a later registration silently overwriting an earlier one by slug)
  • `/field-reports`, location reports — a SUBJECT-SCOPED report shape alongside the handle-scoped one: createLocationReportCollection (one required option, subjectRelationTo; injectable statuses/transitionTable/tiers, the full house seam set — extraFields → fieldOverrides (rename via name) → omitFields → fieldOrder, per-verb access injection merged over the defaults one key at a time, mergeHooks for consumer hooks, labels, relation slugs, slug). The collection's beforeChange logic is split into five named built-ins — statusForcing, authorStamping, contentEditGate, transitionGate, tierDerivation — each individually opt-out-able via builtInHooks. Read access is built from resolveViewerTier(req) plus an optional grantsResolver(req, subjectId), and fails CLOSED on a viewer tier outside tiers. effectiveReportTier(tiers, subjectTier, reportVisibility, fallback?) and stricterTier ship as pure, Payload-free leaves — a report may be more restrictive than its subject, never less. The lifecycle table contract lives in the same subpath (ReportTransitionTable, resolveReportTransition, findReportTransitions, notReportSubmitter) and is structurally identical to @wabbit/tome-workflow's, so a real WorkflowTransitionTable assigns with no cast and that package's own resolveTransition can be injected via transitionResolver; core declares its own copy rather than importing upward into a layer that already depends on core. Server API: transitionReport, reDeriveTier, markStale — all three drive the collection's OWN gates through the local API with the actor injected on req.context (LOCATION_REPORT_ACTOR_CONTEXT_KEY, systemTransition), rather than reaching around them.

Auth subpaths

@wabbit/tome-core/auth is the aggregate entry: it re-exports the constants, the deprecated flat RBAC checkers, the async guards, Roles, initializeRoles, the two cleanup-job endpoints, createBetterAuth, the permission engine and the capability engine. Import a narrower subpath when you want only one of them (and, for client code, only /auth/client or /auth/capabilities/client).

SubpathPurpose
/authAggregate server entry — everything below except the two client bundles
/auth/betterAuthFactorycreateBetterAuth(options) → Payload plugins (BetterAuth + the session enricher populating user.role[] / activeOrganizationId / orgRole); createBetterAuthOptions, CreateBetterAuthOptions, OAuthProviderConfig
/auth/capabilitiesCapability engine — can/canAsync (all listed), canAny/canAnyAsync, canAll/canAllAsync, defineCapabilities, grantCapabilities, getGrantedCapabilities, seedTomeCoreCapabilities, TOME_CORE_CAPABILITIES, TOME_CORE_DEFAULT_GRANTS. The one runtime-gate API for new code
/auth/capabilities/clientReact side of the capability engine — useCapability, useCapabilityAll, useCapabilityAny, CapabilityGate, useTomeSession
/auth/rbacFlat role and permission checkers (checkRoleAsync, hasAdminAccessAsync, …). checkRole/checkPermission and their variants are @deprecated in favor of can/canAsync; hasAdminAccessAsync remains the flat admin gate inside Payload access rules
/auth/clientPre-configured BetterAuth client bundle for the browser (createPayloadAuthClient)
/auth/session-keep-aliveClient component that keeps the session cookie moving for active members (SessionKeepAlive, rollSession, claimKeepAlivePing). Pair it with session.deferSessionRefresh: true; with the setting on and this not mounted, sessions stop sliding
/auth/repScopingThe canonical "rows I own" Where-clause builders for rep-scoped access — admin-bypass, legacy-role fallback, and deny-all semantics in one place
/auth/constantsSingle source of truth for RBAC constants — role slugs, access types, permission keys
/auth/guardsAsync, BetterAuth-compatible access control guards — supersede the sync string-based guards in /access/guards
/auth/collections/roles (case alias /auth/collections/Roles)The Roles collection definition, referenced by the Users collection for RBAC — both spellings are real exports-map keys pointing at the same target
/auth/initRolesIdempotent default-role seeding utility, run during Payload onInit
/auth/jobs/cleanup-sessionsRemoves expired BetterAuth sessions + stale verification tokens; CRON_SECRET-protected
/auth/jobs/cleanup-unverifiedRemoves user accounts created but never email-verified, past a retention window; CRON_SECRET-protected
/auth/permissionsThe converged platform permission engine — declarative super-permission hierarchy, per-member override allowlist, sync/async/batched checkers

Additional subpaths

Every other exports-map subpath outside /auth, /gdpr and /lms, with a one-line purpose from its source header:

SubpathPurpose
/admin-nav/self-registertome-core's own self-registration into the admin sidebar nav manifest registry — tome-core has no initTomeCore() factory, so this is the canonical way its Content/Settings groups land in the registry
/authorityPhase A Contract 1: the authority-resolution seam — layers call getAuthority(req), consumers supply the traversal via registerAuthorityResolver
/notificationsPhase A Contract 3: task-notification emission seam — interface only, no-op default, consumer decides where notifications are stored
/jobsScheduled-work adapters — asPayloadTask/asCronEndpoint wrap one JobHandler for either a Payload task queue or an external cron-triggered endpoint, plus authorizedCronRequest. The returned task uses Payload's own types (handler input: unknown and req: PayloadRequest, `retries: number \RetryConfig`), so its handler can be called with a real request without a cast
/licenselicenseHeartbeat() — report-only license-site heartbeat, run during Payload onInit alongside initRoles; silent, five-second timeout, never blocks boot, opt out via TOME_LICENSE_HEARTBEAT=off
/access/checkRole@deprecated minimal role-checking utility for string-based role fields — use can/canAsync from /auth/capabilities instead
/access/selfOrAdminAccess factory allowing admins or the user themselves (supports User/Profile-split sites via a custom resolveUserId)
/access/scopedPhase A Contract 2: the Where-returning access contract — the security-grade alternative to boolean-only access helpers, since a boolean-only port of a Where-scoped gate silently widens access (including findByID)
/access/secureAccessDefaultsDeny-by-default access: a last-position plugin that gives every collection/global operation without an access rule (plugin-added collections included, plus admin/unlock on auth collections, readVersions, the job queue's run/queue/cancel, and the system collections Payload adds after plugins: edit locks, migrations, folders and the jobs-stats global) to your staff rule instead of Payload's "any signed-in user"; mode: 'report' lists what it would change; also formSubmissionAccess(staff) for form-builder submissions and jobsRunAccess({ staff }) for a scheduler that calls /api/payload-jobs/run with a bearer token
/access/orgScopedWraps a collection config with multi-tenant org isolation: admins bypass, members see/write only rows matching their active org, no active org fails closed, and the orgField relationship is added if absent
/access/vendorScopedWraps a collection config with vendor-ownership checks — vendors manage only documents they own when @wabbit/tome-org's vendor role is enabled; a no-op when org is absent
/infra/healthhealthRoute() — a Next.js route-handler factory returning { status, checks: { db, redis? }, timestamp } for an app/api/health/route.ts
/infra/env-scaffoldSHARED_ENV_VARS + validateEnv() — the shared env-var spec (required vs optional) and the startup validator to call from payload.config.ts/instrumentation.ts
/registry/productTypeHookRegistryClosed-shape hook interface for product-type behavior dispatch across layers; consumers register opt-in hook implementations and degrade gracefully when one is absent
/registry/adminNavAdmin sidebar nav manifest contract — the DOMAINS frozen array and its isAdminNavDomain type-guard, used by layerRegistry.registerLayer for domain validation
/utilities/normalizeCanonical value normalizers every layer must agree on (e.g. email — the cross-layer join key for CRM/deals/intake/marketing)
/utilities/timingSafeEqualConstant-time string comparison for shared-secret / webhook-token verification, avoiding the timing side-channel a naive === or hand-rolled loop leaks
/utilities/typedSlugCoerces a runtime string slug to Payload's generated CollectionSlug literal-union type, for factory collections that take slugs via config
/fields/slug-uiSlugComponent — the admin UI field component paired with /fields/slug's factory
/config/assertRelationTargetsPhase A Contract 4: dangling-relation validator — catches a consumer override that renames a slug in only one of two related collections
/fields/addresspostalAddressGroup / postalAddressFields (two vocabularies: legacy-crm, postal), mapAddress — one address shape for crm/deals/fulfillment
/fields/fieldShapeapplyFieldShape, applyFieldOverrides, applyOmitFields, applyFieldOrder, insertFieldsAfter — the consumer field-shape pipeline every layer factory uses
/fields/selectOptionsresolveSelectOptions with the canonical `{ mode: 'extend' \'replace', options } override (dedupes by value); the older { extend?, replace? } shape in /identity` is deprecated
/registry/createKeyedRegistrycreateKeyedRegistry(symbolKey, opts) — globalThis/Symbol.for-anchored keyed registry with duplicate policies; the one way to build a cross-module registry
/registry/themeType-only ThemeManifest / ThemePalette / ThemeFonts / ThemeKind — the one descriptor every theme package (kind: 'theme') and block treatment (kind: 'treatment') exports, activated by data-tome-theme="<name>"
/registry/themeRegistryregisterTheme, getTheme, listThemes({ kind }), resolveThemeName (maps a legacy data-tome-pack value to its theme) on createKeyedRegistry. Theme packages register from their @wabbit/tome-core/register subpath; read it on the server and pass listThemes() to client components as props
/theme-configcreateThemeConfig({ mode }) — the themeConfig global (theme + palette selects from the registry, typography roles, radius, Layer-1 color groups; 'light-first' keeps regularColors/darkmodeColors, color fields accept hex/rgb/hsl/oklch()); renderThemeConfigCss(doc, { themes }) for <style id="theme-config"> (byte-identical to the classic starter injector when no theme is active; with a theme, emits only changed fields, above every theme selector); themeConfigHtmlAttributes(doc, { themes }) for <html> (also data-theme="dark" when the selected palette sets defaultMode: 'dark')
/utilities/batchbatchWrite (bounded concurrency, per-item error isolation) and batchWriteInChunks (paced) — replaces serial await payload.* loops
/utilities/layerFactoryConfigLayerFactoryConfig — the base config every create*Layer factory extends — and applyLayerFactoryConfig
/utilities/telHreftoTelHref / toE164 — a typed phone number to a tel: href or E.164 string (a leading + is kept, an optional defaultCallingCode qualifies national numbers, x123 becomes ;ext=123); telOptionsFromContext reads defaultCallingCode from a block's render context
/utilities/readingTimeREADING_WPM (230), wordsInLexical(value) (words in a Lexical state: text nodes only, including rich text nested in block fields such as a margin note's body) and readingMinutes(words, wpm?) (whole minutes, rounded up) — one reading-time rule for a post's "N min read" and any live counter
/utilities/formPrefillapplyFormPrefill(root, { allow, note, noteElement, search, skip, links }), parseFormPrefill, FORM_PREFILL_DEFAULT_NOTE, FORM_PREFILL_MAX_LENGTH — a framework-free DOM helper that preselects allowlisted fields of any rendered form from ?field=value and from clicks on in-page data-form-prefill="field=value" links (it works on the markup, fires input and change, and returns a function that removes its click listener; after a fragment link's click it re-asserts focus once the link's own hash navigation has dropped it to body); @wabbit/tome-forms/prefill re-exports it
/utilities/relationIdrelationId / relationIdOrThrow — populated-doc-or-id → string id, one implementation
/utilities/requestMemorequestMemo(req) — a per-request Map keyed on the stable req object in a WeakMap, since Payload replaces req.context on every local-API call
/data/readThroughCachereadThroughCache(keyParts, tags, read, useCache) — run a read through Next's unstable_cache when available, directly otherwise; the read's own errors are never swallowed
/utilities/safeHrefsafeHref(value) — the one link-safety rule: http(s), mailto, tel, a path or an anchor pass; other schemes, protocol-relative hosts and whitespace or control characters return null. Used by a layer's save hook and a render pack alike
/utilities/optionalIconattachOptionalIcon(entry, iconName) — fills an admin-nav entry's icon from lucide-react via a lazy import, so a layer registers on installs without that optional peer (the sidebar then uses the domain default)

GDPR subpaths

Server subpaths take a payload instance and are not 'use server' actions; wrap them in your own server action. The three components/* subpaths are 'use client'.

SubpathPurpose
/gdprAggregate entry for the consent utilities, the registry, erasure and retention
/gdpr/cookieConsentFirst-party consent cookie (cookie_consent, 365 days, readable server-side): getConsent, setConsent, hasConsent, revokeConsent, acceptAllConsent, OPEN_CONSENT_EVENT
/gdpr/registrygdprRegistry — layers register the collections holding user data so export and erasure cascade without hard-coded lists; registerDefaultBetterAuthCollections() registers BetterAuth's own collections (call it in onInit)
/gdpr/actions/exportUserDataexportUserData({ payload, user }) — Article 20 export of every registered collection as JSON
/gdpr/actions/deleteAccountdeleteAccount({ payload, user, confirmationText }) — Article 17 account deletion; a thin wrapper over runErasure
/gdpr/runErasurerunErasure — the erasure cascade over gdprRegistry; every step reports its own errors and the run continues
/gdpr/erasureStatePure classifiers for a pending erasure's lifecycle (classifyErasureState, isErasureExecutable, isErasureOverdue, resolveErasureTrail)
/gdpr/retentionData-retention engine — defineRetentionPolicies, runRetention (lock-guarded), createInMemoryRetentionLock, and the createRetentionTask / createRetentionEndpoint adapters
/gdpr/complianceOpt-in compliance records — Article 30 processing register (createProcessingRegisterCollection), consent ledger (createConsentLedgerCollection, recordConsent, requiresReconsent). Nothing runs unless you import it
/gdpr/compliance/breachArticle 33/34 breach-notification helpers (assessBreachSeverity, generateAuthorityNotification, generateSubjectNotification, createBreachRecord). Untested against a real breach: review its output before relying on it
/gdpr/components/CookieConsentConsent banner ('use client'), themed through --tome-* tokens
/gdpr/components/AnalyticsLoaderRenders its analytics children only while analytics consent is granted ('use client')
/gdpr/components/CookieSettingsButtonRe-opens the consent banner, for a footer link ('use client')
/gdpr/seed/legalPagesseedLegalPages — idempotent placeholder Impressum, Privacy Policy and Terms pages (English and German)
/gdpr/seed/lexicalHelpersLexical JSON builders for seed data (paragraph, heading, ul, lexicalRoot, …)

Admin overview views (@wabbit/tome-core/admin-views)

@wabbit/tome-core/admin-views holds the presentational pieces the layers' Overview edit views are built from: OverviewShell, Timeline (+ TimelineItem / TimelineGroup / TimelineTiles), SummaryRail (+ RailCard / RailRow / MoneyRow), StatusPill, StageBar, ProbabilityBar, FilterChips, and the formatMoney / formatDate / formatDateTime helpers. They are server-renderable, style only through Payload's and --tome-* CSS variables, and import nothing from @payloadcms/ui (an optional peer, needed only by the layers that mount views in the admin). The product-type hook registry also gains two optional hooks for these views: getAccessSummary (what access a purchase granted) and revokeAccess (called by economy's refund access policy).

Hosting an existing member table

createMemberCollection (@wabbit/tome-core/identity) defaults to a small, opinionated shape: a required-unique user relationship, a required displayName + generated slug, avatar, bio. Every option below is additive — omit all of them and you get exactly that shape, unchanged.

To host a member table that predates this factory (imported rows with no linked account yet, a different title field, your own standing lifecycle), reach for:

  • `requireUser: false` + `userValidate` — make user optional for legacy/imported rows. unique: true stays on regardless; Payload's unique index is sparse for optional fields, so multiple null rows don't collide. Pass userValidate to conditionally require it (e.g. "optional only while standing === 'legacy'").
  • `identity: { includeDisplayName, displayNameRequired, titleField, slugSource, slugField }` — includeDisplayName: false OMITS displayName entirely while KEEPING the factory's slug handling: slugSource then defaults to titleField instead of 'displayName', and the generated (or your own, via slugField) slug field is still emitted. titleField is REQUIRED when includeDisplayName is false — there's no displayName left for admin.useAsTitle/defaultColumns to fall back to; the factory throws MemberIdentityConfigError at collection-definition time otherwise. This closes the gap skipDefaultIdentity couldn't: that flag drops displayName AND the slug pair TOGETHER, with no way to keep a factory-composed slug sourced from your own identifier field. displayNameRequired (ignored when includeDisplayName is false) makes displayName optional while keeping it present. slugSource/slugField behave as before: re-source the generated slug, or supply your own slugField(...) pair outright (slugField: [...], or slugField: false to drop it).
  • `includeClassification` / `includePortrait` / `includeReportsTo` / `includeTimezone` (+ `timezoneField`) / `includeRegion` / `includeJoinDate` (+ `joinDateField`) — six generic member-table fields, each opt-in and default false (the pre-existing default snapshot is unchanged). includeClassification emits a 'public' | 'classified' select (DEFAULT_MEMBER_CLASSIFICATION_OPTIONS); includePortrait an upload field distinct from the always-on avatar; includeReportsTo a self-relationship for chain-of-command data; includeTimezone a plain text field (name configurable via timezoneField, default 'timezone') rather than a curated select — the factory doesn't own a timezone-options list; includeRegion a six-value coarse-geography select (DEFAULT_MEMBER_REGION_OPTIONS); includeJoinDate a date field (name configurable via joinDateField, default 'joinDate'). None of these byte-match any one consumer's existing field shape (different value casings, no curated options list) — they're generic defaults a future consumer can take as-is, rename via the *Field options where offered, or still fully override via extraFields.
  • `standingOptions: true | { extend, replace }` + `standingDefault` — opt into a standing select field. Omitted by default; DEFAULT_MEMBER_STANDING_OPTIONS is exported if you want to extend rather than replace it.
  • `defaultPopulate` / `indexes` — passthrough to the underlying CollectionConfig, present in the output only when you provide them.
  • `defaultReadAccess: 'authenticated' | 'self-or-admin' | Access` — choose the read strategy without giving up the create/update/delete defaults (which a full access override would do). Accepts any Payload Access function, including one that returns a Where instead of a boolean.
  • Every `config.hooks` key is appended after the factory's built-in hooks, not replaced — via the shared @wabbit/tome-core/hooks/mergeHooks append utility. A caller's afterChange (or any other key) composes alongside future built-in hooks on that same key instead of silently dropping them.

How a layer announces itself (@wabbit/tome-core/utilities/layerRegistry)

Every Tome layer calls registerLayer(name, info) once from its create*Layer() / init*() entry point. info carries its version, the collections it contributes, and optional admin-nav and learner-nav manifests. Other code then composes on what's installed with hasLayer(name) / getLayer(name) / getRegisteredLayers(), and claims a shared logical slot with claimSlot() / isSlotClaimed().

import { registerLayer, hasLayer } from '@wabbit/tome-core/utilities/layerRegistry'

export function createWidgetsLayer(config = {}) {
  try {
    registerLayer('@wabbit/tome-widgets', { version: WIDGETS_LAYER_VERSION, collections: ['widgets'] })
  } catch {
    // already registered (HMR re-evaluation): registration is idempotent by intent
  }
  // ...
}

if (hasLayer('@wabbit/tome-org')) {
  // compose org-aware behavior
}
  • Import it statically. The registry's state lives on globalThis (keyed by Symbol.for(...)), not a module-local Map, so ESM, CJS, bundler and CLI copies of this package all share one store. That has held since 1.0.10. The old lazy-require()-in-try/catch pattern is obsolete, and it breaks under the Payload CLI's ESM loader, where require is undefined and registration silently skips. Keep try/catch around the registerLayer() call only, for HMR idempotency.
  • Register before anyone reads. hasLayer() is only as good as call order: a layer must register before a factory that checks for it runs. For example, create the org layer before the collections that branch on hasLayer('@wabbit/tome-org').
  • Names are canonicalised, so 'tome-org' and '@wabbit/tome-org' resolve to the same entry.

Consumer Rules of Thumb

  • Add secureAccessDefaults({ staff: isAdmin }) (@wabbit/tome-core/access/secureAccessDefaults) as the LAST plugin. Payload gives any operation you leave out to "any signed-in user", and authenticated / authenticatedOrPublished mean the same thing: on a site with public signup that is any stranger. Gate writes, drafts and personal data with a role guard (isAdmin; for drafts-enabled content staffOrPublished(staff) or isAdminOrPublished). A written authenticatedOrPublished is a rule, so the plugin never replaces it: swap it yourself. Run mode: 'report' first on an existing site. With basePlugins, also pass formBuilder.staff so form submissions get formSubmissionAccess(staff). If an external scheduler calls /api/payload-jobs/run, set jobs.access.run: jobsRunAccess({ staff }) (staff or Bearer $CRON_SECRET, fails closed when the secret is unset). Composing layers without the plugin? Pass staff to each createXLayer({ staff }): it fills the operations that layer left without a rule.
  • Pass *Slug config options everywhere you renamed a collection — platform code never hardcodes your slugs.
  • Use async access guards in Payload access: rules; sync guards are for client/UI code where roles are already enriched.
  • orgScoped/vendorScoped presuppose createBetterAuth()'s session enricher; wiring a different auth stack means re-implementing it.

Development (monorepo)

pnpm install
pnpm typecheck
pnpm build      # via scripts/build-two-pass.mjs at the repo root

See the repo root CLAUDE.md for conventions and docs/claude-gotchas.md for the hard-won build/publish gotchas.

Decisions that shaped this package

  • `can`/`canAsync` is the one runtime-gate API for new code — four permission systems (checkRole, flat checkPermission, capabilities, hierarchical effectivePermissions) coexisted with no stated order; capabilities now sit on top of effectivePermissions and checkRole/checkPermission carry @deprecated tags, sunset at core 2.0.
  • `core/lms` is deprecated in full, not just the one factory that already carried the tag — the half-tagged state was worse than either extreme; removal at core 2.0 is gated by a CI check for zero repo-wide @wabbit/tome-core/lms/* importers, and it is explicitly distinct from the still-active @wabbit/tome-lms domain package.
  • The admin-nav registry (`DOMAINS`, `isAdminNavDomain`, `LicenseTier`) lives in core, additive to `layerRegistry` — the sidebar needed a validated domain taxonomy and an install-scoped license-tier type that every layer and @wabbit/tome-admin could share without a circular dependency on the admin package itself.
  • The invalid-domain guard warns and coerces in every environment; it never throws — the original dev-throw was reasoned to bite next dev when a dependency (not the consumer's own code) shipped an invalid domain, which a consumer cannot fix by editing their own config; hard-fail-on-your-own-misconfig now lives in the opt-in nav-doctor check instead of a NODE_ENV branch.
  • Layer-direction is enforced by a machine-checkable `wabbit.layer` field read fresh at ESLint config-load time, not by convention — part of the repo-wide core < domain < ui < blocks < app < meta zone map.
  • `/verification`'s `createDistributedRateLimiter` defaults to fail-CLOSED on backend unavailability — the limiter it generalises failed open on a Redis outage with no opt-out; that default is inverted here (the same reasoning as the GDPR retention lock: a coordination-backend outage silently disabling a rate limit is a worse failure mode than denying a slot for one tick). A consumer that wants the old always-proceed behavior passes onUnavailable: 'run' explicitly. createResilientFetcher also differs from the fetcher it generalises, which retried EVERY non-2xx: it never THROWS on an HTTP status, but it does retry one — governed by an explicit retryOn policy defaulting to res.status === 429 || res.status >= 500 (a 429/5xx is presumed transient; every other 4xx, e.g. 404, is returned on the first attempt, since retrying a stable client error wastes the budget for no benefit). A 429's numeric Retry-After header is honored, capped at timeoutMs so a server-supplied wait can never stall one attempt past its timeout budget.
  • `/field-reports`'s `RiskScoreInput.visibility` is a single `'public' | 'hidden' | 'redacted'` enum, not two independent `isHidden`/`isRedacted` booleans — the two states describe the same RSI profile-visibility setting and cannot really co-occur; a combined "hostile + hidden + redacted" case therefore scores as two 30-point cases, not one 40-point case (scorer.test.ts). createWatchlistGlobal's per-org category field does not store a score weight — the weight lives on the categories array passed to BOTH createWatchlistGlobal and createRiskScorer; a caller who edits weights in only one place desyncs them. assessAndStore/runReassessRisk also record more than a hostile-orgs list would, deliberately: EVERY watchlist match is recorded (matchedWatchlist/stored affiliations), including weight: 0 categories — score/flags still skip anything with weight <= 0. createReassessRiskTask is a cache-only rescore: it rescores from what is already stored on each subject-assessments row (no member-scoped profile cache exists in this module) — assessAndStore now persists input.createdAt onto fieldNames.subjectCreatedAt (nullable, never overwritten by an omitted/undefined createdAt on a later scan) precisely so new_account CAN be recomputed on a cache-only pass: runReassessRisk reads it back and re-evaluates the window against the run's own injectable now, so a subject correctly clears new_account once enough time has passed even without a fresh fetch. A row whose subjectCreatedAt is null (predates this field, or its source never supplied one) simply never has the flag set or cleared by fiat. registerFieldReportsGdpr registers subject-assessments as mode: 'redact', not mode: 'retain', to express "retain with basis, redact on request" as one registration: retain in this registry's vocabulary means the row is NEVER touched, which cannot express a conditional; redact degrades to a no-op (behaviorally identical to retain) whenever the erasure identity's handle doesn't match, and only acts on the one case retain cannot cover.
  • `/field-reports` lets an existing schema adopt it without migration, all additive, zero default change: (1) createWatchlistGlobal hardcoded the organizations array field name with only externalIdField overridable — fieldNames.{organizations,externalId,name,category,notes} now covers every stored field, and createRiskScorer's new watchlistFieldNames option reads the watchlist through the SAME names (previously conflated with the unrelated fieldNames.affiliationExternalId on the assessment side — a coincidence of matching defaults, not a real coupling); (2) createRiskScorer hardcoded the multi_affiliation flag value where an existing scorer may emit multi_org — flagNames now overrides every emitted flag string (multiAffiliation/hiddenProfile/redactedProfile/newAccount/manualFlag/categoryFlag), threaded through score, assessAndStore, AND runReassessRisk (which must recognize a PRIOR row's flags under the same names via the new resolveFlagNames export); scorer.test.ts covers a renamed-flags configuration end to end; (3) registerFieldReportsGdpr's two registrations had a fixed phase/order with no opt-out, forcing a consumer with its own pinned registration to rely on "the later registerCollection call silently overwrites the earlier one by slug" — reports/assessments each now take false (skip registering that collection) or { phase?, order? } (reposition it; assessments.phase still defaults to 'pre-identity', needed for its pre-overwrite handle search).

Exports

Import path
@wabbit/tome-core
@wabbit/tome-core/admin-nav/self-register
@wabbit/tome-core/access
@wabbit/tome-core/access/guards
@wabbit/tome-core/access/checkRole
@wabbit/tome-core/access/selfOrAdmin
@wabbit/tome-core/access/vendorScoped
@wabbit/tome-core/access/orgScoped
@wabbit/tome-core/access/scoped
@wabbit/tome-core/access/secureAccessDefaults
@wabbit/tome-core/authority
@wabbit/tome-core/notifications
@wabbit/tome-core/jobs
@wabbit/tome-core/auth
@wabbit/tome-core/auth/client
@wabbit/tome-core/auth/session-keep-alive
@wabbit/tome-core/auth/rbac
@wabbit/tome-core/auth/repScoping
@wabbit/tome-core/auth/constants
@wabbit/tome-core/auth/guards
@wabbit/tome-core/auth/collections/roles
@wabbit/tome-core/auth/collections/Roles
@wabbit/tome-core/auth/initRoles
@wabbit/tome-core/auth/jobs/cleanup-sessions
@wabbit/tome-core/auth/jobs/cleanup-unverified
@wabbit/tome-core/auth/betterAuthFactory
@wabbit/tome-core/auth/capabilities
@wabbit/tome-core/auth/capabilities/client
@wabbit/tome-core/auth/permissions
@wabbit/tome-core/gdpr
@wabbit/tome-core/gdpr/cookieConsent
@wabbit/tome-core/gdpr/registry
@wabbit/tome-core/gdpr/runErasure
@wabbit/tome-core/gdpr/erasureState
@wabbit/tome-core/gdpr/retention
@wabbit/tome-core/gdpr/compliance
@wabbit/tome-core/gdpr/compliance/breach
@wabbit/tome-core/gdpr/actions/exportUserData
@wabbit/tome-core/gdpr/actions/deleteAccount
@wabbit/tome-core/gdpr/components/CookieConsent
@wabbit/tome-core/gdpr/components/AnalyticsLoader
@wabbit/tome-core/gdpr/components/CookieSettingsButton
@wabbit/tome-core/gdpr/seed/legalPages
@wabbit/tome-core/gdpr/seed/lexicalHelpers
@wabbit/tome-core/data/errors
@wabbit/tome-core/data/cacheTags
@wabbit/tome-core/data/readThroughCache
@wabbit/tome-core/hooks/mergeHooks
@wabbit/tome-core/fields/slug
@wabbit/tome-core/fields/slug-ui
@wabbit/tome-core/fields/link
@wabbit/tome-core/fields/address
@wabbit/tome-core/fields/fieldShape
@wabbit/tome-core/fields/selectOptions
@wabbit/tome-core/fields/defaultLexical
@wabbit/tome-core/config/siteIdentity
@wabbit/tome-core/config/assertRelationTargets
@wabbit/tome-core/content/pageBuilder
@wabbit/tome-core/content/richTextRenderer
@wabbit/tome-core/infra/health
@wabbit/tome-core/infra/env-scaffold
@wabbit/tome-core/infra/posthog
@wabbit/tome-core/infra/plugins
@wabbit/tome-core/media/cdnConfig
@wabbit/tome-core/registry
@wabbit/tome-core/registry/productTypeHookRegistry
@wabbit/tome-core/registry/adminNav
@wabbit/tome-core/registry/createKeyedRegistry
@wabbit/tome-core/registry/theme
@wabbit/tome-core/registry/themeRegistry
@wabbit/tome-core/admin-views
@wabbit/tome-core/theme-config
@wabbit/tome-core/utilities/batch
@wabbit/tome-core/utilities/formPrefill
@wabbit/tome-core/utilities/layerFactoryConfig
@wabbit/tome-core/utilities/layerRegistry
@wabbit/tome-core/utilities/readingTime
@wabbit/tome-core/utilities/requestMemo
@wabbit/tome-core/utilities/safeHref
@wabbit/tome-core/utilities/relationId
@wabbit/tome-core/utilities/optionalIcon
@wabbit/tome-core/utilities/normalize
@wabbit/tome-core/utilities/telHref
@wabbit/tome-core/utilities/timingSafeEqual
@wabbit/tome-core/utilities/typedSlug
@wabbit/tome-core/lms
@wabbit/tome-core/lms/collections/lesson
@wabbit/tome-core/lms/collections/topic
@wabbit/tome-core/lms/collections/module
@wabbit/tome-core/lms/collections/course
@wabbit/tome-core/lms/collections/enrollment
@wabbit/tome-core/lms/collections/lesson-completion
@wabbit/tome-core/lms/collections/badge
@wabbit/tome-core/lms/collections/achievement
@wabbit/tome-core/lms/collections/points
@wabbit/tome-core/lms/collections/gradebook-entry
@wabbit/tome-core/lms/collections/product
@wabbit/tome-core/lms/collections/order
@wabbit/tome-core/lms/collections/course-review
@wabbit/tome-core/lms/collections/student-note
@wabbit/tome-core/lms/collections/certificate
@wabbit/tome-core/lms/collections/quiz-attempt
@wabbit/tome-core/lms/collections/assignment-upload
@wabbit/tome-core/lms/blocks
@wabbit/tome-core/lms/blocks/video
@wabbit/tome-core/lms/blocks/quiz
@wabbit/tome-core/lms/blocks/assignment
@wabbit/tome-core/lms/blocks/code
@wabbit/tome-core/lms/blocks/note
@wabbit/tome-core/lms/access
@wabbit/tome-core/lms/guards
@wabbit/tome-core/lms/utilities/progress
@wabbit/tome-core/lms/utilities/gamification
@wabbit/tome-core/lms/utilities/grading
@wabbit/tome-core/lms/fields/items
@wabbit/tome-core/lms/fields/prerequisites
@wabbit/tome-core/lms/fields/suggested-next
@wabbit/tome-core/lms/types
@wabbit/tome-core/identity
@wabbit/tome-core/verification
@wabbit/tome-core/field-reports
@wabbit/tome-core/license

Changelog

1.35.xMinor

  • applyFormPrefill now also reads the query string of a clicked link.

    A link that points at the page's form and carries ?field=value pairs in its href preselects those fields, with the same allowlist as the page URL, so a plain href works with no extra attribute. A link that also has data-form-prefill keeps its earlier behavior and wins if the two disagree.

    The click listener is now registered in the capture phase: a client-routing link such as next/link cancels the click's default action in its own handler, which used to hide the click from the helper. Nothing changes for a form with no allowlist or for links: false.

1.34.xMinor

  • Canceled statuses now accept both spellings.

    This is the first of three steps toward American spelling in stored values, and nothing that gets written changes yet.

    Training events, attendance, exam tickets, LMS orders, events, campaigns, CRM activities, resource requests, task forces and the event, status board and operation briefing blocks gain the American canceled (CANCELED on the status board) next to the existing value. Reads, queries, the closed-event lock, event board state and selection, the calendar export (STATUS:CANCELLED is unchanged, it is the iCalendar literal), the status board badge, theme selectors for data-event-status and the erasure trail treat the two spellings as one meaning.

    The event list board labels gain a canceled key that is read before the old cancelled key. The old field is hidden, not removed, so stored text is kept on save.

    The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)".

    Writers still store the old values.

1.33.xMinor

  • Add @wabbit/tome-core/auth/session-keep-alive (SessionKeepAlive, rollSession) and a session.deferSessionRefresh option on createBetterAuth.

    Together they keep the session cookie moving for active members: a page render can't set a cookie, so without them the cookie expires a fixed expiresIn after sign-in. The option is off by default and must be paired with <SessionKeepAlive />, because with it on nothing else refreshes the session.

    It needs a Better Auth version that has deferSessionRefresh (1.6.20 or later).

1 fix (1.33.1)
  • 1.33.1

    Admin labels, field descriptions, demo content and messages now use American spellings.

1.32.xMinor

  • Add @wabbit/tome-core/utilities/safeHref: the one link-safety rule, lifted from @wabbit/tome-blocks-local-pack so a layer's save hook and a render pack apply the same check.

    safeHref(value) returns the trimmed link for http(s), mailto, tel, a path or an anchor, and null for any other scheme, a protocol-relative //host, or whitespace and control characters.

    Also adds @wabbit/tome-core/utilities/requestMemo (a per-request Map keyed on the stable req object) and @wabbit/tome-core/data/readThroughCache (a read through Next's unstable_cache when it is there, directly when it is not), so layers share one implementation instead of copying one.

1.31.xMinor

  • createBetterAuth gains enrichSessionRoles (default true), so a site can turn off the role lookup the factory adds to every session fetch.

    The factory's customSession plugin loads the user at depth 1 on every getSession and adds user.role, orgRole and activeOrganizationId. Better Auth's admin login view needs user.role, so the default stays on.

    A site that keeps Payload's own /admin login and doesn't read session.user.role can pass enrichSessionRoles: false and skip one users read per session fetch; if it uses the Better Auth login view, it must also pass admin.requiredRole: null.

1.30.xMinor

  • Add staffOrPublished(staff) (exported from @wabbit/tome-core/auth and @wabbit/tome-core/access): staff see drafts, everyone else, signed in or not, sees only published documents.

    It's the safe replacement for authenticatedOrPublished on sites with public signup, where that guard shows drafts to every account. Only an explicit true from staff opens drafts; a Where is OR-ed with the published filter.

    The authenticatedOrPublished warnings now point at it.

1 fix (1.30.1)
  • 1.30.1
    createBetterAuth no longer enables the adapter's first-user-admin hook, which never worked on Tome's roles relationship and logged an error on every first sign-up.

    The hook writes a role string on the first sign-up. Tome users hold a roles relationship instead, so the write was dropped, its follow-up query failed ("first-user-admin race resolution failed ... path cannot be queried: role"), and nobody was made admin by it.

    Sites assign the first admin in their own users hook. Pass firstUserAdmin: true only for a users collection that stores roles in a plain role field.

    With the hook off, the adapter logs a one-time reminder at boot that access.create on users must reject non-admin callers; the starter template already does, and a sign-up that sends roles is stored with the default role.

1.29.xMinor

  • secureAccessDefaults now also covers the collections Payload adds after plugins run: edit locks, migrations, folders and the jobs-stats global.

    Payload creates payload-locked-documents, payload-migrations, the folders collection and (when a task has a schedule) the payload-jobs-stats global while it finalizes the config, after every plugin has run, and gives them "any signed-in account" on every operation. On a site with public signup, any account could list who is editing what and create or delete edit locks on admin documents.

    The plugin now installs staff on them: folders through a folders.collectionOverrides entry that runs before yours (so your overrides still win), the others at init on the live config, before your own onInit. Lock reads go to everyone allowed into the admin panel (the admin-user collection's access.admin), because Payload's admin reads locks as the signed-in user; lock writes go to staff.

    exclude takes these slugs, report mode lists them, and installLateSystemAccess is exported for configs built without the plugin.

1.28.xMinor

  • Layer factories accept staff, which gives every operation a layer left without an access rule to the site's staff check.

    LayerFactoryConfig.staff (applied by applyLayerFactoryConfig, so every createXLayer that passes its config through gets it): each missing create/read/update/delete/readVersions, plus admin/unlock on an auth collection, becomes staff. Rules the layer wrote are untouched and per-collection access overrides still win.

    This closes gaps like tome-catalog's product variants (no write rules) and tome-org's members (no read rule) for a site that composes layers by hand, without the secureAccessDefaults plugin. Omitting staff changes nothing.

    Also exports fillMissingAccess(collection, staff) from @wabbit/tome-core/access/secureAccessDefaults, the shared rule behind both the plugin and the layer option.

1.27.xMinor

  • secureAccessDefaults now restricts the job queue to staff, and a new jobsRunAccess helper admits staff or the cron bearer token.

    Payload lets any signed-in account call jobs.access.run, queue and cancel when they are unset, and run guards the REST endpoints GET /api/payload-jobs/run and /handle-schedules, so any member could drain or trigger queued work. When the config has a jobs block, each missing rule now becomes staff (only an explicit true admits), reported as jobs: run, queue, cancel in the init summary.

    exclude: ['payload-jobs'] opts out. The Local API (payload.jobs.run() from a cron route, a sidecar or autoRun) is unaffected.

    jobsRunAccess({ staff, secretEnv? }) is jobs.access.run for a site whose external scheduler calls the job endpoints. It compares the Authorization: Bearer <CRON_SECRET> header in constant time and fails closed when the secret is unset (the Payload website template's version admits Bearer undefined in that case).

    Behavior change for sites already using secureAccessDefaults: after upgrading, non-staff accounts lose REST access to the job endpoints unless jobs.access sets a rule. No API, peer or required argument changed, so this ships as a minor.

1.26.xMinor

  • New secureAccessDefaults plugin (@wabbit/tome-core/access/secureAccessDefaults) gives every operation without an access rule to your staff check instead of "any signed-in user".

    Payload fills any access key you leave out with "any signed-in account". A missing readVersions does the same, unlock defaults to any account in the admin-user collection, and an auth collection without access.admin lets any signed-in user open the admin panel.

    On a site with public signup, or with members in the admin-user collection, that hands writes, drafts, version history and plugin-added records (form-builder submissions have no delete rule) to anyone who signs up.

    Add secureAccessDefaults({ staff: isAdmin }) as the last plugin. It fills only the operations you didn't write, including those of plugin-added collections.

    It logs them once at init, and it warns when form-submissions still uses the form-builder plugin's default read. Run mode: 'report' first on an existing site to see the list without changing anything.

    onReport hands that list to a script or CI check when the config is built, before any database connection.

    Also new:

    • formSubmissionAccess(staff): anyone may submit, staff may read and delete, and no one edits a submission over the API.
    • isAdminOrPublished in @wabbit/tome-core/auth/guards: the safe replacement for authenticatedOrPublished on content.
    • basePlugins({ formBuilder: { staff } }): applies both presets to the form-builder plugin. Your own access keys still win.

    The authenticated and authenticatedOrPublished docs now say plainly that they admit any signed-in account.

1.25.xMinor

  • New @wabbit/tome-core/admin-views subpath with presentational building blocks for Overview admin views, plus two optional product-type hooks.
    • ProductTypeHooks gains optional getAccessSummary (returns a TomeAccessSummary for admin access tiles) and revokeAccess (ends non-entitlement access after a refund; must be idempotent and never throw).
    • @wabbit/tome-core/admin-views ships server-safe primitives styled only with Payload admin CSS variables: OverviewShell, Timeline / TimelineItem / TimelineGroup, SummaryRail / RailCard / RailRow / MoneyRow, StatusPill, StageBar, ProbabilityBar and FilterChips.
    • @payloadcms/ui is now declared as an optional peer dependency; nothing to install unless you use the subpath.
2 fixes (1.25.1 – 1.25.2)
  • 1.25.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.

  • 1.25.1

    Overview views keep Payload's horizontal gutter: the order and contact Overview tabs no longer sit flush against the admin nav and the window edge.

1.24.xMinor

  • Adds signToken and verifyToken to @wabbit/tome-core/verification: a generic expiring HMAC-SHA-256 signed token for signed links.
    • signToken(payload, { secret, ttlMs | expiresAt, purpose? }) returns data.signature (base64url). verifyToken(token, { secret, purpose? }) compares the signature in constant time, then checks the envelope, the purpose and the expiry, and returns { ok: true, payload, iat, exp } or { ok: false, reason } (malformed, bad-signature, wrong-purpose, expired). It never throws.
    • purpose is signed into the token, so a link minted for one use never verifies for another under the same secret.
    • The payload is signed, not encrypted: carry ids and a revocation version, never personal data.
    • createProfileVerificationFlow's issueToken/verifyToken now delegate to it. Tokens it issued before still verify; a token is now expired at its exp instant rather than one millisecond after.

1.23.xMinor

  • @wabbit/tome-core/data/cacheTags now exports loadUnstableCache() and the UnstableCache type: a lazy loader for Next's unstable_cache that returns null outside Next.

    Layers that cache public reads by tag share this one helper instead of each carrying a copy.

  • ThemePalette gains an optional defaultMode, and @wabbit/tome-core/utilities/readingTime adds one shared reading-time rule.
    • ThemePalette.defaultMode?: 'light' | 'dark' names the mode a palette opens in. themeConfigHtmlAttributes(doc, { themes }) adds data-theme="dark" for a palette that opens dark, the ThemeConfig palette field names those palettes in its help text, and registerTheme() rejects a defaultMode the palette does not list in modes. Palettes without it behave exactly as before.
    • readingTime exports READING_WPM (230), wordsInLexical(value) (counts real text nodes only, including rich text nested in block fields such as a margin note's body) and readingMinutes(words, wpm?).
    • @wabbit/tome-core/utilities/formPrefill is the framework-free link-prefill helper, moved here from the forms engine so a site that does not use @wabbit/tome-forms can use it: applyFormPrefill(root, { allow, note, noteElement, search, skip, links }), parseFormPrefill, FORM_PREFILL_DEFAULT_NOTE and FORM_PREFILL_MAX_LENGTH. It preselects allowlisted fields of any rendered form from ?field=value and from clicks on in-page data-form-prefill="field=value" links. After a link click, focus lands on the checked radio itself (focusable even when only visually hidden); if the control cannot take focus (display: none), on its label, then on the first other focusable control in its group. focusPrefilledControl(control, { preventScroll }) is exported for this. A fragment link's own hash navigation runs after the click handler and drops focus to body a few milliseconds later, so the handler focuses at once and then re-asserts focus after the navigation has settled (a timeout, then an animation frame), only when focus was lost; the link keeps its native hash behavior and preventScroll still holds.

1.22.xMinor

  • New @wabbit/tome-core/utilities/telHref subpath: one tel: href builder with E.164 normalization, replacing four per-package copies that kept only the typed digits.

    toTelHref(raw, { defaultCallingCode? }) returns tel:+<digits> and toE164(raw, opts) returns +<digits>, both null for fewer than three digits. A leading + is kept.

    Without one, a defaultCallingCode ('1', '44') qualifies the number: NANP numbers of 11 digits starting with 1 just gain the +, any other national number loses one leading trunk 0 and gains +<code>. With neither, toTelHref keeps the digits as typed, so nothing changes until a site opts in.

    Trailing x123 / ext. 123 becomes ;ext=123; trailing , / ; dial pauses pass through. telOptionsFromContext(context) reads defaultCallingCode from the host render context a block receives as BlockRenderProps.context.

1 fix (1.22.1)
  • 1.22.1

    basePlugins types its seo.generateTitle and seo.generateURL from the SEO plugin's own config, so async generators and the plugin's full argument object type-check without a cast.

1.21.xMinor

  • CookieConsent gains an optional layout?: 'panel' | 'bar' prop (default 'panel', unchanged from today), plus equal-weight Accept/Reject buttons.

    'bar' renders a full-width slim strip pinned to the bottom instead of the centered card — description and both buttons share one row when there's room, wrapping onto a compact second row on phone widths via flex-wrap rather than a fixed breakpoint.

    Fix: Accept and Reject now have equal visual weight by default in both layouts — Accept was previously a filled primary button next to an outline Reject, which visually nudges toward accepting. Both buttons now share the same outline treatment.

    This also retires two --tome-color-* custom properties that were never defined in the @wabbit/tome-ui token vocabulary (--tome-color-muted-foreground, --tome-color-primary-foreground): the description text now reads --tome-color-on-surface-muted, and the primary-filled button treatment that referenced the other undefined token is gone along with the equal-weight change, not just re-pointed.

    Fix: the banner's fixed outer wrapper no longer intercepts clicks on page content outside the card/bar (pointer-events: none, re-enabled to auto on the card/bar itself).

    All existing consent storage, callbacks, and ARIA role/label behavior is unchanged. The presentational markup is now split into an exported CookieConsentPanel (same props minus showDelay, plus onAccept/onReject) so it renders deterministically in a render test without needing to fake the visibility-gating effects.

1 fix (1.21.1)
  • 1.21.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.

1.20.xMinor

  • New @wabbit/tome-core/registry/themeRegistry (theme discovery), /registry/theme (the ThemeManifest type) and /theme-config (a platform ThemeConfig global and injector).
    • /registry/theme — type-only ThemeManifest, ThemePalette, ThemeFonts, ThemeKind ('theme' | 'treatment'): the one descriptor every theme package exports. Themes activate with data-tome-theme="<name>", palettes with data-tome-palette.
    • /registry/themeRegistry — registerTheme, getTheme, listThemes({ kind }), resolveThemeName (maps a legacy data-tome-pack value to its theme), validateThemeManifest, on the globalThis-anchored createKeyedRegistry (duplicate policy replace, so HMR re-registration is safe; a different package claiming a registered name warns in development). Also re-exported from /registry.
    • /theme-config — createThemeConfig({ mode }) returns the themeConfig Payload global: theme and palette selects fed by the registry, a typography group (pairing, per-role display/body/mono overrides, display weight → --tome-type-weight-display), radius, and the Layer-1 color groups. 'light-first' keeps the classic regularColors/darkmodeColors field names, so existing documents need no migration. Color fields accept hex, rgb(a), hsl(a) and oklch(). renderThemeConfigCss(doc, { themes }) renders the <style id="theme-config"> body: with no theme active its output is byte-identical to the classic starter injector; with a theme active it emits only fields changed from their defaults, under a selector that outranks every theme and palette rule. themeConfigHtmlAttributes(doc) returns the <html> attributes.

1.19.xMinor

  • asPayloadTask's task handler now accepts Payload's own task arguments, so task.handler({ input, req }) with a real PayloadRequest needs no cast.

    The handler takes input: unknown and req: PayloadRequest, and retries is Payload's number | RetryConfig.

    Previously the handler typed req as { payload } & Record<string, unknown>, which a real PayloadRequest does not satisfy, and input as a record, which Payload's per-task unknown input does not satisfy; calling task.handler({ input, req }) directly needed a cast. PayloadTaskLike and PayloadTaskOptions change accordingly.

    The generic plumbing stays loose on purpose: there is no task-slug type parameter and the handler still resolves to { output: unknown }, because those types key off a site's generated jobs map, which a layer package cannot see.

    At runtime, an object input is forwarded to your JobHandler exactly as before; a non-object input (which Payload does not produce) now reaches the handler as undefined.

    Who could see a compile error: code that calls task.handler(...) directly with a hand-built req that is not typed as a PayloadRequest (typically a test stub — cast it to PayloadRequest), and code that passes a retries value outside Payload's number | RetryConfig (Payload's own task config would reject it). Registering the task and calling it with Payload's real arguments are unaffected, and every in-repo producer typechecks unchanged.

1 fix (1.19.1)
  • 1.19.1
    Two people with the same name can now both sign up.

    createMemberCollection wires a new built-in beforeValidate hook, ensureUniqueMemberSlug, that gives a taken member slug the lowest free numeric suffix on create (jane-doe → jane-doe-2).

    Before this, the second member with a given display name failed the unique slug index inside createMemberOnSignup, which aborted the signup after the auth user was written and left that email unable to sign in. Updates are untouched, and the hook is only wired when the collection has a slug field. Also exported from @wabbit/tome-core/identity.

1.18.xMinor

  • New subpath @wabbit/tome-core/utilities/optionalIcon exporting attachOptionalIcon(entry, iconName).

    It fills an admin-nav manifest entry's icon from lucide-react through a lazy import, so a layer that wants a specific sidebar icon still registers on an install without lucide-react; the sidebar then falls back to the nav domain's default icon. lucide-react is declared as an optional peer.

    Layers that carried their own copy of this helper now import it from core.

  • initializeRoles no longer pauses for one second before creating missing roles, so a first boot on an empty database seeds roles without the delay.

    The pause had no recorded reason and guarded nothing: concurrent workers seeding the same database are handled by the unique-conflict path, which treats a lost race as "role already exists".

  • initializeRoles now runs its per-role existence precheck with bounded concurrency instead of one read at a time.

    Behavior is unchanged: a failed read still fails initialization.

    Internal: the identity helpers type collection slugs through core's own typedSlug() instead of inline casts.

  • customer-facing wording: internal references removed from admin descriptions, error messages and block metadata.

1.17.xMinor

  • @wabbit/tome-core/utilities/relationId gains the readers the 48 local copies needed (2026-09-24 audit A3 #7).

    All additions are additive. relationIdRaw(value) returns the id in its original type (string | number | null) for call sites that compare against a raw doc.id or write back to a numeric column.

    relationIds(value) and relationIdsRaw(value) read hasMany values; a single value is read as a one-element list. A { polymorphic: true } option on every reader also unwraps { relationTo, value }.

    relationId(value) behaves exactly as before. Core's field-reports lifecycle drops its private idOf for relationIdRaw.

  • New core homes for three helpers that were copied across layers (2026-09-24 audit A3 #16).
    • autoSlugFieldHook(sourceField) in ./fields/slug: the field-level auto-slug hook that accounts and org carried byte-identically.
    • Duck-typed role-slug readers in ./auth/rbac: readRoles, slugsFromArray, slugFromEntry, roleSlugsMatchTiers, and resolveRoleSlugs(req, { cacheKey, userCollection }), previously copied between lms and gamification.
    • ownOrCapabilityAccess(capability, ownerField) in ./auth/repScoping: the own-or-operator Access that crowdfund and fulfillment each carried.

    Behavior change, deliberate: identity's autoSlugFromDisplayName now uses formatSlug, the stricter slugifier, instead of its private toSlug. The two agree on ordinary input.

    On irregular whitespace the output changes: ' Ada Lovelace ' now gives 'ada-lovelace' instead of '--ada--lovelace-', and a tab becomes a hyphen instead of being deleted. Stored slugs are untouched.

    createMemberCollection has used formatSlug since 2026-07.

  • License heartbeat: an .npmrc credential is now expanded like npm does (${VAR} from the environment), and a value that is empty or still contains a ${ placeholder after expansion is treated as "no credential" — nothing is sent, and a debug line is logged.

    Previously the committed //npm.wabbit.com/:_authToken=${NPM_TOKEN} line was hashed LITERALLY whenever NPM_TOKEN was unset at runtime (the normal case for a deployed container, since the token is a build-time secret), so every such site reported the same bogus license hash. buildHeartbeatPayload accepts an optional debug sink.

1.16.xMinor

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

4 fixes (1.16.1 – 1.16.4)
  • 1.16.4
    bunnyCdnStorage() now passes BUNNY_REGION (or the env.region override) through as storage.region.

    It was declared in the options type but never read, so zones outside the default region got 401s from the unprefixed storage endpoint; consumers had to merge the region in by hand.

  • 1.16.3
    `createBetterAuth({ admin: { requiredRole: null } })` now actually disables the LoginView's client-side role check.

    The option's contract said null "skips the client check entirely (server check still enforced)", and its type accepted null, but the factory handed the LoginView resolvedRequiredRole ?? undefined. The view declares requiredRole = 'admin' as a default parameter, so undefined re-enabled the check with a slug Tome never issues.

    Worse, the view runs that check on the sign-in _response_ user, which has no role field (customSession enriches only /get-session), so a consumer that opted out with null still saw "Access Denied" on every submit and had to navigate to /admin by hand. A production consumer carried that workaround from 2026-08-23 until a domain cutover on 2026-09-19 forced a fresh sign-in and surfaced it again.

    null now passes through as null; the plugin's login.requiredRole type declares string | string[] | null, and its checkUserRoles returns true for a falsy requirement. The server-side Users.access.admin gate is unchanged and remains the real lock.

    The default (option unset) still resolves to DEFAULT_ADMIN_ROLES.

  • 1.16.2
    checkRoleAsync, checkRole and hasRole now accept readonly role lists and consumer-defined slugs without a cast.

    checkRoleAsync and checkRole took RoleSlug[] | string[], a mutable array, and hasRole took (RoleSlug | string)[]. A site that keeps one as const list for its admin gates, like tome-starter's and other consumer sites' ADMIN_PANEL_ROLE_SLUGS, cannot pass that list without as unknown as string[].

    Keeping that list single is the point: Users.access.admin and createBetterAuth's requiredRole have to read the same one so the two gates never drift apart. 1.16.1 already widened requiredRole the same way.

    All three now take readonly (RoleSlug | string)[]. That is a strict widening: every existing caller still compiles, and runtime behavior is unchanged.

    The functions only read the list.

    checkRoleAsync and checkRole stay @deprecated in favor of canAsync/can from @wabbit/tome-core/auth/capabilities. This change only removes a cast from existing call sites; it does not revive them for new code.

  • 1.16.1
    createBetterAuth({ admin: { requiredRole } }) now accepts consumer-defined role slugs, and readonly lists, without a cast.

    The option was typed as Tome's closed RoleSlug union, which cannot express a site's own admin-capable role. tome-starter's read-only viewer role hit this. Its two admin gates, Users.access.admin and the BetterAuth LoginView's requiredRole, both read one as const list, ADMIN_PANEL_ROLE_SLUGS.

    They have to, because widening only one of them is what locked the demo viewer out after a successful login. Passing that list to requiredRole needed as unknown as RoleSlug[], which also turned off type checking on the line.

    requiredRole is now readonly AdminRoleSlug[] | AdminRoleSlug | null, where the newly exported AdminRoleSlug is RoleSlug | (string & {}). Built-in slugs still autocomplete, and non-string values are still rejected.

    The LoginView underneath already took plain strings, and hasRole in rbac.ts already accepted RoleSlug | string. A readonly list is copied into the mutable array the LoginView props declare.

    Type-only for existing callers; the runtime default is unchanged. Consumers can delete the as unknown as RoleSlug[] cast after upgrading.

    A typo in a custom slug is no longer caught by the compiler, which is the same tradeoff hasRole already makes.

1.15.xMinor

  • New @wabbit/tome-core/license subpath: licenseHeartbeat(), an onInit-compatible export (wire it alongside initRoles, same pattern) that reports a deployed site's presence to wabbit's licensing desk — D10's exact shape, { licenseHash, host, tomeCoreVersion, nodeEnv, families }, sent once at boot and then daily via an unref()'d interval so it never blocks or holds open the process.

    licenseHash is the sha256 of the deploy credential the build installed with, resolved from the same sources npm itself would use (NPM_TOKEN env, then the project .npmrc's //npm.wabbit.com/:_authToken, then ~/.npmrc) — the raw token is never sent, and the response is never read. families is the distinct wabbit.family values of every installed @wabbit/tome-* package, read from each one's own node_modules manifest and cached per boot.

    Report-only by contract: there is no runtime denial path, on this end or any other — a heartbeat cannot disable anything. Silent by default (opt out with TOME_LICENSE_HEARTBEAT=off), a 5s timeout, and every failure swallowed to at most one debug-level log line.

    buildHeartbeatPayload(env, opts) is exported alongside it as a pure, network-free helper for anything that wants to inspect the payload shape without sending it.

  • New in @wabbit/tome-core/field-reports (Locations wave): createLocationReportCollection, a SUBJECT-SCOPED report with a reviewed lifecycle and a denormalised read tier — a sibling of createFieldReportCollection, not a variant of it.

    One required option, subjectRelationTo; everything else is a seam: statuses, transitionTable, tiers, initialStatus, timestampOnEnter, the stored field names, and the resolvers for actor, author, subject tier, viewer tier and per-subject grants. Full house seam set — extraFields → fieldOverrides (rename via name) → omitFields → fieldOrder, per-verb access injection merged over the defaults one key at a time, mergeHooks for consumer hooks, labels, relation slugs, slug.

    The five behaviors the donor collection ran as one 100-line beforeChange are split into named built-ins — statusForcing, authorStamping, contentEditGate, transitionGate, tierDerivation — each individually opt-out-able via builtInHooks, so a consumer can replace one without forking the collection.

    effectiveReportTier(tiers, subjectTier, reportVisibility, fallback?) and stricterTier(tiers, a, b) ship as pure, Payload-free exports: a report may be MORE restrictive than its subject, never less. The read gate is built from resolveViewerTier(req) with an optional grantsResolver(req, subjectId), treats tiers as a cumulative loosest-first ladder, keeps the legacy-row arm for documents written before the derived field existed, and FAILS CLOSED on a viewer tier outside tiers.

    The lifecycle table contract lives in the same subpath — ReportTransitionTable, resolveReportTransition, findReportTransitions, allowedReportTransitionsFrom, notSelfReporter, notReportSubmitter — and is structurally identical to @wabbit/tome-workflow's, so a real WorkflowTransitionTable assigns with no cast and that package's own resolveTransition can be injected through the transitionResolver option. Core declares its own copy rather than importing @wabbit/tome-workflow, which already depends on core; the module header records the reasoning and what a future wave should do instead.

    Lifecycle server API, also on ./field-reports: transitionReport (one report, returns a structured outcome rather than throwing — transitioned / refused with what WAS reachable / no-op / not-found), reDeriveTier (recompute the denormalised tier after a subject is reclassified; one read per distinct subject, writes only rows that actually changed, fails closed to the strictest tier on an unreadable subject) and markStale (batch sweep that puts every row through the transition table and COUNTS refusals instead of forcing them). All three drive the collection's own gates through the Payload local API with the actor injected on req.context (LOCATION_REPORT_ACTOR_CONTEXT_KEY, systemTransition) rather than reaching around them, and the bulk paths go through batchWrite + findPaged so a per-row failure is isolated and reported instead of aborting the run.

    No clearance, game-version or intel vocabulary anywhere in the module — every consumer-specific concept is a resolver or a config value.

1.14.xMinor

  • Two more primitives the audit found copied across layers, promoted with their forcing function (2026-09-01 sale-readiness audit §5.1, T3(g)).

    New public surface:

    • `@wabbit/tome-core/fields/address` (new subpath) — postalAddressGroup(opts), postalAddressFields(opts), mapAddress / mapAddressToLegacy, the AddressVocabulary type and the two field-name constants. The same six-field postal block was hand-rolled in FIVE places across three layers (@wabbit/tome-crm accounts + contacts, @wabbit/tome-deals billing + shipping, @wabbit/tome-fulfillment Addresses) in TWO INCOMPATIBLE VOCABULARIES: address1/address2/city/state/zip/country and line1/line2/city/region/postalCode/country. Two vocabularies for one concept guarantees a mapping layer at the CRM → shipping seam, and that mapping did not exist anywhere.

    This module does not pick a winner, and no stored field name changes. Both vocabularies are stored shapes with live rows behind them — renaming zip to postalCode in crm is a data migration, not a refactor. Every adopter passes the vocabulary it already stores.

    required marks the four load-bearing lines only (never line2, never state/region — requiring a state makes the schema US-shaped, which fulfillment's own comment argues against at length). validateCountry carries fulfillment's ISO-3166 alpha-2 check and its uppercase-normalizing beforeValidate hook across verbatim; normalizing rather than rejecting lowercase is deliberate, because the shipping rate table matches on this value and a rejected "us" teaches nothing.

    fieldOverrides merges per-sub-field changes through the existing /fields/fieldShape seam, which is how deals keeps its eight per-field labels and its 'US' country default without a second copy of the block.

    postalAddressFields exists alongside the group because fulfillment stores the postal lines FLAT at collection top level, interleaved with recipientName and phone. Wrapping them in a group to reuse the group helper would have been exactly the stored-shape change this promotion refuses to make.

    • `@wabbit/tome-core/utilities/relationId` (new subpath) — relationId(value): string | null and relationIdOrThrow(value, label). The audit counted this read in core's deprecated lms tree, lms (twelve copies under two names), sc, ledger, crowdfund, fulfillment, lms-ui and an inline ternary in crm. The finding was not the count but four different return types: string | null, string | number | null, string | undefined, string | number | undefined. Two call sites resolving the same row could disagree about equality — a populated doc's numeric id arriving unstringified next to a bare id string.

    Core picks string | null and stringifies: String(id) for both string and number, null (never undefined) for absent, null for an array (a hasMany value is the caller's loop, not a silent first-element read). Payload accepts either form in a where clause, so ids fed back into a query are unaffected, and === between two resolved ids now means what a reader thinks it means.

    A caller that genuinely needs the id in its stored type should not use this — that is a documented divergence rather than a fifth accidental copy.

    Forcing function: scripts/assert-no-forked-primitives.mjs gains a fourth, STRUCTURAL check. Checks 1–3 compare code, and the address fork is invisible to all three because it is not code — it is five object literals whose sub-field name sets are the same schema typed out by hand.

    The new check finds every type: 'group' field literal, reads the direct sub-field names out of its fields: array, and warns when that set COVERS either vocabulary. Coverage rather than equality on purpose: exact equality would have caught none of the five real copies, since crm prepends name and deals prepends name + company.

    Run on the pre-adoption tree it reports all four group-shaped copies (crm accounts:89, crm contacts:135, deals:192 and :207); on the adopted tree it reports none.

    @wabbit/tome-ledger keeps its own extractId, for the same architectural reason as its mergeHooks (core is an OPTIONAL peer there) plus a second one: its semantics differ deliberately (string | undefined, string-only input). That exemption is recorded as prose above the assert's ALLOWLIST rather than as an entry, because the ALLOWLIST is FILE-granular — an entry for a nine-line function inside a 130-line module would never match and would print as STALE on every run forever, training readers to ignore the stale report.

    That is the failure mode assert-test-floor.mjs already demonstrated for seven weeks.

  • Add batchWrite / batchWriteInChunks — the platform's bounded-concurrency batch writer — at the new @wabbit/tome-core/utilities/batch subpath.

    The 2026-09-01 sale-readiness audit found 15 serial-await-in-loop sites across @wabbit/tome-org, @wabbit/tome-lms and @wabbit/tome-sc: Payload afterChange/afterDelete hooks and reconciler jobs fanning out N independent writes one await at a time, so an event with 200 attendees blocked the request for 200 sequential round-trips. In every case the correct pattern already existed a few files away in the same package — division-team-reciprocity.ts:101-108 batched while :88-99 did not; gdpr.ts used one bulk update in one handler and a per-row loop in its sibling.

    The primitive was never extracted, so nobody adopted it, so it kept getting rewritten wrong.

    • batchWrite(items, fn, { concurrency = 8, onError, logger, label }) — bounded-concurrency mapper. results is index-aligned with items regardless of completion order, and a per-item throw is isolated into errors instead of poisoning its siblings (the failure mode that makes a naive Promise.all unsafe in a cascade hook, where one missing related doc must not abort the other 199 updates). onError: 'throw' reproduces a serial loop's exact abort semantics for callers that want them.
    • batchWriteInChunks(items, fn, { chunkSize = 25, pauseMs = 250, shouldStop, ... }) — the same, plus the WRITE_CHUNK/WRITE_PAUSE_MS pacing invented in @wabbit/tome-lms's reconcileCourseCompletionAwards: process a chunk, then pause, so a write's own afterChange fan-out has room to drain before the next chunk lands. shouldStop carries a per-run write budget without abandoning the pacing.

    The module header states what it is NOT for: a bulk payload.update({ where, data }) beats any amount of concurrency when every item takes the same data, and a deliberately ordered loop (money capture, ledger legs, settleCampaign's maxCapturesPerRun counter) stays sequential — pass concurrency: 1 when you want the pacing and error isolation but must keep strict ordering.

    Shipped with its forcing function: a no-restricted-syntax rule in eslint.config.mjs warns on await payload.* as a direct statement inside a for…of/for body and points here.

  • createKeyedRegistry in core, and the gate that keeps the next registry anchored.

    Tome had eleven keyed registries and two implementations of one idea: five anchored their state on globalThis via Symbol.for, six held a module-local Map (2026-09-01 sale-readiness audit §5.1, "same mechanism, half correct").

    The half that is wrong is wrong silently. A published package ships separate ESM and CJS builds — distinct module instances with distinct module-local state — so the moment one consumer static-imports one build and another require()s the other, or a bundler splits an RSC/SSR/client graph, a module-local Map exists twice and a registration made through one is invisible through the other.

    Nothing throws; the handler just never fires. layerRegistry shipped that bug in 2026-05 and moved onto globalThis in tome-core 1.0.10, both it and the render registry explain the mechanism at length in their headers, and six registries were written afterwards without it.

    A comment cannot make the next author read it.

    New in core: @wabbit/tome-core/registry/createKeyedRegistry (a NEW exports-map subpath — hence the minor). createKeyedRegistry<T>(symbolKey, { onDuplicate, validate }) returns { register, replace, get, has, list, clear } over a store anchored at globalThis[Symbol.for(symbolKey)].

    onDuplicate is 'throw' (default) / 'replace' / 'ignore', chosen to match each migrating registry's CURRENT behavior rather than a preferred one. replace() is the explicit override path every throw-on-duplicate registry in the repo already exposed for tests and consumer shadowing.

    The module's JSDoc carries the full migration recipe for the registries not migrated here. 14 unit tests, including the dual-instantiation proof: two separately-created registries on one key share a store, and the state survives a vi.resetModules() re-evaluation of the defining module — a module-local Map fails both.

    Migrated in core: gdpr/registry.ts. This one had BOTH halves of the defect — a module-local Map inside GdprRegistryImpl, and absence from core's own sideEffects array — while four layers (fulfillment, org, sc, plus consumer sites) register into it by import side effect.

    Split state meant runErasure/exportUserData reporting zero rows for collections registered into the other copy; a missing sideEffects entry meant a bundler was free to drop the registering module outright. The store is now anchored (onDuplicate: 'replace', matching registerCollection's documented overwrite) and ./dist/gdpr/registry.* is in sideEffects, with a sideEffectsRationale block in the manifest recording why each entry is there.

    The class API is unchanged — same names, arguments, semantics, and getAll()'s registration-order guarantee. unregisterCollection rebuilds the store minus one key (the helper exposes no per-key delete because nothing else needs one), preserving that order.

    Migrated in deals: both registries. registry/side-effect-registry.ts is now a delegation shim over @wabbit/tome-workflow's registry (see the workflow-adoption changeset) — anchored by that route.

    registry/artifact-registry.ts moves onto createKeyedRegistry, INCLUDING its frozen flag: a freeze applied to one module instance while another still accepted registrations would have enforced the config-time contract in exactly half the process. Public API, throws and messages are unchanged.

    This registry is populated in payload.config.ts and read during collection construction, and under the Payload CLI those are separate module instances — the observable failure was an artifactType select with no options and a thrown "Unknown artifact type".

    Forcing function: scripts/assert-registry-anchoring.mjs + pnpm assert:registry-anchoring, wired into platform-discipline.yml immediately after assert:no-forked-primitives (source + manifest reading only, so it runs pre-build and fails fast). Any module-scope mutable Map/Set/instance singleton whose name — or whose FILE name — announces a registry must import createKeyedRegistry, contain Symbol.for(, or have its built path listed in the package's sideEffects array; otherwise it fails with the migration recipe.

    Before this change it reported 3 violations (core's gdpr registry and both deals registries) and now reports 0.

    Eight registries are ALLOWLISTED with a written architectural reason each, not a schedule: forms ×3, intake and print are owned by the forms+intake access wave and their file sets are off-limits to this one; blocks-core/src/registry/index.ts is the deliberately explicit-instance DESCRIPTOR registry (ARCHITECTURE.md § Three Registry Mechanisms #2 — the registry that genuinely must be one store, the render registry, is separately Symbol.for-anchored and passes), and changing it is a twelve-package linked-family decision; blocks-gallery's two are import-side-effect registries its own header already calls "the outlier, not the template", in a package with zero tests, so they migrate in the wave that gives it tests.

  • Adopt the three primitives that four other layers had each copied verbatim, and add the gate that stops the next copy (2026-09-01 sale-readiness audit §5.1, T3(a)).

    New public surface:

    • `@wabbit/tome-core/jobs` gains findPaged, readPositiveNumber, chunk, JOB_PAGE_SIZE, JOB_DEFAULT_MAX_PAGES and the FindPagedArgs type — the bounded paginated job scan that existed identically in @wabbit/tome-lms, @wabbit/tome-crowdfund and @wabbit/tome-workflow. Each of those three headers named this exact promotion as its own trigger condition; crowdfund's said "if a third layer needs it, it is promoted into @wabbit/tome-core/jobs and both call sites collapse". Workflow became the third layer on 2026-08-18. No behavior change: the body is the lms/crowdfund version, with the collection slug now passed through core's own typedSlug() rather than an inline cast. The ./jobs subpath already existed — these are new named exports on it, so a consumer importing them needs core >=1.14.0.
    • `@wabbit/tome-core/fields/fieldShape` (new subpath) — applyFieldShape, applyFieldOverrides, applyOmitFields, applyFieldOrder, insertFieldsAfter, plus the FieldOverrideMap and FieldShapeConfig types. The canonical body is @wabbit/tome-ledger's, the superset (it is the only copy that had the applyFieldShape combinator). Where the three copies had drifted they had drifted only in two error-message strings; the longer wording is kept, because it names the most common cause of the error ("check for a fieldNames rename applied before this").
    • `@wabbit/tome-core/fields/selectOptions` (new subpath) — the ONE { mode: 'extend' | 'replace'; options } select-vocabulary override contract, with extend deduped by value. The audit found this contract implemented five times under one name in three incompatible shapes, so the same config key behaved differently depending on which layer emitted the field. Core's own field-reports/shared.ts and gdpr/compliance/processingRegister.ts now import it and re-export their existing symbols unchanged.

    Deprecated, not removed: SelectOptionOverride in @wabbit/tome-core/identity (the { extend?, replace? } shape) now carries @deprecated pointing at the canonical contract. It is public API on a 1.x package and keeps working — MemberCollectionConfig.identity.standingOptions is unaffected — but nothing new should adopt it.

    New gate: pnpm assert:no-forked-primitives (scripts/assert-no-forked-primitives.mjs), wired into platform-discipline.yml immediately after assert:declared-imports (source-only, pre-build). It hashes comment-stripped, import-stripped file bodies across every packages/*/src/**/*.ts and fails on a non-core file identical to a core file, or on two non-core files identical to each other.

    A second, function-granular check warns on near-forks — two files sharing at least two identically-named exported functions whose bodies are ≥90% identical — which is how the fieldShape trio would have been caught before it drifted. One allowlist entry, @wabbit/tome-ledger's mergeHooks, with the architectural reason stated in full: ledger declares core as an OPTIONAL peer.

    The failure mode this closes is not "we forgot to DRY this up". Four files in the repo stated their own promotion trigger in prose, the trigger fired, and nothing happened, because a comment is not a gate.

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

  • fields/link no longer throws "(0, import_deepMerge.default) is not a function" when required from CommonJS.

    The internal deepMerge helper was a default export consumed via a default import inside the package; with tsup bundle: false that compiles to __toESM(require(...), 1).default — the whole module object, not the function — so any CJS consumer of @wabbit/tome-core/fields/link (tome-chrome's header factory under raw Node or the Payload CLI) failed at the first link() call. ESM consumers were unaffected, which is why it went unnoticed until assert:node-loadable ran across every package.

    deepMerge is now a named export; the rule (named exports for intra-package modules, default only for React components) is recorded in docs/claude-gotchas.md.

  • infra/envScaffold now covers the whole platform, not core's own third of it.

    validateEnv() checked 10 variables; the platform reads about 22, and every paid integration was outside the check — Stripe, Encharge, Kit, Resend, AI_CREDENTIAL_KEY, TOME_ADMIN_LICENSE_KEY. That is not a boot crash, it is a checkout that 500s or a webhook that verifies nothing, in production, weeks after the deploy that caused it.

    New LAYER_ENV_VARS, keyed by package name so it takes the same identifiers as hasLayer(), covering economy, crowdfund, marketing, ai, admin-pro, deals, intake, crm and print. validateEnv({ layers }) merges the named blocks on top of SHARED_ENV_VARS; when two layers declare the same variable the strictest requirement wins and the descriptions are joined, so a site running both economy and crowdfund gets one STRIPE_SECRET_KEY line carrying both reasons.

    An unknown layer name throws rather than contributing nothing — a typo there would silently check less than the caller believes.

    Backwards compatible: validateEnv() with no argument checks exactly the set it always did, so no existing payload.config.ts changes behavior. EnvVarSpec and the new ValidateEnvOptions are exported.

    required here means "required GIVEN this layer is installed", and the distinction is real rather than cosmetic: the Stripe pair and TOME_ADMIN_LICENSE_KEY are hard failures, while the marketing keys stay warnings because a site runs Encharge or Kit or neither and making them fatal would break a legitimate deployment. RESEND_API_KEY is likewise a warning — its absence selects a supported log-instead-of-send mode.

    One real bug fell out of the survey: SHARED_ENV_VARS and env.example both documented `POSTHOG_HOST`, while infra/posthog.tsx has always read `NEXT_PUBLIC_POSTHOG_HOST`. A site that set the documented name got the /ingest reverse-proxy default and no error.

    The scaffold now names the variable the code reads; env.example gains the per-layer block with the same required/optional annotations. 13 new tests in tests/infra/envScaffold.test.ts.

  • `infra/plugins.ts` no longer types the five Payload plugin wrappers `(config: any)`.

    This is the one place every Tome site wires redirects, SEO, form-builder, search and nested-docs, and it was the audit's headline "load-bearing any" (§6): a misconfiguration here was invisible to tsc for every consumer at once.

    Each constructor's parameter is now recovered as Parameters<typeof pluginFn>[0] through a type-only import of the plugin package. Type-only because the constructors are still passed in by the consuming site — importing them for real would reintroduce the ESM/CJS resolution problem that shape exists to avoid — and import type erases entirely at build time, so this adds zero runtime coupling.

    Parameters<...> rather than a named config import because the plugin packages export only their constructor from the barrel; the config interfaces live in unexported ./types.js modules. A side benefit: the types track whichever plugin version the consumer resolved instead of a snapshot copied into this file.

    All five packages are already declared peerDependencies of core, so nothing is added to the manifest.

    New exported types: RedirectsPluginOptions, SeoPluginOptions, FormBuilderPluginOptions, SearchPluginOptions, NestedDocsPluginOptions. search.beforeSync is typed from the plugin's own config instead of (args: any) => any.

    The stricter types immediately found one real seam: nested-docs' generateURL takes four arguments and untyped docs, while Tome's option has always been the narrower (docs: {slug}[]) => string. Widening the public option would break every consumer (the narrow callback is not assignable to the wider parameter under strictFunctionTypes), so the public shape is unchanged and basePlugins now adapts between the two explicitly, defaulting a missing slug to '' instead of emitting /undefined — the runtime bug the any had been hiding.

    .d.ts note: the emitted dist/infra/plugins.d.ts now references the @payloadcms/plugin-* types. Consumers already install these to pass the constructors in, and they are declared peers; a consumer with skipLibCheck (the Payload default) is unaffected either way.

  • validateEnv({ layers }) now knows about TOME_CRM_BOOTSTRAP_READ_FALLBACK, the flag crm 0.6.0 introduced to re-open the bootstrap crm:read bridge that is otherwise off in production.

    It is optional, so nothing fails without it; the point is that an operator reading the per-layer env schema to answer "what does installing the CRM oblige me to configure?" now sees the switch that governs whether unseeded sites expose contact and account PII, instead of discovering it from a log warning after the fact.

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

  • README fixes surfaced by the extended assert:readme-contract gate (2026-09-01 sale-readiness audit, Tier 2), each verified against the package's own manifest or source.
    • blocks-core — the ./categories and ./types entry points are now named in the Public API section; both were published but undocumented.
    • core — added /access/orgScoped, /access/vendorScoped, /infra/health and /infra/env-scaffold to the additional-subpaths table, and noted that /auth/collections/roles has a real /auth/collections/Roles case alias in the exports map.
    • crowdfund — CROWDFUND_LAYER_VERSION is also published standalone at ./version; the row now says so.
    • dispatch — the eight per-block ./blocks/* config subpaths and all eight ./components/* component subpaths are enumerated instead of one "etc." row.
    • forms — the peer table now lists @wabbit/tome-core, @wabbit/tome-ui and typescript, which are declared peerDependencies but appeared only in prose (or not at all).
    • lms-ui — StudentProfileEditor is flagged @deprecated in the component table, matching the tag its source has carried since the permissions-naming convergence pass.
  • README peer tables, and the gate that now requires them.

    Sixteen packages declared peerDependencies and documented them nowhere a reader could scan — in prose inside an install paragraph, in a transposed "compatibility matrix" with the peers as columns, or not at all. Docs only; no source, no manifest, no runtime change (the one manifest change in this PR, admin's sonner peer, has its own changeset).

    Each of the sixteen gains a ## Peer dependencies section generated from its own package.json — | Peer | Range | Required |, one row per peer, the range verbatim, no (optional) read from peerDependenciesMeta, plus one sentence on what is a real dependency rather than a peer and why the optional ones are optional. The worst omissions this surfaced: @wabbit/tome-core documented 2 of its 13 peers and left out both next and @payloadcms/richtext-lexical, which are required; @wabbit/tome-admin listed 5 of 20; @wabbit/tome-readout and @wabbit/tome-sc listed none.

    Eight block packs carried a hand-typed compatibility table that had drifted a full React major — still >=18 after the peer floor moved to >=19.0.0 — and none of the eight listed react-dom at all. Those tables are retired in favor of the generated one, with a line saying what they used to claim so the next reader does not reinstate them.

    The forcing function ships with the fix: scripts/assert-readme-contract.mjs now FAILS a package that declares peers without a peer table (a markdown table whose header row names a Peer and a Range column — the existing Optional? and Notes third columns still pass, so the thirty already-conforming READMEs were not touched). It is deliberately shape-only, not row-level: asserting that each row agrees with the manifest is the Tier 2 generation work.

    Verified non-vacuous by breaking one table's header and watching the gate fail, then restoring it. CONTRIBUTING.md's assert-script list — which said "five" while sixteen existed — and the three guides that describe this gate were corrected in the same pass.

1.13.xMinor

  • Wave 6 org plan, W6-I4 — createMemberCollection (@wabbit/tome-core/identity) grows the config surface a consumer's members collection needs to host it, upstream only: nothing is adopted here, and every option is additive with a default that reproduces the pre-I4 output byte-for-byte (a new "default snapshot" test block asserts this, kept green through the change).
    • `requireUser` (default `true`) + `userValidate`: the user relationship can now be optional — a production consumer's members collection has 1395 rows, 1167 with user: null (all standing: 'legacy'). unique: true stays on the field either way; Payload's unique index is sparse for optional fields, so multiple null rows don't collide. userValidate is wired only when requireUser: false.
    • `identity` config (`displayNameRequired`, `titleField`, `slugSource`, `slugField`): lets a consumer make displayName optional, point admin.useAsTitle at a different field (e.g. a consumer's rsiHandle), and source the generated slug from that field instead — or supply their own slugField(...) pair entirely (slugField: Field[]), or drop the slug field while keeping displayName (slugField: false). This composes WITHOUT skipDefaultIdentity, which still drops both displayName and slug unchanged, exactly as before.
    • `standingOptions` (`true | { extend?, replace? }`) + `standingDefault`: adds an opt-in standing select field. Omitted by default — the pre-I4 factory has no standing field at all, and @wabbit/tome-org's Member wrapper still supplies its own via extraFields today, unchanged by this release. DEFAULT_MEMBER_STANDING_OPTIONS exports the 8 values org hardcodes today, so a later increment can reference the same base set instead of re-typing it.
    • `defaultPopulate` / `indexes` passthrough: the factory did not spread unknown config into the returned CollectionConfig — both are now explicit passthroughs, present in the output only when provided.
    • `defaultReadAccess` (`'authenticated' | 'self-or-admin' | Access`): lets a caller choose the read strategy without replacing the whole access object (which would also drop the create/update/delete defaults). Payload's Access type already supports a Where-returning function (sync or async) — Contract C2 — so a custom function honors a consumer's membersReadAccess verbatim. Ignored when access is provided; access always fully replaces, unchanged.
    • Hook merge, every key: config.hooks used to merge beforeValidate by hand (built-in first, caller appended) but spread every other key wholesale — a caller-supplied afterChange would have silently REPLACED a future built-in afterChange hook the moment one was added, the same bug independently found and fixed in @wabbit/tome-org and @wabbit/tome-sc. New shared @wabbit/tome-core/hooks/mergeHooks leaf subpath (ported verbatim from packages/org/src/hooks/mergeHooks.ts / packages/sc/src/extensions/mergeHooks.ts) generalizes the append semantics across every hook key; org/sc can migrate their local copies to this one in a later increment.

    Read-only consumer verification: tome-starter and wabbit-site-core call createMemberCollection with none of the new options (unaffected); @wabbit/tome-org's Member.ts wrapper typechecks clean against the new signature; @wabbit/tome-lms does not consume this factory.

  • Close three ./field-reports gaps the first adoption attempt by a production consumer hit (Wave 2R I4.1), all additive with unchanged defaults: createWatchlistGlobal gains fieldNames: { organizations?, externalId?, name?, category?, notes? } (the array field name was previously hardcoded to organizations; externalIdField stays as a deprecated alias); createRiskScorer gains watchlistFieldNames (reads the watchlist through the SAME names createWatchlistGlobal was given, instead of piggybacking on the assessment collection's fieldNames.affiliationExternalId) and flagNames (overrides every emitted flag string — multiAffiliation/hiddenProfile/redactedProfile/newAccount/manualFlag/categoryFlag — threaded through score, assessAndStore, and runReassessRisk via the new resolveFlagNames export); registerFieldReportsGdpr gains reports/assessments options, each false (skip registering that collection) or { phase?, order? } (reposition it), replacing "the later registration silently overwrites the earlier one by slug" as the only way to coexist with a consumer's own pinned registration.

    A new "consumer-compatible config" test block in scorer.test.ts ports every case from that consumer's characterized scoreThreat.spec.ts and confirms identical scores/flags (one pre-existing divergence carries over: the single visibility enum here splits the consumer's combined 40-point "hostile + hidden + redacted" case into two 30-point assertions).

1 fix (1.13.1)
  • 1.13.1
    Wave 6 org-adoption I4.1 — closes the identity.displayNameRequired gap the first createMemberCollection consumer (a production consumer, 1395 rows, 0 with displayName) hit, and grows the factory's generic field coverage.

    All opt-in, defaults byte-identical.

    `identity.includeDisplayName?: boolean` (default `true`). displayNameRequired only ever toggled the field's required flag — there was no way to omit displayName entirely while keeping the factory's slug handling. skipDefaultIdentity: true was the only escape, but it drops displayName AND the slug pair TOGETHER, forcing that consumer's real adapter to re-supply its own slug pair verbatim via extraFields just to keep a factory-composed slug.

    includeDisplayName: false closes the gap: no displayName field is emitted, admin.useAsTitle/defaultColumns fall back to identity.titleField (now REQUIRED in that case — the factory throws MemberIdentityConfigError at collection-definition time otherwise), slugSource defaults to titleField instead of 'displayName', and the slug field itself is still emitted by default (pass slugField: false to also drop it, same as today).

    Six generic member-table fields, each opt-in and default off — the factory only ever emitted 4 of that consumer's 62 members fields (user/avatar/bio/standing); these are the ones judged generic enough for any member table to plausibly want, from auditing what that consumer's real adapter supplied via extraFields:

    • includeClassification — 'public' | 'classified' select, default 'public' (DEFAULT_MEMBER_CLASSIFICATION_OPTIONS exported).
    • includePortrait — upload field (relationTo mediaCollection), distinct from the always-on avatar.
    • includeReportsTo — self-relationship (relationTo the collection's own slug) for chain-of-command data.
    • includeTimezone + timezoneField (default 'timezone') — plain text field, not a curated select (the factory doesn't own a timezone-options list); rename to match an existing column (e.g. timeZone).
    • includeRegion — six-value coarse-geography select (DEFAULT_MEMBER_REGION_OPTIONS: NA/EU/APAC/OCE/SA/MEA).
    • includeJoinDate + joinDateField (default 'joinDate') — plain date field. Does NOT replicate a nested onboarding/induction group shape some consumers may use instead.

    None of these byte-match any one consumer's existing field shape (different value casings, no curated timezone list) — generic defaults a future consumer can take as-is, rename where a *Field option is offered, or still fully override via extraFields.

    Also: admin.defaultColumns' first entry now reads titleField instead of a hardcoded 'displayName' literal, so it never references a dropped field when includeDisplayName is false (unchanged value when includeDisplayName is true, since titleField still defaults to 'displayName').

    New MemberIdentityConfigError exported from @wabbit/tome-core/identity. New tests in tests/identity/createMemberCollection.test.ts (2 new describe blocks, 20 new cases); the pre-existing "default snapshot" block is untouched and green.

1.12.xMinor

  • Add ./field-reports subpath (Wave 2R / 2R-I4): subject watchlist + scored risk assessment + free-text field reports, absorbed from a consumer's intel cluster.

    createFieldReportCollection, createSubjectAssessmentCollection (fully overridable stored field names via fieldNames — zero migration), createWatchlistGlobal, createRiskScorer (score/assessAndStore, injectable weights/bands/thresholds, case-insensitive upsert), inputFromProfile, the standard task/endpoint adapter pair createReassessRiskTask/createReassessRiskEndpoint (cache-only rescore), and registerFieldReportsGdpr (reports null-ref; assessments conditional redact per the retain-with-basis decision).

1.11.xMinor

  • Add @wabbit/tome-core/verification — neutral external-profile-verification primitives absorbed from a consumer's external-profile hardening work (Wave 2R / 2R-I1): createProfileVerificationFlow (HMAC rolling-window ownership-proof codes + signed tokens), createDistributedRateLimiter + createInMemoryLimiterClient (fail-closed by default — a deliberate inversion of the always-fail-open behavior it was absorbed from), createResilientFetcher (retry/backoff, explicit retryOn status policy defaulting to 429/>=500 with Retry-After honored and capped at timeoutMs — never throws on an HTTP status), the ExternalProfileAdapter<TProfile> contract, createProfileCacheCollection + createProfileCacheInvalidator, and runProfileReconcile with the standard task/endpoint adapter pair createProfileReconcileTask/createProfileReconcileEndpoint.

    Additive only — nothing in the monorepo consumes this subpath yet (RSIProfileAdapter lands in @wabbit/tome-sc at 2R-I2).

1.10.xMinor

  • New ./gdpr/compliance and ./gdpr/compliance/breach subpaths (Wave 5 / W5-I7): three optional, consumer-opt-in compliance artefacts absorbed from a production consumer, none of them wired into any existing gdpr consumer — nothing changes for a site that does not import this subpath.
    • `createProcessingRegisterCollection(options)` — an Article 30 processing register factory (neutral default slug processing-records, overridable). The absorb win: collectionsInvolved is now a select whose options are DERIVED from gdprRegistry.getAll() at factory-call time (plus a free-text otherCollections escape hatch), replacing the consumer's free-text field that had already drifted from what was actually registered. access is injected via an option — the default uses only core's generic admins/authenticated guards, never a hardcoded consumer-specific permission key. validateAgainstRegistry(rows, registrations?) cross-checks in both directions: register rows naming a collection no longer registered (stale), and registered collections no register row covers (undocumented processing).
    • `createConsentLedgerCollection(options)` — an immutable (update: () => false, not overridable) consent ledger factory: user, action (select, extensible via actions), policyVersion, ipHash, userAgent, metadata, at. Collapses the consumer's consentType + granted boolean + grantedAt/revokedAt pair into one generic action + at — the specific consent taxonomy is a consumer's extraFields, not core's business. Auto-registers itself with gdprRegistry as mode: 'retain' by default (registerGdpr: false to opt out) — it IS the erasure proof (Art 7(1)), never deleted regardless of the rest of a consumer's cascade. Plus pure helpers requiresReconsent(consentedVersion, currentVersion) (major-version-only comparison, no module-level constant) and recordConsent(payload, input, options?) (Local API, overrideAccess: true).
    • `./gdpr/compliance/breach` — assessBreachSeverity, generateAuthorityNotification, generateSubjectNotification, createBreachRecord, ported from a consumer's breachNotification.ts (5 exports, ZERO call sites in production — absorbed as untested prior art, not hardened code; these are its first tests, full stop). Two real fixes made during the port: org identity (organizationName/dpoContact) is now a caller-supplied BreachNotificationConfig instead of hardcoded to the consumer's name/contact, and Article 33(3)(c)/34(2)'s REQUIRED "likely consequences" content is a real report.likelyConsequences field with an honest fallback sentence instead of a rendered [To be assessed based on breach specifics] placeholder. createBreachRecord(payload, report, { slug, ... }) takes its target collection explicitly rather than assuming the consumer's audit-logs.

    65 new unit tests: factory option overrides, collectionsInvolved/validateAgainstRegistry both directions, ledger immutability + auto-registration, the requiresReconsent major-version matrix, recordConsent's call shape, assessBreachSeverity's score-boundary matrix, and both notification templates' Article 33(3)(a-d)/34(2) required-field coverage.

1 fix (1.10.1)
  • 1.10.1
    createProcessingRegisterCollection (@wabbit/tome-core/gdpr/compliance, Wave 5 / I7.1) gains override options for its three fixed-vocabulary select fields — dataCategoryOptions?, dataSubjectOptions?, securityMeasureOptions?: { mode: 'extend' | 'replace', options: {label, value}[] } — closing a gap the initial absorb (I7) left: a consumer migrating an existing register with vocabulary the default list doesn't cover (dataCategories: fleet, dataSubjects: veterans — renamed alumni on the way in — and four securityMeasures values) had no way to keep it.

    extend appends the consumer's options after the default vocabulary, deduped by value (default wins on collision); replace substitutes wholesale, the same full-replace contract access already uses. The three default arrays (DEFAULT_DATA_CATEGORY_OPTIONS, DEFAULT_DATA_SUBJECT_OPTIONS, DEFAULT_SECURITY_MEASURE_OPTIONS) are now exported so a consumer can compose rather than retype them.

    Also adds userCollection?: string (default 'users') for lastReviewedBy's relationTo, mirroring createConsentLedgerCollection's option of the same name — it was the one relationship field in this factory not already injectable.

    Fully backward compatible: a caller passing none of the four new options gets today's fields, byte-identical.

1.9.xMinor

  • New ./gdpr/retention subpath (Wave 5 / W5-I5): a pure runRetention({ payload, policies, now?, dryRun?, lock?, findDueErasures?, onErasureDue?, onStalledErasure?, stalledAfterDays? }) retention engine, absorbed from a consumer's gdprRetentionCleanup task with its single-flight lock and supersede semantics carried along, ported test-for-test (74 new tests) and shipped with both standard task/endpoint adapters.
    • Policy model as data: defineRetentionPolicies([...]) validates and normalizes RetentionPolicy = { collection, action: 'hard-delete' | 'anonymize' | 'retain', olderThan: { field, days }, where?, batchSize?, label? } (plus onAnonymize, required when action: 'anonymize' — new relative to the donor engine, which never anonymized anything at the retention horizon). Cutoff is strict less-than (a row exactly at the cutoff is not yet eligible). Hard-delete batching re-queries page 1 like deleteInBatches; anonymize batching pages by explicit page number instead, since an anonymized row usually stays matched. Both cap at 2000 batches and report hitting the cap as a policy errors[] entry, not just a log line.
    • Erasure-due sweep: given a caller-supplied findDueErasures(now) (its own query + assembled lifecycle trail) and onErasureDue(candidate) (loads user/member, calls runErasure), the engine applies the supersede check via resolveErasureTrail before dispatching — the 6a4c2f47 regression fixture (an erasure request re-executed 2-6 times a night for 18 nights) is now a pinned unit test, alongside "complete-then-new-request executes once."
    • Single-flight lock seam: lock?: { acquire(key): Promise<Release | null>, onUnavailable?: 'skip' | 'run' }. Required outside dryRun whenever policies or findDueErasures are non-empty — runRetention throws immediately rather than running unlocked by omission. Ships createInMemoryRetentionLock() for tests; no redis dependency added. `onUnavailable` defaults to `'skip'` (fail CLOSED) — this inverts the donor's acquireDrainLock, which fails OPEN on a Redis outage. The 6a4c2f47 incident is the reason: running this sweep unlocked is a repeatable, multi-week data-integrity failure, judged worse here than skipping one tick. A consumer under legal-deadline pressure opts back in via onUnavailable: 'run'.
    • Officer-task escalation: onStalledErasure?(info) fires once per candidate, only when it remains unresolved after onErasureDue was tried and is overdue past stalledAfterDays (default 1).
    • Adapters: createRetentionTask(config) → Payload TaskHandler, createRetentionEndpoint(config) → CRON_SECRET-guarded endpoint. Both call the identical runRetention.

    The donor consumer's 9 retention policies ship ONLY as a test fixture (tests/gdpr/retention/fixtures/consumerPolicies.ts) exercising this model — their slugs are that consumer's collections, not core's default policy set. See packages/core/src/gdpr/README.md's ./gdpr/retention section for the full contract.

1.8.xMinor

  • GDPR registry gains cascade semantics needed to carry a production consumer's Article 17 erasure engine (Wave 5 / W5-I1), with zero behavior change for existing registrants.

    GdprCollectionRegistration adds optional mode ('hard-delete' | 'soft-anonymize' | 'redact' | 'retain' | 'null-ref', default 'hard-delete'), phase ('pre-identity' | 'identity' | 'post-identity', default 'pre-identity'), order (default 0), memberField (a second FK for member-keyed collections), and mode handlers onRedact/onAnonymize/onNullRef. gdprRegistry.getOrdered() sorts by phase then order then registration sequence — a no-op for any registry containing only default-mode registrations.

    New runErasure({ payload, user, member?, identity, dryRun? }) dispatches every registration on its mode and returns { status: 'completed' | 'completed-with-errors', steps }, one StepResult per registration, with no bare catch {} — every failure lands in that step's errors[] and the run continues. deleteAccount is now a thin wrapper over runErasure that reproduces its pre-existing behavior byte-for-byte for starter/wabbit-site-core (pinned by a call-sequence parity test); exportUserData now iterates getOrdered() instead of getAll() (identical order for default-mode registries).

    Also adds ./gdpr/erasureState: pure, zero-import cooling/due/overdue classification (classifyErasureState) and a generic latest-wins trail resolver (resolveErasureTrail) absorbed from the same consumer's erasure-lifecycle utilities, ported test-for-test.

1 fix (1.8.1)
  • 1.8.1
    GDPR onDelete/onExport handlers now receive the full GdprStepContext (Wave 5 / I1.1) — the same object onRedact/onAnonymize/onNullRef already got — instead of a bare { payload, userId, userEmail }, closing the gap the first runErasure consumer hit: a member-keyed collection (member-notes.member, wallets.owner, group-memberships.member) had no memberId to query by, and neither handler could see the subject's pre-overwrite identity.

    Fully additive — a handler destructuring only the old three keys is unaffected. exportUserData's ExportArgs gains optional member?/identity? to thread this through; both default to absent/{} for every caller today.

    onDelete may now return either a bare number (legacy — normalized to { count, errors: [] }) or a GdprStepOutcome ({ count, skipped?, errors }), so a handler can report a partial sweep (e.g. a media cap that leaves files behind) without throwing. Throwing still forfeits the count — a rejected promise carries no return value, so a thrown error always reports count: 0.

    Verified the default (handler-less) hard-delete path already honored memberField (buildDefaultWhere's userField = userId OR memberField = memberId OR-clause predates this change); documented that null-ref has no default dispatch at all and has always required onNullRef unconditionally, since nulling a reference needs to know which field(s) to null and to what value.

1.7.xMinor

  • Phase A shared contracts — six cross-layer seams, all additive.
    • `/authority` — injected AuthorityResolver + request-cached getAuthority. The platform owns the seam, shape and caching guarantee; it never implements the cascade, which is consumer org policy. scopes is a string-keyed map rather than named fields so consumers with differing hierarchies aren't forced to misrepresent them.
    • `/access/scoped` — ScopedAccessResult (boolean | Where), andWhere/orWhere with defined boolean short-circuits, and whereScopedTo bridging a resolved authority to a row constraint. It returns false, never {}, when a subject commands nothing: an empty Where matches every row, so "no authority" expressed as {} is a total access bypass.
    • `/notifications` — task-notification emitter interface (no collection). Frozen dedupKey/groupKey conventions, no-op default so layers work standalone, and emit/resolve helpers guaranteed not to throw — a notification failure must never fail the mutation it describes.
    • `/jobs` — framework-agnostic JobHandler plus asPayloadTask and asCronEndpoint, so layers ship logic and consumers choose a runner. Both adapters invoke the same function. Also exports authorizedCronRequest.
    • `/config/assertRelationTargets` — startup validator for dangling relationTo targets. A missed sibling slug does not error in Mongo; it returns zero rows months later.
    • `registerSuperRoles` / `isSuperRoleUser` (in /auth/permissions) — opt-in, empty by default, wired into all three resolution paths. With nothing registered, behavior is byte-identical to before.

    Security fix: three copies of authorizedCronRequest short-circuited on authHeader.length !== expected.length — the exact leak utilities/timingSafeEqual was promoted into core to eliminate. Core's two copies now delegate to the shared hash-then-compare implementation, so a wrong-length header costs the same work as a right-length one.

    It also rejects a blank secret outright.

1 fix (1.7.1)
  • 1.7.1
    dist is now loadable by raw Node. tsup builds with bundle: false, so it emitted relative specifiers exactly as the TypeScript source wrote them — extensionless (from "./hierarchy", require("./hierarchy")).

    Bundlers and tsx resolve those; raw Node does not. ESM raised ERR_MODULE_NOT_FOUND, and CJS was worse: require("./x") resolved to the ESM .js twin (.cjs is not in Node's CJS extension search list), and Node 22+ require(esm) then died on _that_ file's own extensionless import.

    Any consumer outside a bundler — the payload CLI under plain node, generate:types, ops scripts, codegen tools — hit this on every subpath that had relative imports; single-file subpaths loaded fine, which is why it went unnoticed. A post-build step (scripts/fix-dist-extensions.mjs --strict) now appends explicit extensions (.js / /index.js, .cjs / /index.cjs) and fails the build on any specifier it cannot resolve rather than guessing.

    No source changes, and bundler consumers are unaffected — extensioned relative specifiers are universally resolvable.

1.6.xMinor

  • Platform follow-up fixes across three packages.

    @wabbit/tome-core (minor): createBetterAuth() now exposes email-delivery pass-throughs so production consumers can actually verify signups and reset passwords: emailVerification (better-auth's whole config block — sendVerificationEmail, sendOnSignUp, autoSignInAfterVerification, expiresIn, lifecycle hooks), sendResetPassword, and resetPasswordTokenExpiresIn, all typed against better-auth's own BetterAuthOptions. Previously the factory offered no way to wire these, so any deployment that left requireEmailVerification on (the production default) shipped an un-verifiable signup dead end — better-auth sent nothing and sign-in threw EMAIL_NOT_VERIFIED.

    Defaults are unchanged when the new options are not provided.

    @wabbit/tome-chrome (patch): the mobile nav Sheet in Navbar5 and the shared MobileNavSheet (used by Navbar1/Navbar2) now renders a visually-hidden SheetTitle ("Navigation"; configurable via sheetTitle on MobileNavSheet) and opts out of aria-describedby, fixing Radix's "DialogContent requires a DialogTitle" accessibility warning and its missing-Description sibling.

    @wabbit/tome-blocks-extras (patch): renderers no longer paint lucide icon NAMES as literal text. FeatureHeroWithCards (PascalCase names like "Timer"), FeatureWithIconGrid, CardGrid, CardBlock, and LexicalBanner (kebab-case names like "zap", "calendar") now resolve authored icon strings through a shared name→component map (<Icon aria-hidden size="1em" />, slot font-size owns sizing).

    Unmapped name-shaped strings render nothing; emoji/free text still render as text. Adds lucide-react as peer >=0.460.0 + dev, matching the catalog-pack/chrome convention.

2 fixes (1.6.1 – 1.6.2)
  • 1.6.2
    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 (prior client-name and SC-universe strings, e.g. "LOG-2954-0847", "Stanton // Crusader Orbit", "UEES STALWART" → 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-lore/SC references; config examples de-lored. Pack positioning (SC-tier bundling) 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 the client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.
  • 1.6.1
    Auth stack unpinned to current: the April workspace override (better-auth 1.4.18 / adapter 0.3.10, added when better-auth 1.6.2 dropped the apiKey plugin export that payload-better-auth 0.3.15 still imported) is removed — the factory dropped the apiKey plugin long ago and the adapter ecosystem resolved the breakage by moving it to @better-auth/api-key.

    Core now builds and tests (203/203) against better-auth 1.6.26 and @delmaredigital/payload-better-auth 0.10; published peer ranges are unchanged.

1.5.xMinor

  • link() / linkGroup() accept a routes option — a third link type for pages that live in the app's route tree rather than a collection.

    A code-owned page has no document for the internal-link relationship to point at, so the only way an editor could reach /support or /docs/get-started was to type the path into the external-URL box — which mislabels the data and leaves nothing for a consumer's link resolver to key on when choosing between a client-side route transition and a hard navigation.

    The new type is backed by a select, not a text field: the destination list is closed, so a broken internal link cannot be authored. Consumers generate the list from their own route tree.

    Stored shape is { type: 'route', route: '/support' }.

    Independent of relationTo — a site may offer collections, routes, or both:

    ConfigurationEmitted radio
    collections onlyreference, custom (unchanged)
    collections + routesreference, route, custom
    routes onlyroute, custom (new)
    neitherexternal-URL-only (unchanged)

    Route sits directly after the collection option so the two same-site destinations read as a pair; legacy type options stay last. naming gains typeValues.route, typeLabels.route and routeFieldName, matching the existing reference/url escape hatches.

    Fully additive. With routes omitted or empty the emitted field is identical to before — asserted directly by test.

1.4.xMinor

  • Permissions convergence: capabilities (can/canAsync) are THE runtime-gate API — six flat/hierarchy-unaware checkers (checkRole, rbac's checkPermission/checkRoleAsync/checkPermissionAsync/checkAnyPermission/checkAllPermissions) are @deprecated (sunset core 2.0) with a decision tree in ARCHITECTURE.md; vendorScoped/orgScoped admin bypass is now role-OR-capability (vendor:manage/org:manage, configurable) — additive and default-safe, with one deliberate widening: super-admin passes the bypass via the capability engine's implicit grant even under a narrowed custom adminRoles (20-test truth table ships with it).

    LMS v1 sunset: the entire core/lms surface (37 exports) carries dated @deprecated tags naming each v2 replacement — including two honest no-replacement-yet blockers (Module's three-level shape; the typed lesson Block schemas pending LMS sub-spec 2) — and the new assert:no-core-lms script is the removal gate (report-only until 2.0; --strict flips it).

  • New timingSafeEqual utility (exported at ./utilities/timingSafeEqual): constant-time string comparison that hashes both operands to fixed-length SHA-256 digests before node:crypto.timingSafeEqual, so neither content nor length differences leak timing.

    Also anchors productTypeHookRegistry on globalThis (Symbol.for) so product-type hook registrations survive Next.js' split RSC/SSR/client module graphs — same fix, same rationale as blocks-core's render registry.

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

  • DRY adoption sweep (the audit's "adoption, not extraction" rule): crm/deals capability presets delegate to core's sessionHasCapabilityOrLegacyAdmin; new core buildOwnershipWhere/ownershipOrBypass (via ./access) adopted by core's vendorScoped, catalog's vendor-scoping, and org's ownOrScoped (public APIs unchanged); slugField() adopted at 7 sites where semantics matched exactly (core lms collections + createMemberCollection — replacing a third independent slugify), with ~25 sites honestly skipped for named semantic divergences (auto-regenerate-on-clear vs allow-empty, collection-level hook pattern) now listed as core-enhancement candidates; new formatDisplayDate in blocks-core utilities (UTC-pinned, hydration-safe) adopted at 5 verified-identical sites; lms-ui consolidates its two certificate date formatters locally; useMediaQuery/useIsMobile published from tome-ui and adopted by AppShell + admin's SidebarProvider; gamification's awardPoints now uses the authoritative getPointsBalance (fixes a divergent 1000-row scan cap vs the correct 10000).

1.3.x

2 fixes (1.3.3 – 1.3.4)
  • 1.3.4
    Fix a duplicate-key race in the onInit seeders (initializeRoles in auth/initRoles.ts, seedLegalPages in gdpr/seed/legalPages.ts) on a fresh/empty database under concurrency — e.g. a Next.js build's "collecting page data" phase, which spawns many parallel worker processes each triggering Payload init against the same DB.

    Root cause: both seeders do a non-atomic check-then-create (find → create) per document. Two workers can both see a document missing and both call create; the loser's duplicate-key rejection wasn't handled correctly.

    initializeRoles's catch matched on raw driver text ('duplicate' / 'E11000'), but Payload's DB adapters (mongo, postgres, sqlite) all normalize native unique-constraint violations into a payload ValidationError ({ data: { errors: [{ path, message }] } }) before the error reaches consumer code — the raw Mongo E11000 string never arrives, so the loser's error was rethrown, crashing that worker's onInit. seedLegalPages had the opposite defect: it blanket-caught all errors and continued, so a lost race (or any real failure) silently skipped seeding that page's remaining locales.

    Fix: detect the conflict via Payload's canonical, adapter-agnostic ValidationError shape (instanceof ValidationError + data.errors[].path matching the unique field the seeder keys on) instead of grepping driver-specific error text. On a detected conflict, re-fetch by the key to confirm the document now exists before treating it as success — if the re-fetch comes back empty, the conflict signal was a false positive and the original error is rethrown rather than silently swallowed.

    • initializeRoles: a confirmed race-loss logs the existing ○ Role already exists line and continues. Sequential/single-worker behavior (log lines, created roles, return type) is unchanged.
    • seedLegalPages: a confirmed race-loss now continues the per-locale loop against the winner's document (winner and loser write identical data, so the overlap is idempotent) — a lost race can no longer leave a page missing its non-primary locales. Behavior change: non-unique create failures (and locale-update failures) are now rethrown instead of being logged and swallowed — a broken seed now fails loudly instead of half-seeding.

    Uniqueness ground truth: name and slug on the roles collection (auth/collections/Roles.ts) both carry unique: true, so a real DB-level constraint exists on every supported adapter — the roles failure mode was a crash, not silent duplicate rows; no schema change needed. For seedLegalPages the target Pages collection is consumer-owned: when the consumer's slug field is unique the race surfaces as the handled ValidationError; when it isn't, the race silently duplicates pages instead and no seeder-side catch can fire (documented on the helper).

    The same fix is applied to @wabbit/tome-admin's seedTomeAdminLayouts in its own changeset. An atomic payload.db.upsert() was considered and rejected for all of these: it bypasses the collection's hooks/access pipeline that payload.create() runs, which would change sequential-case behavior.

  • 1.3.3

    Admin label polish + formatted commerce money columns: explicit labels for CRM collections ("CRM Accounts…"), Admin/Learner UI Preferences, and better-auth generated collections ("Auth Accounts", "Two-Factor Credentials", OAuth/JWKS casing) via the plugin's customizeCollection hook; nav SYSTEM_LABEL_OVERRIDES map (payload-kv → "Payload KV") applied at resolver + pinned-section label sites; Orders.total / Payments.amount / Prices.amount virtual afterRead fields format integer cents against the row currency ("4900" → "$49.00") in list views with no client components (zero generate:importmap coupling).

1.2.xMinor

  • auth: converge the platform permission engine.

    Adds the ./auth/permissions sub-module — super-permission hierarchy (MANAGE_X implies EDIT_X/DELETE_X), per-member permission overrides with a constrained (fail-closed) overridable allowlist, sync/async/batched effective-permission checkers, fail-secure resolution on unpopulated roles, a request-scoped authority cache, and a registration seam (registerSuperPermissions/registerOverridablePermissions) for layer-specific permission key-sets. Fully additive — the existing Roles collection, PERMISSIONS catalog, rbac checkers, and capabilities engine are unchanged.

    This is now the single platform permission engine that tome-org and tome-accounts build on.

2 fixes (1.2.1)
  • 1.2.1
    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.

  • 1.2.1
    Fix assignment-uploads upload collection rejecting every file.

    It set mimeTypes: ['*/*'], but Payload's validateMimeType strips only the first * ('*/*' → '/*'), so the wildcard matched no detected MIME type and the upload guard blocked all student file submissions. Removed the broken config — omitting mimeTypes is the correct "accept any file" setting, and Payload still blocks dangerous executable types via its built-in checkFileRestrictions allowlist.

1.1.xMinor

  • Consolidation pass (2026-06-10 audit dialect-drift findings) — the platform stops forking its own conventions.

    tome-core (minor — new public APIs):

    • ./auth/repScoping — buildRepWhereClause({ adminCapability, repField }) + buildCapabilityScopedRead({ readCapability, adminCapability, repField }) + sessionHasCapabilityOrLegacyAdmin + DENY_ALL_WHERE. The canonical "rows I own" access primitive, promoted from crm/deals' ~90%-identical copies (266 LOC → one parameterized implementation).
    • ./utilities/normalize — normalizeEmail (trim + lowercase). Email is the cross-layer join key; one normalizer, everywhere.
    • ./fields/slug — formatSlug upgraded to the canonical algorithm (promoted from catalog's strictly-more-robust slugify: collapses whitespace/hyphen runs, trims edge hyphens); new buildAutoSlugHook(sourceField, slugField) collection-level variant. Stored slugs untouched; only future generations on irregular-whitespace inputs differ.

    catalog / org / crm / deals (patch): local copies replaced with delegations to the core primitives. Public names and signatures unchanged (slugify, autoSlugHook, buildNormalizeEmailHook, normalizeDealEmail, repWhereClause, accountRepWhereClause, dealsRepWhereClause, dealsRepOrAdminWhereClause).

    Notably, org's auto-slug header had _claimed_ to wrap core's slugifier while carrying a divergent local copy — now it actually does.

  • Removed two phantom export subpaths: ./lms/components/quiz-renderer and ./lms/components/assignment-renderer.

    Their targets (dist/lms/components/QuizRenderer.* / AssignmentRenderer.*) have never existed — src/lms/components/ is absent from the package, the files are missing from every published tarball (verified against the registry), and no consumer imports the subpaths (verified across all four consumer repos). Leftover keys from before the LMS renderers moved to @wabbit/tome-lms.

    Caught by the new assert-exports-map + smoke-registry-contract checks on their first run.

1.0.xMajor

  • Graduate `@wabbit/tome-core` to 1.x — version-policy change, no API change.

    Diagnosed root cause of the 2026-04-27 pnpm changeset version cascade: while tome-core sits at 0.x, ^0.4.0 peer ranges (resolved from workspace:^ at publish) do NOT satisfy 0.5.0 per semver-zero rules, so @changesets/assemble-release-plan correctly force-major-bumps every peer-dependent on every minor release. Only fix is moving tome-core out of 0.x.

    After this release, ^1.x.0 peer ranges accept future minor bumps cleanly and the cascade disappears.

    Existing published peer-dependents (@wabbit/tome-admin@0.4.1, @wabbit/tome-economy@0.2.0, @wabbit/tome-catalog@1.1.0, @wabbit/tome-blocks-core@0.3.0) ship with peerDeps frozen at the 0.5.x range and will produce installation peer-dep warnings against tome-core@1.0.0 until each republishes — warnings only, runtime works. They re-resolve their peer ranges to ^1.0.0 on their next publish naturally.

    Bumped manually rather than via pnpm changeset version to avoid retriggering the same cascade in the changesets run that documents this change. See memory feedback_changesets_0x_major_bump_bug.md for the diagnosis and reproduction.

4 fixes (1.0.1 – 1.0.12)
  • 1.0.12
    Three additive packaging fixes surfaced by a registry consumer's migration off path-aliasing (path-aliasing was masking these — the actual package contracts didn't cover them).
    • @wabbit/tome-blocks-marketing-starter: add ./blocks/* subpath exports for the 8 block directories (banner, cta, faq, feature-hero, high-impact-hero, logo-slider, pricing, testimonial). Source already shipped these as directories with index.ts; the exports map only declared . and ./render, so any consumer of a specific block from the registry got a module-not-found error. Path-aliasing bypassed the exports map, hiding the gap.
    • @wabbit/tome-core: add ./auth/collections/Roles (capital R) alongside the existing lowercase ./auth/collections/roles. Both resolve to the same file (./dist/auth/collections/Roles.{js,cjs,d.ts}). The source file is Roles.ts; the exports map declared only lowercase, so consumers using the file's actual case (which is what TS path-aliasing produced when reading the source directly) couldn't import via the package's public API.
    • @wabbit/tome-ui: add ./tokens.css alongside the existing ./tokens (both point at ./dist/tokens.css). Lets consumers write import '@wabbit/tome-ui/tokens.css' to match the CSS-file naming convention as well as the existing import '@wabbit/tome-ui/tokens'.

    All three additions are purely additive — no existing exports removed or changed, so existing consumers stay compatible.

  • 1.0.11
    Align the BetterAuth dependency contract with what the auth layer's source already requires (post the 1.5/1.6 apiKey rename).

    betterAuthFactory.ts imports twoFactor/customSession/organization from better-auth/plugins + passkey from @better-auth/passkey and no longer uses apiKey (extracted to @better-auth/api-key in better-auth 1.6 / dropped from payload-better-auth 0.7). But the package's peerDependencies floors were still better-auth >=1.0.0 / @delmaredigital/payload-better-auth >=0.3.0, so a consumer on a stale version installed cleanly and only failed at runtime with a cryptic does not provide an export named 'apiKey'.

    • peerDependencies floors raised: better-auth >=1.6.0, @better-auth/passkey >=1.6.0, @delmaredigital/payload-better-auth >=0.7.0 — drift now fails loud at install, not at runtime.
    • devDependencies bumped to match (better-auth ^1.6.11, @better-auth/passkey ^1.6.11, @delmaredigital/payload-better-auth ^0.7.3) so the package's own build/tests exercise the real target versions.

    No source change — type-only / contract-only.

  • 1.0.11
    safeRevalidateTag / safeRevalidateTags (@wabbit/tome-core/data/cacheTags) now lazily load next/cache via dynamic import() instead of require().

    tome-core is "type": "module" and ships a dual tsup build with bundle: false. A bare require('next/cache') in source was preserved verbatim in the emitted ESM dist/data/cacheTags.js, where require is undefined — true-ESM consumers hit require is not defined at first revalidation (this broke wabbit-site-core's admin when source-linked).

    Dynamic import() is preserved verbatim by tsup in both the .js and .cjs outputs and is natively supported by Node under CommonJS, so it is the module-system-agnostic idiom — the same pattern @wabbit/tome-core/infra/posthog already uses to lazily pull an optional peer dep without bundler contamination.

    Behavior is otherwise identical: out-of-request-scope revalidation is still swallowed via IGNORABLE_PATTERNS, and a missing next/cache (standalone scripts, non-Next Payload hook cascades) now flows through the same ignorable path instead of throwing.

    Signature ripple: both functions return Promise<void> instead of void (dynamic import() is async). Every known call site invokes them fire-and-forget inside Payload afterChange hooks / server actions and discards the return value, so this is non-breaking in practice.

    Consumers that want to observe revalidation completion may now await them.

  • 1.0.1
    Security: multi-tenant access hardening (CRITICAL). Closes 2 cross-tenant write bugs and 5 hardening findings from the 2026-04-27 /autoresearch:security audit deferred at the 1.0.0 cut.

    No API changes.

    `packages/core/src/access/orgScoped.ts`:

    • Added create: access guard requiring authenticated user with current activeOrganizationId AND non-null orgRole (defense in depth alongside the hook fix).
    • beforeChange create hook now force-overwrites orgField to user.activeOrganizationId for non-admins regardless of submitted data. Previously the hook only assigned when the field was empty (if (!data[orgField])), letting an attacker plant rows in foreign orgs by submitting data.organization = '<foreign-org-id>' (finding 8.2).
    • Read/update/delete access now re-verifies current org membership via u.orgRole !== null, not just activeOrganizationId presence (finding 8.1). Stale active-org pointers (e.g. user removed from org since last login) no longer grant access.

    `packages/core/src/access/vendorScoped.ts`:

    • Added read: access guard mirroring update/delete — read was previously fully unrestricted across vendors despite the wrapper's "restricts CRUD" comment (finding 9.1).
    • Added create: access guard requiring authenticated user.
    • beforeChange create hook now force-overwrites vendorField to user.id for non-admins regardless of submitted data (finding 9.2).

    `packages/core/src/auth/jobs/cleanupExpiredSessions.ts` + `cleanupUnverifiedAccounts.ts`:

    • Replaced authHeader !== \Bearer ${cronSecret}\`plain-string compare withcrypto.timingSafeEqualand added apayload.logger.warnon auth failure (finding 5.1). Plain===` short-circuits on first byte mismatch and leaks prefix length under sufficient signal-to-noise ratio.

    `packages/core/src/auth/betterAuthFactory.ts`:

    • localhost:3000 (http + https) origins are now gated behind process.env.NODE_ENV !== 'production' in the BetterAuth trustedOrigins array (finding 1.1). Pass explicit prod origins via opts.trustedOrigins.

    `packages/core/src/access/checkRole.ts`:

    • Added @deprecated JSDoc directing new code to @wabbit/tome-core/auth/rbac (finding 7.1). The function remains exported for backwards compatibility and is consumed internally by orgScoped/vendorScoped against the customSession-enriched flat role slug array — that consumption is intentional per finding 8.4 (a comment in each wrapper explains the rationale; full migration to async auth/rbac paths would impose a DB round-trip on every CRUD access check).

    No source changes outside packages/core/src/{access,auth}. Public API surface, exports map, and types unchanged.

0.2.xMinor

  • Initial publish to npm.wabbit.com — first registry release for the 5 non-block-pack packages.

    Companion to the existing sprint-3-blocks-split changeset (which handles the 11 linked block packages). Together these two changesets bring all 8 publish-pipeline-Phase-2-remediated packages to a coherent first-release cohort:

    • @wabbit/tome-core 0.1.0 → 0.2.0
    • @wabbit/tome-ui 0.2.0 → 0.3.0
    • @wabbit/tome-motion 0.1.0 → 0.2.0
    • @wabbit/tome-lms 0.1.0 → 0.2.0
    • @wabbit/tome-lms-ui 0.1.0 → 0.2.0
    • @wabbit/tome-blocks-core 0.1.0 → 0.2.0 (via sprint-3)
    • @wabbit/tome-blocks-extras 0.1.0 → 0.2.0 (via sprint-3)
    • @wabbit/tome-blocks-marketing-starter 0.1.0 → 0.2.0 (via sprint-3)

    All 8 packages ship with metadata, dist/ output, exports map verified by P6 scratch-consumer smoke (35/35 resolutions), 'use client' + 'server-only' directives preserved through tsup bundle:false. Verdaccio v0 live since 2026-04-18 at npm.wabbit.com.

Full changelog across all packages