Documentation menu
Documentation / Foundation / core
Core
npm install @wabbit/tome-coreFirst install from our registry?
Get a free registry token from your credentials page. Every install from our registry needs one, free packages included.
Add the registry and your token to the
.npmrcat the root of your project, with your token in place ofYOUR_TOKEN:@wabbit:registry=https://npm.wabbit.com/ //npm.wabbit.com/:_authToken=YOUR_TOKEN
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).
| Peer | Range | Required |
|---|---|---|
@better-auth/passkey | >=1.6.0 | no (optional) |
@delmaredigital/payload-better-auth | >=0.7.0 | no (optional) |
@payloadcms/plugin-form-builder | >=3.67.0 | no (optional) |
@payloadcms/plugin-nested-docs | >=3.67.0 | no (optional) |
@payloadcms/plugin-redirects | >=3.67.0 | no (optional) |
@payloadcms/plugin-search | >=3.67.0 | no (optional) |
@payloadcms/plugin-seo | >=3.67.0 | no (optional) |
@payloadcms/ui | >=3.67.0 | no (optional) |
@payloadcms/richtext-lexical | >=3.67.0 | yes |
better-auth | >=1.6.0 | no (optional) |
lucide-react | >=0.460.0 | no (optional) |
next | >=15.0.0 | yes |
payload | >=3.67.0 | yes |
posthog-js | >=1.0.0 | no (optional) |
react | >=19.0.0 | yes |
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) plusorgScoped/vendorScopedmulti-tenant wrappers with force-overwrite hooks - `/auth/rbac` — role + permission checks; use
hasAdminAccessAsyncinside 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 iscan/canAsyncfrom/auth/capabilities - `/auth/capabilities` — capability engine (
can/canAsync,defineCapabilities,grantCapabilities,seedTomeCoreCapabilities);/auth/capabilities/clientships the React hooks (useCapability,CapabilityGate) - `/auth/betterAuthFactory` —
createBetterAuth()plugin pair including the customSession enricher that populatesuser.role[]/activeOrganizationId/orgRole(required byorgScoped) - `/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, explicitretryOnstatus policy — default429/>=500,Retry-Afterhonored — never throws on HTTP status),ExternalProfileAdapter<TProfile>,createProfileCacheCollection+createProfileCacheInvalidator, andrunProfileReconcilewith its two adapters,createProfileReconcileTask(Payload task queue) andcreateProfileReconcileEndpoint(CRON_SECRET-guarded endpoint) - `/field-reports` — subject watchlist + scored risk assessment + free-text field reports:
createFieldReportCollection,createSubjectAssessmentCollection(stored field names fully overridable viafieldNames— zero migration for a consumer with an existing table),createWatchlistGlobal(category → score-weight list; pass the SAMEcategoriesarray tocreateRiskScorer; stored field names fully overridable viafieldNames.{organizations,externalId,name,category,notes}— e.g. an existingorgsarray keyed bysid, not just the id —externalIdFieldkept as a deprecated alias),createRiskScorer(score/assessAndStore, case-insensitive upsert, injectable weights/bands/thresholds;watchlistFieldNamesreads the watchlist global through the SAME namescreateWatchlistGlobalwas given — pass one resolved config to both;flagNamesoverrides every emitted flag string, includingcategoryFlag: (categoryValue) => stringfor the<category>_orgmapping) +inputFromProfile(bridges anExternalProfileAdapterfetch into aRiskScoreInput),createReassessRiskTask/createReassessRiskEndpoint(cache-only rescore — re-reads the watchlist, never re-fetches externally; also takesflagNamesso a prior row's flags are recognized under the SAME scorer config), andregisterFieldReportsGdpr(reportsnull-refonreportedBy; assessmentsredacthandle+notes ONLY on a matching erasure identity, score row kept — see the module'sgdpr.tsheader for why that isretain-with-basis in effect, not literallymode: 'retain';reports/assessmentseach takefalseto 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; injectablestatuses/transitionTable/tiers, the full house seam set —extraFields→fieldOverrides(rename vianame) →omitFields→fieldOrder, per-verbaccessinjection merged over the defaults one key at a time,mergeHooksfor consumer hooks,labels, relation slugs,slug). The collection'sbeforeChangelogic is split into five named built-ins —statusForcing,authorStamping,contentEditGate,transitionGate,tierDerivation— each individually opt-out-able viabuiltInHooks. Read access is built fromresolveViewerTier(req)plus an optionalgrantsResolver(req, subjectId), and fails CLOSED on a viewer tier outsidetiers.effectiveReportTier(tiers, subjectTier, reportVisibility, fallback?)andstricterTiership 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 realWorkflowTransitionTableassigns with no cast and that package's ownresolveTransitioncan be injected viatransitionResolver; 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 onreq.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).
| Subpath | Purpose |
|---|---|
/auth | Aggregate server entry — everything below except the two client bundles |
/auth/betterAuthFactory | createBetterAuth(options) → Payload plugins (BetterAuth + the session enricher populating user.role[] / activeOrganizationId / orgRole); createBetterAuthOptions, CreateBetterAuthOptions, OAuthProviderConfig |
/auth/capabilities | Capability 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/client | React side of the capability engine — useCapability, useCapabilityAll, useCapabilityAny, CapabilityGate, useTomeSession |
/auth/rbac | Flat 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/client | Pre-configured BetterAuth client bundle for the browser (createPayloadAuthClient) |
/auth/session-keep-alive | Client 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/repScoping | The canonical "rows I own" Where-clause builders for rep-scoped access — admin-bypass, legacy-role fallback, and deny-all semantics in one place |
/auth/constants | Single source of truth for RBAC constants — role slugs, access types, permission keys |
/auth/guards | Async, 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/initRoles | Idempotent default-role seeding utility, run during Payload onInit |
/auth/jobs/cleanup-sessions | Removes expired BetterAuth sessions + stale verification tokens; CRON_SECRET-protected |
/auth/jobs/cleanup-unverified | Removes user accounts created but never email-verified, past a retention window; CRON_SECRET-protected |
/auth/permissions | The 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:
| Subpath | Purpose | |
|---|---|---|
/admin-nav/self-register | tome-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 | |
/authority | Phase A Contract 1: the authority-resolution seam — layers call getAuthority(req), consumers supply the traversal via registerAuthorityResolver | |
/notifications | Phase A Contract 3: task-notification emission seam — interface only, no-op default, consumer decides where notifications are stored | |
/jobs | Scheduled-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 |
/license | licenseHeartbeat() — 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/selfOrAdmin | Access factory allowing admins or the user themselves (supports User/Profile-split sites via a custom resolveUserId) | |
/access/scoped | Phase 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/secureAccessDefaults | Deny-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/orgScoped | Wraps 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/vendorScoped | Wraps 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/health | healthRoute() — a Next.js route-handler factory returning { status, checks: { db, redis? }, timestamp } for an app/api/health/route.ts | |
/infra/env-scaffold | SHARED_ENV_VARS + validateEnv() — the shared env-var spec (required vs optional) and the startup validator to call from payload.config.ts/instrumentation.ts | |
/registry/productTypeHookRegistry | Closed-shape hook interface for product-type behavior dispatch across layers; consumers register opt-in hook implementations and degrade gracefully when one is absent | |
/registry/adminNav | Admin sidebar nav manifest contract — the DOMAINS frozen array and its isAdminNavDomain type-guard, used by layerRegistry.registerLayer for domain validation | |
/utilities/normalize | Canonical value normalizers every layer must agree on (e.g. email — the cross-layer join key for CRM/deals/intake/marketing) | |
/utilities/timingSafeEqual | Constant-time string comparison for shared-secret / webhook-token verification, avoiding the timing side-channel a naive === or hand-rolled loop leaks | |
/utilities/typedSlug | Coerces a runtime string slug to Payload's generated CollectionSlug literal-union type, for factory collections that take slugs via config | |
/fields/slug-ui | SlugComponent — the admin UI field component paired with /fields/slug's factory | |
/config/assertRelationTargets | Phase A Contract 4: dangling-relation validator — catches a consumer override that renames a slug in only one of two related collections | |
/fields/address | postalAddressGroup / postalAddressFields (two vocabularies: legacy-crm, postal), mapAddress — one address shape for crm/deals/fulfillment | |
/fields/fieldShape | applyFieldShape, applyFieldOverrides, applyOmitFields, applyFieldOrder, insertFieldsAfter — the consumer field-shape pipeline every layer factory uses | |
/fields/selectOptions | resolveSelectOptions with the canonical `{ mode: 'extend' \ | 'replace', options } override (dedupes by value); the older { extend?, replace? } shape in /identity` is deprecated |
/registry/createKeyedRegistry | createKeyedRegistry(symbolKey, opts) — globalThis/Symbol.for-anchored keyed registry with duplicate policies; the one way to build a cross-module registry | |
/registry/theme | Type-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/themeRegistry | registerTheme, 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-config | createThemeConfig({ 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/batch | batchWrite (bounded concurrency, per-item error isolation) and batchWriteInChunks (paced) — replaces serial await payload.* loops | |
/utilities/layerFactoryConfig | LayerFactoryConfig — the base config every create*Layer factory extends — and applyLayerFactoryConfig | |
/utilities/telHref | toTelHref / 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/readingTime | READING_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/formPrefill | applyFormPrefill(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/relationId | relationId / relationIdOrThrow — populated-doc-or-id → string id, one implementation | |
/utilities/requestMemo | requestMemo(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/readThroughCache | readThroughCache(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/safeHref | safeHref(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/optionalIcon | attachOptionalIcon(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'.
| Subpath | Purpose |
|---|---|
/gdpr | Aggregate entry for the consent utilities, the registry, erasure and retention |
/gdpr/cookieConsent | First-party consent cookie (cookie_consent, 365 days, readable server-side): getConsent, setConsent, hasConsent, revokeConsent, acceptAllConsent, OPEN_CONSENT_EVENT |
/gdpr/registry | gdprRegistry — 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/exportUserData | exportUserData({ payload, user }) — Article 20 export of every registered collection as JSON |
/gdpr/actions/deleteAccount | deleteAccount({ payload, user, confirmationText }) — Article 17 account deletion; a thin wrapper over runErasure |
/gdpr/runErasure | runErasure — the erasure cascade over gdprRegistry; every step reports its own errors and the run continues |
/gdpr/erasureState | Pure classifiers for a pending erasure's lifecycle (classifyErasureState, isErasureExecutable, isErasureOverdue, resolveErasureTrail) |
/gdpr/retention | Data-retention engine — defineRetentionPolicies, runRetention (lock-guarded), createInMemoryRetentionLock, and the createRetentionTask / createRetentionEndpoint adapters |
/gdpr/compliance | Opt-in compliance records — Article 30 processing register (createProcessingRegisterCollection), consent ledger (createConsentLedgerCollection, recordConsent, requiresReconsent). Nothing runs unless you import it |
/gdpr/compliance/breach | Article 33/34 breach-notification helpers (assessBreachSeverity, generateAuthorityNotification, generateSubjectNotification, createBreachRecord). Untested against a real breach: review its output before relying on it |
/gdpr/components/CookieConsent | Consent banner ('use client'), themed through --tome-* tokens |
/gdpr/components/AnalyticsLoader | Renders its analytics children only while analytics consent is granted ('use client') |
/gdpr/components/CookieSettingsButton | Re-opens the consent banner, for a footer link ('use client') |
/gdpr/seed/legalPages | seedLegalPages — idempotent placeholder Impressum, Privacy Policy and Terms pages (English and German) |
/gdpr/seed/lexicalHelpers | Lexical 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
useroptional for legacy/imported rows.unique: truestays on regardless; Payload's unique index is sparse for optional fields, so multiplenullrows don't collide. PassuserValidateto conditionally require it (e.g. "optional only whilestanding === 'legacy'"). - `identity: { includeDisplayName, displayNameRequired, titleField, slugSource, slugField }` —
includeDisplayName: falseOMITSdisplayNameentirely while KEEPING the factory's slug handling:slugSourcethen defaults totitleFieldinstead of'displayName', and the generated (or your own, viaslugField)slugfield is still emitted.titleFieldis REQUIRED whenincludeDisplayNameisfalse— there's nodisplayNameleft foradmin.useAsTitle/defaultColumnsto fall back to; the factory throwsMemberIdentityConfigErrorat collection-definition time otherwise. This closes the gapskipDefaultIdentitycouldn't: that flag dropsdisplayNameAND the slug pair TOGETHER, with no way to keep a factory-composed slug sourced from your own identifier field.displayNameRequired(ignored whenincludeDisplayNameisfalse) makesdisplayNameoptional while keeping it present.slugSource/slugFieldbehave as before: re-source the generated slug, or supply your ownslugField(...)pair outright (slugField: [...], orslugField: falseto 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).includeClassificationemits a'public' | 'classified'select (DEFAULT_MEMBER_CLASSIFICATION_OPTIONS);includePortraitan upload field distinct from the always-onavatar;includeReportsToa self-relationship for chain-of-command data;includeTimezonea plaintextfield (name configurable viatimezoneField, default'timezone') rather than a curated select — the factory doesn't own a timezone-options list;includeRegiona six-value coarse-geography select (DEFAULT_MEMBER_REGION_OPTIONS);includeJoinDateadatefield (name configurable viajoinDateField, 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*Fieldoptions where offered, or still fully override viaextraFields. - `standingOptions: true | { extend, replace }` + `standingDefault` — opt into a
standingselect field. Omitted by default;DEFAULT_MEMBER_STANDING_OPTIONSis 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
readstrategy without giving up the create/update/delete defaults (which a fullaccessoverride would do). Accepts any PayloadAccessfunction, including one that returns aWhereinstead 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/mergeHooksappend utility. A caller'safterChange(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 bySymbol.for(...)), not a module-localMap, 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/catchpattern is obsolete, and it breaks under the Payload CLI's ESM loader, whererequireis undefined and registration silently skips. Keeptry/catcharound theregisterLayer()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 onhasLayer('@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", andauthenticated/authenticatedOrPublishedmean 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 contentstaffOrPublished(staff)orisAdminOrPublished). A writtenauthenticatedOrPublishedis a rule, so the plugin never replaces it: swap it yourself. Runmode: 'report'first on an existing site. WithbasePlugins, also passformBuilder.staffso form submissions getformSubmissionAccess(staff). If an external scheduler calls/api/payload-jobs/run, setjobs.access.run: jobsRunAccess({ staff })(staff orBearer $CRON_SECRET, fails closed when the secret is unset). Composing layers without the plugin? Passstaffto eachcreateXLayer({ staff }): it fills the operations that layer left without a rule. - Pass
*Slugconfig 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/vendorScopedpresupposecreateBetterAuth()'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 rootSee 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, flatcheckPermission, capabilities, hierarchicaleffectivePermissions) coexisted with no stated order; capabilities now sit on top ofeffectivePermissionsandcheckRole/checkPermissioncarry@deprecatedtags, 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-lmsdomain 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-admincould 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 devwhen 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-innav-doctorcheck instead of aNODE_ENVbranch. - 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 < metazone 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.createResilientFetcheralso 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 explicitretryOnpolicy defaulting tores.status === 429 || res.status >= 500(a429/5xxis 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). A429's numericRetry-Afterheader is honored, capped attimeoutMsso 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-orgcategoryfield does not store a score weight — the weight lives on thecategoriesarray passed to BOTHcreateWatchlistGlobalandcreateRiskScorer; a caller who edits weights in only one place desyncs them.assessAndStore/runReassessRiskalso record more than a hostile-orgs list would, deliberately: EVERY watchlist match is recorded (matchedWatchlist/storedaffiliations), includingweight: 0categories — score/flags still skip anything withweight <= 0.createReassessRiskTaskis a cache-only rescore: it rescores from what is already stored on eachsubject-assessmentsrow (no member-scoped profile cache exists in this module) —assessAndStorenow persistsinput.createdAtontofieldNames.subjectCreatedAt(nullable, never overwritten by an omitted/undefinedcreatedAton a later scan) precisely sonew_accountCAN be recomputed on a cache-only pass:runReassessRiskreads it back and re-evaluates the window against the run's own injectablenow, so a subject correctly clearsnew_accountonce enough time has passed even without a fresh fetch. A row whosesubjectCreatedAtisnull(predates this field, or its source never supplied one) simply never has the flag set or cleared by fiat.registerFieldReportsGdprregisterssubject-assessmentsasmode: 'redact', notmode: 'retain', to express "retain with basis, redact on request" as one registration:retainin this registry's vocabulary means the row is NEVER touched, which cannot express a conditional;redactdegrades to a no-op (behaviorally identical toretain) whenever the erasure identity's handle doesn't match, and only acts on the one caseretaincannot cover. - `/field-reports` lets an existing schema adopt it without migration, all additive, zero default change: (1)
createWatchlistGlobalhardcoded theorganizationsarray field name with onlyexternalIdFieldoverridable —fieldNames.{organizations,externalId,name,category,notes}now covers every stored field, andcreateRiskScorer's newwatchlistFieldNamesoption reads the watchlist through the SAME names (previously conflated with the unrelatedfieldNames.affiliationExternalIdon the assessment side — a coincidence of matching defaults, not a real coupling); (2)createRiskScorerhardcoded themulti_affiliationflag value where an existing scorer may emitmulti_org—flagNamesnow overrides every emitted flag string (multiAffiliation/hiddenProfile/redactedProfile/newAccount/manualFlag/categoryFlag), threaded throughscore,assessAndStore, ANDrunReassessRisk(which must recognize a PRIOR row's flags under the same names via the newresolveFlagNamesexport);scorer.test.tscovers 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 laterregisterCollectioncall silently overwrites the earlier one by slug" —reports/assessmentseach now takefalse(skip registering that collection) or{ phase?, order? }(reposition it;assessments.phasestill 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
applyFormPrefillnow also reads the query string of a clicked link.A link that points at the page's form and carries
?field=valuepairs in itshrefpreselects those fields, with the same allowlist as the page URL, so a plainhrefworks with no extra attribute. A link that also hasdata-form-prefillkeeps 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/linkcancels 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 forlinks: 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(CANCELEDon the status board) next to the existing value. Reads, queries, the closed-event lock, event board state and selection, the calendar export (STATUS:CANCELLEDis unchanged, it is the iCalendar literal), the status board badge, theme selectors fordata-event-statusand the erasure trail treat the two spellings as one meaning.The event list board labels gain a
canceledkey that is read before the oldcancelledkey. 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 asession.deferSessionRefreshoption oncreateBetterAuth.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
expiresInafter 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-packso 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, andnullfor any other scheme, a protocol-relative//host, or whitespace and control characters.Also adds
@wabbit/tome-core/utilities/requestMemo(a per-requestMapkeyed on the stablereqobject) and@wabbit/tome-core/data/readThroughCache(a read through Next'sunstable_cachewhen it is there, directly when it is not), so layers share one implementation instead of copying one.
1.31.xMinor
createBetterAuthgainsenrichSessionRoles(defaulttrue), so a site can turn off the role lookup the factory adds to every session fetch.The factory's
customSessionplugin loads the user at depth 1 on everygetSessionand addsuser.role,orgRoleandactiveOrganizationId. Better Auth's admin login view needsuser.role, so the default stays on.A site that keeps Payload's own
/adminlogin and doesn't readsession.user.rolecan passenrichSessionRoles: falseand skip oneusersread per session fetch; if it uses the Better Auth login view, it must also passadmin.requiredRole: null.
1.30.xMinor
Add
staffOrPublished(staff)(exported from@wabbit/tome-core/authand@wabbit/tome-core/access): staff see drafts, everyone else, signed in or not, sees only published documents.It's the safe replacement for
authenticatedOrPublishedon sites with public signup, where that guard shows drafts to every account. Only an explicittruefromstaffopens drafts; aWhereis OR-ed with the published filter.The
authenticatedOrPublishedwarnings now point at it.
1 fix (1.30.1)
- 1.30.1
createBetterAuthno longer enables the adapter's first-user-admin hook, which never worked on Tome'srolesrelationship and logged an error on every first sign-up.The hook writes a
rolestring on the first sign-up. Tome users hold arolesrelationship 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: trueonly for a users collection that stores roles in a plainrolefield.With the hook off, the adapter logs a one-time reminder at boot that
access.createon users must reject non-admin callers; the starter template already does, and a sign-up that sendsrolesis stored with the default role.
1.29.xMinor
secureAccessDefaultsnow 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) thepayload-jobs-statsglobal 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
staffon them: folders through afolders.collectionOverridesentry that runs before yours (so your overrides still win), the others at init on the live config, before your ownonInit. Lock reads go to everyone allowed into the admin panel (the admin-user collection'saccess.admin), because Payload's admin reads locks as the signed-in user; lock writes go tostaff.excludetakes these slugs, report mode lists them, andinstallLateSystemAccessis 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 byapplyLayerFactoryConfig, so everycreateXLayerthat passes its config through gets it): each missingcreate/read/update/delete/readVersions, plusadmin/unlockon an auth collection, becomesstaff. Rules the layer wrote are untouched and per-collectionaccessoverrides 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
secureAccessDefaultsplugin. Omittingstaffchanges 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
jobsRunAccesshelper admits staff or the cron bearer token.Payload lets any signed-in account call
jobs.access.run,queueandcancelwhen they are unset, andrunguards the REST endpointsGET /api/payload-jobs/runand/handle-schedules, so any member could drain or trigger queued work. When the config has ajobsblock, each missing rule now becomesstaff(only an explicittrueadmits), reported asjobs: run, queue, cancelin the init summary.exclude: ['payload-jobs']opts out. The Local API (payload.jobs.run()from a cron route, a sidecar orautoRun) is unaffected.jobsRunAccess({ staff, secretEnv? })isjobs.access.runfor a site whose external scheduler calls the job endpoints. It compares theAuthorization: Bearer <CRON_SECRET>header in constant time and fails closed when the secret is unset (the Payload website template's version admitsBearer undefinedin that case).Behavior change for sites already using
secureAccessDefaults: after upgrading, non-staff accounts lose REST access to the job endpoints unlessjobs.accesssets a rule. No API, peer or required argument changed, so this ships as a minor.
1.26.xMinor
New
secureAccessDefaultsplugin (@wabbit/tome-core/access/secureAccessDefaults) gives every operation without an access rule to yourstaffcheck instead of "any signed-in user".Payload fills any access key you leave out with "any signed-in account". A missing
readVersionsdoes the same,unlockdefaults to any account in the admin-user collection, and an auth collection withoutaccess.adminlets 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
deleterule) 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-submissionsstill uses the form-builder plugin's defaultread. Runmode: 'report'first on an existing site to see the list without changing anything.onReporthands 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.isAdminOrPublishedin@wabbit/tome-core/auth/guards: the safe replacement forauthenticatedOrPublishedon content.basePlugins({ formBuilder: { staff } }): applies both presets to the form-builder plugin. Your ownaccesskeys still win.
The
authenticatedandauthenticatedOrPublisheddocs now say plainly that they admit any signed-in account.
1.25.xMinor
New
@wabbit/tome-core/admin-viewssubpath with presentational building blocks for Overview admin views, plus two optional product-type hooks.ProductTypeHooksgains optionalgetAccessSummary(returns aTomeAccessSummaryfor admin access tiles) andrevokeAccess(ends non-entitlement access after a refund; must be idempotent and never throw).@wabbit/tome-core/admin-viewsships server-safe primitives styled only with Payload admin CSS variables:OverviewShell,Timeline/TimelineItem/TimelineGroup,SummaryRail/RailCard/RailRow/MoneyRow,StatusPill,StageBar,ProbabilityBarandFilterChips.@payloadcms/uiis 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
signTokenandverifyTokento@wabbit/tome-core/verification: a generic expiring HMAC-SHA-256 signed token for signed links.signToken(payload, { secret, ttlMs | expiresAt, purpose? })returnsdata.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.purposeis 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'sissueToken/verifyTokennow delegate to it. Tokens it issued before still verify; a token is now expired at itsexpinstant rather than one millisecond after.
1.23.xMinor
@wabbit/tome-core/data/cacheTagsnow exportsloadUnstableCache()and theUnstableCachetype: a lazy loader for Next'sunstable_cachethat returnsnulloutside Next.Layers that cache public reads by tag share this one helper instead of each carrying a copy.
ThemePalettegains an optionaldefaultMode, and@wabbit/tome-core/utilities/readingTimeadds one shared reading-time rule.ThemePalette.defaultMode?: 'light' | 'dark'names the mode a palette opens in.themeConfigHtmlAttributes(doc, { themes })addsdata-theme="dark"for a palette that opens dark, the ThemeConfig palette field names those palettes in its help text, andregisterTheme()rejects adefaultModethe palette does not list inmodes. Palettes without it behave exactly as before.readingTimeexportsREADING_WPM(230),wordsInLexical(value)(counts real text nodes only, including rich text nested in block fields such as a margin note's body) andreadingMinutes(words, wpm?).@wabbit/tome-core/utilities/formPrefillis the framework-free link-prefill helper, moved here from the forms engine so a site that does not use@wabbit/tome-formscan use it:applyFormPrefill(root, { allow, note, noteElement, search, skip, links }),parseFormPrefill,FORM_PREFILL_DEFAULT_NOTEandFORM_PREFILL_MAX_LENGTH. It preselects allowlisted fields of any rendered form from?field=valueand from clicks on in-pagedata-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 tobodya 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 andpreventScrollstill holds.
1.22.xMinor
New
@wabbit/tome-core/utilities/telHrefsubpath: onetel:href builder with E.164 normalization, replacing four per-package copies that kept only the typed digits.toTelHref(raw, { defaultCallingCode? })returnstel:+<digits>andtoE164(raw, opts)returns+<digits>, bothnullfor fewer than three digits. A leading+is kept.Without one, a
defaultCallingCode('1','44') qualifies the number: NANP numbers of 11 digits starting with1just gain the+, any other national number loses one leading trunk0and gains+<code>. With neither,toTelHrefkeeps the digits as typed, so nothing changes until a site opts in.Trailing
x123/ext. 123becomes;ext=123; trailing,/;dial pauses pass through.telOptionsFromContext(context)readsdefaultCallingCodefrom the host render context a block receives asBlockRenderProps.context.
1 fix (1.22.1)
- 1.22.1
basePluginstypes itsseo.generateTitleandseo.generateURLfrom the SEO plugin's own config, so async generators and the plugin's full argument object type-check without a cast.
1.21.xMinor
CookieConsentgains an optionallayout?: '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-uitoken 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 toautoon 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 minusshowDelay, plusonAccept/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.ctsdeclarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license:/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */.The
.mapfiles and thesourceMappingURLcomments that pointed at them are gone, which roughly halves the size of each tarball.Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line.
A
'use client'directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged.No API change, no runtime behavior change, and nothing to do on upgrade.
In
@wabbit/tome-blocks-gallery, the source snapshotsextractGallerySourcewrites from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
1.20.xMinor
New
@wabbit/tome-core/registry/themeRegistry(theme discovery),/registry/theme(theThemeManifesttype) and/theme-config(a platform ThemeConfig global and injector)./registry/theme— type-onlyThemeManifest,ThemePalette,ThemeFonts,ThemeKind('theme' | 'treatment'): the one descriptor every theme package exports. Themes activate withdata-tome-theme="<name>", palettes withdata-tome-palette./registry/themeRegistry—registerTheme,getTheme,listThemes({ kind }),resolveThemeName(maps a legacydata-tome-packvalue to its theme),validateThemeManifest, on theglobalThis-anchoredcreateKeyedRegistry(duplicate policyreplace, 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 thethemeConfigPayload 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 classicregularColors/darkmodeColorsfield names, so existing documents need no migration. Color fields accept hex, rgb(a), hsl(a) andoklch().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, sotask.handler({ input, req })with a realPayloadRequestneeds no cast.The handler takes
input: unknownandreq: PayloadRequest, andretriesis Payload'snumber | RetryConfig.Previously the handler typed
reqas{ payload } & Record<string, unknown>, which a realPayloadRequestdoes not satisfy, andinputas a record, which Payload's per-taskunknowninput does not satisfy; callingtask.handler({ input, req })directly needed a cast.PayloadTaskLikeandPayloadTaskOptionschange 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
inputis forwarded to yourJobHandlerexactly as before; a non-objectinput(which Payload does not produce) now reaches the handler asundefined.Who could see a compile error: code that calls
task.handler(...)directly with a hand-builtreqthat is not typed as aPayloadRequest(typically a test stub — cast it toPayloadRequest), and code that passes aretriesvalue outside Payload'snumber | 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.
createMemberCollectionwires a new built-inbeforeValidatehook,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/optionalIconexportingattachOptionalIcon(entry, iconName).It fills an admin-nav manifest entry's
iconfromlucide-reactthrough a lazy import, so a layer that wants a specific sidebar icon still registers on an install withoutlucide-react; the sidebar then falls back to the nav domain's default icon.lucide-reactis declared as an optional peer.Layers that carried their own copy of this helper now import it from core.
initializeRolesno 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".
initializeRolesnow 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/relationIdgains 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 rawdoc.idor write back to a numeric column.relationIds(value)andrelationIdsRaw(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'sfield-reportslifecycle drops its privateidOfforrelationIdRaw.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, andresolveRoleSlugs(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'sautoSlugFromDisplayNamenow usesformatSlug, the stricter slugifier, instead of its privatetoSlug. 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.createMemberCollectionhas usedformatSlugsince 2026-07.License heartbeat: an
.npmrccredential 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 wheneverNPM_TOKENwas 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.buildHeartbeatPayloadaccepts an optionaldebugsink.
1.16.xMinor
Fix a silent Payload collection-slug collision between
createBetterAuth()andcreateAccountsLayer()that broke every sign-up on a consumer mounting both at their defaults.Root cause: better-auth's internal
accountmodel and@wabbit/tome-accounts' tenant Account collection both default to the Payload slugaccounts. The collections plugin that assembled them merged the two definitions instead of failing, producing a collection whose requiredname/slug/ownerMemberfields (from tome-accounts) were never supplied by better-auth's ownlinkAccount/createAccountcalls — everyPOST /api/auth/sign-up/emailreturned a 500 naming those fields, not the collision that caused them.The fix (`@wabbit/tome-core`):
registerLayer(utilities/layerRegistry) now checks a newly-registering layer'scollectionsagainst every already-registered layer'scollectionsand throws a newLayerCollectionSlugCollisionError— naming the slug, both claimant layers, and a remedy — the moment two layers claim the same slug, regardless of which two layers or which mount order. Exported alongside aisLayerCollectionSlugCollisionErrortype guard so a layer factory's own try/catch (most exist only to swallow the pre-existing "already registered" HMR throw) can re-throw a genuine collision instead of silently eating it.createBetterAuth()now registers the Payload collection slugs it mounts (@wabbit/tome-core-auth) with this guard, and its own try/catch re-throws a collision instead of swallowing it.- New opt-in
internalModelNames.accountoncreateBetterAuth()renames better-auth's internal account model (e.g.{ account: 'authAccount' }→ Payload slugauthAccounts) — the auth-side resolution when a collision fires. Default is unchanged (accounts), so upgrading never renames an existing consumer's table. A consumer that opts in AFTER going live must rename or migrate the existingaccountstable/collection first — see the option's JSDoc.
The fix (`@wabbit/tome-accounts`):
createAccountsLayer()'s existingregisterLayertry/catch now re-throws a genuineLayerCollectionSlugCollisionErrorinstead of swallowing it as a duplicate-registration no-op. The layer's existingslugs: { accounts: '<other-slug>' }override is the accounts-side resolution — pick ONE side, not both.Backward compatibility: a consumer mounting only one of the two layers is unaffected — both new tests and the existing suites confirm no throw and identical registered collections. Both defaults (
accountson each side) are unchanged; the guard only fires when two layers genuinely collide.
4 fixes (1.16.1 – 1.16.4)
- 1.16.4
bunnyCdnStorage()now passesBUNNY_REGION(or theenv.regionoverride) through asstorage.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 acceptednull, but the factory handed the LoginViewresolvedRequiredRole ?? undefined. The view declaresrequiredRole = 'admin'as a default parameter, soundefinedre-enabled the check with a slug Tome never issues.Worse, the view runs that check on the sign-in _response_ user, which has no
rolefield (customSession enriches only/get-session), so a consumer that opted out withnullstill saw "Access Denied" on every submit and had to navigate to/adminby 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.nullnow passes through asnull; the plugin'slogin.requiredRoletype declaresstring | string[] | null, and itscheckUserRolesreturns true for a falsy requirement. The server-sideUsers.access.admingate is unchanged and remains the real lock.The default (option unset) still resolves to
DEFAULT_ADMIN_ROLES. - 1.16.2
checkRoleAsync,checkRoleandhasRolenow accept readonly role lists and consumer-defined slugs without a cast.checkRoleAsyncandcheckRoletookRoleSlug[] | string[], a mutable array, andhasRoletook(RoleSlug | string)[]. A site that keeps oneas constlist for its admin gates, like tome-starter's and other consumer sites'ADMIN_PANEL_ROLE_SLUGS, cannot pass that list withoutas unknown as string[].Keeping that list single is the point:
Users.access.adminandcreateBetterAuth'srequiredRolehave to read the same one so the two gates never drift apart. 1.16.1 already widenedrequiredRolethe 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.
checkRoleAsyncandcheckRolestay@deprecatedin favor ofcanAsync/canfrom@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
RoleSlugunion, which cannot express a site's own admin-capable role. tome-starter's read-onlyviewerrole hit this. Its two admin gates,Users.access.adminand the BetterAuth LoginView'srequiredRole, both read oneas constlist,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
requiredRoleneededas unknown as RoleSlug[], which also turned off type checking on the line.requiredRoleis nowreadonly AdminRoleSlug[] | AdminRoleSlug | null, where the newly exportedAdminRoleSlugisRoleSlug | (string & {}). Built-in slugs still autocomplete, and non-string values are still rejected.The LoginView underneath already took plain strings, and
hasRoleinrbac.tsalready acceptedRoleSlug | 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
hasRolealready makes.
1.15.xMinor
New
@wabbit/tome-core/licensesubpath:licenseHeartbeat(), anonInit-compatible export (wire it alongsideinitRoles, 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 anunref()'d interval so it never blocks or holds open the process.licenseHashis the sha256 of the deploy credential the build installed with, resolved from the same sources npm itself would use (NPM_TOKENenv, then the project.npmrc's//npm.wabbit.com/:_authToken, then~/.npmrc) — the raw token is never sent, and the response is never read.familiesis the distinctwabbit.familyvalues of every installed@wabbit/tome-*package, read from each one's ownnode_modulesmanifest 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 onedebug-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 ofcreateFieldReportCollection, 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 vianame) →omitFields→fieldOrder, per-verbaccessinjection merged over the defaults one key at a time,mergeHooksfor consumer hooks,labels, relation slugs,slug.The five behaviors the donor collection ran as one 100-line
beforeChangeare split into named built-ins —statusForcing,authorStamping,contentEditGate,transitionGate,tierDerivation— each individually opt-out-able viabuiltInHooks, so a consumer can replace one without forking the collection.effectiveReportTier(tiers, subjectTier, reportVisibility, fallback?)andstricterTier(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 fromresolveViewerTier(req)with an optionalgrantsResolver(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 outsidetiers.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 realWorkflowTransitionTableassigns with no cast and that package's ownresolveTransitioncan be injected through thetransitionResolveroption. 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/refusedwith 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) andmarkStale(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 onreq.context(LOCATION_REPORT_ACTOR_CONTEXT_KEY,systemTransition) rather than reaching around them, and the bulk paths go throughbatchWrite+findPagedso 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, theAddressVocabularytype and the two field-name constants. The same six-field postal block was hand-rolled in FIVE places across three layers (@wabbit/tome-crmaccounts + contacts,@wabbit/tome-dealsbilling + shipping,@wabbit/tome-fulfillmentAddresses) in TWO INCOMPATIBLE VOCABULARIES:address1/address2/city/state/zip/countryandline1/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
ziptopostalCodein crm is a data migration, not a refactor. Every adopter passes thevocabularyit already stores.requiredmarks the four load-bearing lines only (neverline2, neverstate/region— requiring a state makes the schema US-shaped, which fulfillment's own comment argues against at length).validateCountrycarries fulfillment's ISO-3166 alpha-2 check and its uppercase-normalizingbeforeValidatehook across verbatim; normalizing rather than rejecting lowercase is deliberate, because the shipping rate table matches on this value and a rejected"us"teaches nothing.fieldOverridesmerges per-sub-field changes through the existing/fields/fieldShapeseam, which is how deals keeps its eight per-field labels and its'US'country default without a second copy of the block.postalAddressFieldsexists alongside the group because fulfillment stores the postal lines FLAT at collection top level, interleaved withrecipientNameandphone. 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 | nullandrelationIdOrThrow(value, label). The audit counted this read in core's deprecatedlmstree, 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 | nulland stringifies:String(id)for both string and number,null(neverundefined) for absent,nullfor an array (ahasManyvalue is the caller's loop, not a silent first-element read). Payload accepts either form in awhereclause, 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.mjsgains 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 itsfields: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 prependsnameand deals prependsname+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-ledgerkeeps its ownextractId, for the same architectural reason as itsmergeHooks(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.mjsalready demonstrated for seven weeks.- `@wabbit/tome-core/fields/address` (new subpath) —
Add
batchWrite/batchWriteInChunks— the platform's bounded-concurrency batch writer — at the new@wabbit/tome-core/utilities/batchsubpath.The 2026-09-01 sale-readiness audit found 15 serial-await-in-loop sites across
@wabbit/tome-org,@wabbit/tome-lmsand@wabbit/tome-sc: PayloadafterChange/afterDeletehooks and reconciler jobs fanning out N independent writes oneawaitat 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-108batched while:88-99did not;gdpr.tsused 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.resultsis index-aligned withitemsregardless of completion order, and a per-item throw is isolated intoerrorsinstead of poisoning its siblings (the failure mode that makes a naivePromise.allunsafe 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 theWRITE_CHUNK/WRITE_PAUSE_MSpacing invented in@wabbit/tome-lms'sreconcileCourseCompletionAwards: process a chunk, then pause, so a write's ownafterChangefan-out has room to drain before the next chunk lands.shouldStopcarries 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'smaxCapturesPerRuncounter) stays sequential — passconcurrency: 1when you want the pacing and error isolation but must keep strict ordering.Shipped with its forcing function: a
no-restricted-syntaxrule ineslint.config.mjswarns onawait payload.*as a direct statement inside afor…of/forbody and points here.createKeyedRegistryin 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
globalThisviaSymbol.for, six held a module-localMap(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-localMapexists twice and a registration made through one is invisible through the other.Nothing throws; the handler just never fires.
layerRegistryshipped that bug in 2026-05 and moved ontoglobalThisin 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 atglobalThis[Symbol.for(symbolKey)].onDuplicateis'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-localMapfails both.Migrated in core:
gdpr/registry.ts. This one had BOTH halves of the defect — a module-localMapinsideGdprRegistryImpl, and absence from core's ownsideEffectsarray — while four layers (fulfillment, org, sc, plus consumer sites) register into it by import side effect.Split state meant
runErasure/exportUserDatareporting zero rows for collections registered into the other copy; a missingsideEffectsentry meant a bundler was free to drop the registering module outright. The store is now anchored (onDuplicate: 'replace', matchingregisterCollection's documented overwrite) and./dist/gdpr/registry.*is insideEffects, with asideEffectsRationaleblock in the manifest recording why each entry is there.The class API is unchanged — same names, arguments, semantics, and
getAll()'s registration-order guarantee.unregisterCollectionrebuilds 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.tsis now a delegation shim over@wabbit/tome-workflow's registry (see the workflow-adoption changeset) — anchored by that route.registry/artifact-registry.tsmoves ontocreateKeyedRegistry, INCLUDING itsfrozenflag: 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.tsand read during collection construction, and under the Payload CLI those are separate module instances — the observable failure was anartifactTypeselect with no options and a thrown "Unknown artifact type".Forcing function:
scripts/assert-registry-anchoring.mjs+pnpm assert:registry-anchoring, wired intoplatform-discipline.ymlimmediately afterassert:no-forked-primitives(source + manifest reading only, so it runs pre-build and fails fast). Any module-scope mutableMap/Set/instance singleton whose name — or whose FILE name — announces a registry must importcreateKeyedRegistry, containSymbol.for(, or have its built path listed in the package'ssideEffectsarray; 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.tsis the deliberately explicit-instance DESCRIPTOR registry (ARCHITECTURE.md § Three Registry Mechanisms #2 — the registry that genuinely must be one store, the render registry, is separatelySymbol.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_PAGESand theFindPagedArgstype — the bounded paginated job scan that existed identically in@wabbit/tome-lms,@wabbit/tome-crowdfundand@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/jobsand 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 owntypedSlug()rather than an inline cast. The./jobssubpath 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 theFieldOverrideMapandFieldShapeConfigtypes. The canonical body is@wabbit/tome-ledger's, the superset (it is the only copy that had theapplyFieldShapecombinator). 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 afieldNamesrename applied before this"). - `@wabbit/tome-core/fields/selectOptions` (new subpath) — the ONE
{ mode: 'extend' | 'replace'; options }select-vocabulary override contract, withextenddeduped byvalue. 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 ownfield-reports/shared.tsandgdpr/compliance/processingRegister.tsnow import it and re-export their existing symbols unchanged.
Deprecated, not removed:
SelectOptionOverridein@wabbit/tome-core/identity(the{ extend?, replace? }shape) now carries@deprecatedpointing at the canonical contract. It is public API on a 1.x package and keeps working —MemberCollectionConfig.identity.standingOptionsis unaffected — but nothing new should adopt it.New gate:
pnpm assert:no-forked-primitives(scripts/assert-no-forked-primitives.mjs), wired intoplatform-discipline.ymlimmediately afterassert:declared-imports(source-only, pre-build). It hashes comment-stripped, import-stripped file bodies across everypackages/*/src/**/*.tsand 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
fieldShapetrio would have been caught before it drifted. One allowlist entry,@wabbit/tome-ledger'smergeHooks, 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.
- `@wabbit/tome-core/jobs` gains
One
LayerFactoryConfigevery layer factory's config extends, and one factory verb.Fourteen layer packages end in the same one call a consumer writes into
payload.config.ts, and no two agreed on whatconfigmay contain: full seam vocabulary in three (org, lms, ledger), partial in six, NONE in six (2026-09-01 sale-readiness audit §5.3). A site that learnedadminGroupfrom org andhooksfrom sc discovered, package by package, that six factories accept neither — not because the seam had been rejected, but because nothing said it existed.New in core (a NEW exports-map subpath, hence the minor):
@wabbit/tome-core/utilities/layerFactoryConfigexports theLayerFactoryConfiginterface —adminGroup,access(per-collection override map),hooks(appended viamergeHooks, never replacing),extraFields,fieldOverrides,omitFields,fieldOrder,slugs— andapplyLayerFactoryConfig(collections, config), which honors the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated tofields/fieldShape'sapplyFieldShapeso the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config.It is a separate subpath from
./utilities/layerRegistrydeliberately — that module is in core'ssideEffectsarray, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph.The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed
slugs?: Partial<XSlugs>map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base'sRecord<string, string | undefined>), and named<name>Slug?: stringscalars for relationship targets in OTHER layers (memberSlug,mediaSlug,eventSlug,rolesSlug) — those are pointers out of a layer, not entries in its key set.*Every `createLayer
config now extends it.** Twelve extendLayerFactoryConfigdirectly and APPLY it throughapplyLayerFactoryConfig(accounts, catalog, crm, crowdfund, deals, fulfillment, lms, marketing, org, sc) or through a targeted application (chrome). Additive in every case: for the six that accepted none of the seams (deals, economy, gamification, marketing, plus forms/intake, see below), the fields are new; for the rest,adminGroup` and friends keep their existing meaning and the applier is a no-op when they are omitted.Two packages accept the vocabulary but do NOT yet apply it, and say so in their type's JSDoc in the required form ("accepted, not yet applied — trigger: …"). economy and gamification both declare
@wabbit/tome-coreas an OPTIONAL peer and hold zero runtime imports of it — gamification reachesregisterLayerthrough a lazyrequire()in a try/catch for exactly this reason.applyLayerFactoryConfigis a runtime VALUE, so importing it at module scope would convert an optional peer into a required one and break every site that installs those packages without core; copying the applier locally is barred byassert:no-forked-primitives. The trigger is stated: the day core becomes a required peer, delete the note and add one line.Both take the type via
import type, which is erased at runtime.Two packages drop seams EXPLICITLY rather than accept-and-ignore. chrome extends
Omit<LayerFactoryConfig, 'access' | 'hooks' | 'extraFields' | 'fieldOverrides' | 'omitFields' | 'fieldOrder' | 'slugs'>because it returns Payload GLOBALS, not collections — those seven are keyed by collection slug and typed againstCollectionConfig, and chrome's slugs already have direct per-surface knobs (header.slug,footer.slug) a parallel map could contradict.The one seam it keeps,
adminGroup, IS applied: globals carryadmin.groupexactly as collections do. rpg extendsOmit<LayerFactoryConfig, 'access'>becauseCharacterSheetsConfigis a single collection's config that doubles as the layer factory's config, and its ownaccessalready means "this collection's access object" — one level shallower than the base's slug-keyed map.Two meanings under one name is the confusion this interface exists to end.
Factory-verb convergence. Three verbs were live.
createWorkflowLayer(config?)is new in@wabbit/tome-workflow(a new export — hence the minor) and returns a spreadable, deliberately EMPTYCollectionConfig[]: this layer is an engine, not a collection set, so the empty array is the honest answer and lets...createWorkflowLayer()compose exactly like every sibling.Its
WorkflowLayerConfigomits every seam for the same reason, and exists as the stable place a real option will land.createGamificationLayerandcreateRpgLayerare pure aliases ofregisterGamificationLayer/registerRpgLayer.initWorkflow,registerGamificationLayerandregisterRpgLayerare all@deprecatedwith sunset at each package's next major; none is removed.Forcing function:
scripts/assert-layer-factory-contract.mjs+pnpm assert:layer-factory-contract, wired intoplatform-discipline.ymlafterassert:layer-version(source reading only, pre-build). Every exportedcreate*Layermust take a config parameter whose type resolves toLayerFactoryConfig— throughextends, an intersection, or an explicitOmit<…>— with verb aliases followed to theirregister*/init*target.Before this change it reported 12 violations and 0 conforming; it now reports 15 conforming, 0 violations. Deliberately NOT checked: whether a factory actually applies what it accepts, because a machine cannot tell a documented deferral from an accident, and a gate that forced silent application would be worse than one that forces a stated deferral.
docs/guides/create-a-new-layer-package.mdgains a "The factory contract" section stating the rule and the three permitted responses.Three factories are ALLOWLISTED with a reason each:
createAiLayerreturns credential wiring and owns no collections, so every seam is meaningless to it;createFormsLayerandcreateIntakeLayerare owned by the forms+intake access wave running in parallel, whose changes rewrite the same files.Peer floors: accounts, catalog, chrome, crm, deals, economy, fulfillment, gamification, marketing and rpg raise
@wabbit/tome-coreto>=1.14.0 <2.0.0. The new subpaths do not exist below that, and a too-low floor is howERR_PACKAGE_PATH_NOT_EXPORTEDreached crowdfund's consumers once already.These are marked
patchbecause the config widening is purely additive; the raised required-peer floor is the reason a release manager may prefer to cut them as minors instead.fields/linkno longer throws "(0, import_deepMerge.default) is not a function" when required from CommonJS.The internal
deepMergehelper was adefaultexport consumed via a default import inside the package; with tsupbundle: falsethat 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 firstlink()call. ESM consumers were unaffected, which is why it went unnoticed untilassert:node-loadableran across every package.deepMergeis 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/envScaffoldnow 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 ashasLayer(), covering economy, crowdfund, marketing, ai, admin-pro, deals, intake, crm and print.validateEnv({ layers })merges the named blocks on top ofSHARED_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 oneSTRIPE_SECRET_KEYline 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 existingpayload.config.tschanges behavior.EnvVarSpecand the newValidateEnvOptionsare exported.requiredhere means "required GIVEN this layer is installed", and the distinction is real rather than cosmetic: the Stripe pair andTOME_ADMIN_LICENSE_KEYare 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_KEYis likewise a warning — its absence selects a supported log-instead-of-send mode.One real bug fell out of the survey:
SHARED_ENV_VARSandenv.exampleboth documented `POSTHOG_HOST`, whileinfra/posthog.tsxhas always read `NEXT_PUBLIC_POSTHOG_HOST`. A site that set the documented name got the/ingestreverse-proxy default and no error.The scaffold now names the variable the code reads;
env.examplegains the per-layer block with the same required/optional annotations. 13 new tests intests/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 totscfor 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 — andimport typeerases 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.jsmodules. 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
peerDependenciesof core, so nothing is added to the manifest.New exported types:
RedirectsPluginOptions,SeoPluginOptions,FormBuilderPluginOptions,SearchPluginOptions,NestedDocsPluginOptions.search.beforeSyncis typed from the plugin's own config instead of(args: any) => any.The stricter types immediately found one real seam: nested-docs'
generateURLtakes 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 understrictFunctionTypes), so the public shape is unchanged andbasePluginsnow adapts between the two explicitly, defaulting a missingslugto''instead of emitting/undefined— the runtime bug theanyhad been hiding..d.tsnote: the emitteddist/infra/plugins.d.tsnow references the@payloadcms/plugin-*types. Consumers already install these to pass the constructors in, and they are declared peers; a consumer withskipLibCheck(the Payload default) is unaffected either way.validateEnv({ layers })now knows aboutTOME_CRM_BOOTSTRAP_READ_FALLBACK, the flag crm 0.6.0 introduced to re-open the bootstrapcrm:readbridge 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.
homepagedeep-links to that package README on GitHub (.../tree/main/packages/<dir>#readme). Without it a registry page links to the monorepo root and the reader has to guess which of 46 folders they want.bugs.urlpoints at the repo issue tracker, so a paying customer has a place to report a defect that is not email.engines.nodeis>=22, matching the rootenginesand.nvmrcset the same day. This is a real floor, not decoration: CI on Node 20 could not expand the glob the block packs use fornode --test, and a package installed on Node 20 fails at a runtime the installer cannot connect back to the version.
The forcing function ships with the change:
scripts/assert-manifest-metadata.mjs(rootpnpm assert:manifest-metadata, wired intoplatform-discipline.ymlbesideassert:license-metadata) fails when any publishable manifest lacksdescription,repository.directorymatching its own folder,homepage,bugs,engines.nodeequal to the repo floor,license,filesorsideEffects. It reported 138 violations before this change and 0 after.README fixes surfaced by the extended
assert:readme-contractgate (2026-09-01 sale-readiness audit, Tier 2), each verified against the package's own manifest or source.- blocks-core — the
./categoriesand./typesentry points are now named in the Public API section; both were published but undocumented. - core — added
/access/orgScoped,/access/vendorScoped,/infra/healthand/infra/env-scaffoldto the additional-subpaths table, and noted that/auth/collections/roleshas a real/auth/collections/Rolescase alias in the exports map. - crowdfund —
CROWDFUND_LAYER_VERSIONis 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-uiandtypescript, which are declaredpeerDependenciesbut appeared only in prose (or not at all). - lms-ui —
StudentProfileEditoris flagged@deprecatedin the component table, matching the tag its source has carried since the permissions-naming convergence pass.
- blocks-core — the
README peer tables, and the gate that now requires them.
Sixteen packages declared
peerDependenciesand 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'ssonnerpeer, has its own changeset).Each of the sixteen gains a
## Peer dependenciessection generated from its ownpackage.json—| Peer | Range | Required |, one row per peer, the range verbatim,no (optional)read frompeerDependenciesMeta, plus one sentence on what is a realdependencyrather than a peer and why the optional ones are optional. The worst omissions this surfaced:@wabbit/tome-coredocumented 2 of its 13 peers and left out bothnextand@payloadcms/richtext-lexical, which are required;@wabbit/tome-adminlisted 5 of 20;@wabbit/tome-readoutand@wabbit/tome-sclisted none.Eight block packs carried a hand-typed compatibility table that had drifted a full React major — still
>=18after the peer floor moved to>=19.0.0— and none of the eight listedreact-domat 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.mjsnow FAILS a package that declares peers without a peer table (a markdown table whose header row names a Peer and a Range column — the existingOptional?andNotesthird 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'smemberscollection 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
userrelationship can now be optional — a production consumer'smemberscollection has 1395 rows, 1167 withuser: null(allstanding: 'legacy').unique: truestays on the field either way; Payload's unique index is sparse for optional fields, so multiplenullrows don't collide.userValidateis wired only whenrequireUser: false. - `identity` config (`displayNameRequired`, `titleField`, `slugSource`, `slugField`): lets a consumer make
displayNameoptional, pointadmin.useAsTitleat a different field (e.g. a consumer'srsiHandle), and source the generated slug from that field instead — or supply their ownslugField(...)pair entirely (slugField: Field[]), or drop the slug field while keepingdisplayName(slugField: false). This composes WITHOUTskipDefaultIdentity, which still drops bothdisplayNameandslugunchanged, exactly as before. - `standingOptions` (`true | { extend?, replace? }`) + `standingDefault`: adds an opt-in
standingselect field. Omitted by default — the pre-I4 factory has nostandingfield at all, and@wabbit/tome-org'sMemberwrapper still supplies its own viaextraFieldstoday, unchanged by this release.DEFAULT_MEMBER_STANDING_OPTIONSexports 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
readstrategy without replacing the wholeaccessobject (which would also drop the create/update/delete defaults). Payload'sAccesstype already supports aWhere-returning function (sync or async) — Contract C2 — so a custom function honors a consumer'smembersReadAccessverbatim. Ignored whenaccessis provided;accessalways fully replaces, unchanged. - Hook merge, every key:
config.hooksused to mergebeforeValidateby hand (built-in first, caller appended) but spread every other key wholesale — a caller-suppliedafterChangewould have silently REPLACED a future built-inafterChangehook the moment one was added, the same bug independently found and fixed in@wabbit/tome-organd@wabbit/tome-sc. New shared@wabbit/tome-core/hooks/mergeHooksleaf subpath (ported verbatim frompackages/org/src/hooks/mergeHooks.ts/packages/sc/src/extensions/mergeHooks.ts) generalizes the append semantics across every hook key;org/sccan migrate their local copies to this one in a later increment.
Read-only consumer verification:
tome-starterandwabbit-site-corecallcreateMemberCollectionwith none of the new options (unaffected);@wabbit/tome-org'sMember.tswrapper typechecks clean against the new signature;@wabbit/tome-lmsdoes not consume this factory.- `requireUser` (default `true`) + `userValidate`: the
Close three
./field-reportsgaps the first adoption attempt by a production consumer hit (Wave 2R I4.1), all additive with unchanged defaults:createWatchlistGlobalgainsfieldNames: { organizations?, externalId?, name?, category?, notes? }(the array field name was previously hardcoded toorganizations;externalIdFieldstays as a deprecated alias);createRiskScorergainswatchlistFieldNames(reads the watchlist through the SAME namescreateWatchlistGlobalwas given, instead of piggybacking on the assessment collection'sfieldNames.affiliationExternalId) andflagNames(overrides every emitted flag string —multiAffiliation/hiddenProfile/redactedProfile/newAccount/manualFlag/categoryFlag— threaded throughscore,assessAndStore, andrunReassessRiskvia the newresolveFlagNamesexport);registerFieldReportsGdprgainsreports/assessmentsoptions, eachfalse(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.tsports every case from that consumer's characterizedscoreThreat.spec.tsand confirms identical scores/flags (one pre-existing divergence carries over: the singlevisibilityenum 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.displayNameRequiredgap the firstcreateMemberCollectionconsumer (a production consumer, 1395 rows, 0 withdisplayName) hit, and grows the factory's generic field coverage.All opt-in, defaults byte-identical.
`identity.includeDisplayName?: boolean` (default `true`).
displayNameRequiredonly ever toggled the field'srequiredflag — there was no way to omitdisplayNameentirely while keeping the factory's slug handling.skipDefaultIdentity: truewas the only escape, but it dropsdisplayNameAND theslugpair TOGETHER, forcing that consumer's real adapter to re-supply its own slug pair verbatim viaextraFieldsjust to keep a factory-composed slug.includeDisplayName: falsecloses the gap: nodisplayNamefield is emitted,admin.useAsTitle/defaultColumnsfall back toidentity.titleField(now REQUIRED in that case — the factory throwsMemberIdentityConfigErrorat collection-definition time otherwise),slugSourcedefaults totitleFieldinstead of'displayName', and theslugfield itself is still emitted by default (passslugField: falseto 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
membersfields (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 viaextraFields:includeClassification—'public' | 'classified'select, default'public'(DEFAULT_MEMBER_CLASSIFICATION_OPTIONSexported).includePortrait— upload field (relationTomediaCollection), distinct from the always-onavatar.includeReportsTo— self-relationship (relationTo the collection's ownslug) for chain-of-command data.includeTimezone+timezoneField(default'timezone') — plaintextfield, 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') — plaindatefield. 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
*Fieldoption is offered, or still fully override viaextraFields.Also:
admin.defaultColumns' first entry now readstitleFieldinstead of a hardcoded'displayName'literal, so it never references a dropped field whenincludeDisplayNameisfalse(unchanged value whenincludeDisplayNameistrue, sincetitleFieldstill defaults to'displayName').New
MemberIdentityConfigErrorexported from@wabbit/tome-core/identity. New tests intests/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-reportssubpath (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 viafieldNames— zero migration),createWatchlistGlobal,createRiskScorer(score/assessAndStore, injectable weights/bands/thresholds, case-insensitive upsert),inputFromProfile, the standard task/endpoint adapter paircreateReassessRiskTask/createReassessRiskEndpoint(cache-only rescore), andregisterFieldReportsGdpr(reportsnull-ref; assessments conditionalredactper 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, explicitretryOnstatus policy defaulting to429/>=500withRetry-Afterhonored and capped attimeoutMs— never throws on an HTTP status), theExternalProfileAdapter<TProfile>contract,createProfileCacheCollection+createProfileCacheInvalidator, andrunProfileReconcilewith the standard task/endpoint adapter paircreateProfileReconcileTask/createProfileReconcileEndpoint.Additive only — nothing in the monorepo consumes this subpath yet (
RSIProfileAdapterlands in@wabbit/tome-scat 2R-I2).
1.10.xMinor
New
./gdpr/complianceand./gdpr/compliance/breachsubpaths (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:collectionsInvolvedis now aselectwhose options are DERIVED fromgdprRegistry.getAll()at factory-call time (plus a free-textotherCollectionsescape hatch), replacing the consumer's free-text field that had already drifted from what was actually registered.accessis injected via an option — the default uses only core's genericadmins/authenticatedguards, 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 viaactions),policyVersion,ipHash,userAgent,metadata,at. Collapses the consumer'sconsentType+grantedboolean +grantedAt/revokedAtpair into one genericaction+at— the specific consent taxonomy is a consumer'sextraFields, not core's business. Auto-registers itself withgdprRegistryasmode: 'retain'by default (registerGdpr: falseto opt out) — it IS the erasure proof (Art 7(1)), never deleted regardless of the rest of a consumer's cascade. Plus pure helpersrequiresReconsent(consentedVersion, currentVersion)(major-version-only comparison, no module-level constant) andrecordConsent(payload, input, options?)(Local API,overrideAccess: true). - `./gdpr/compliance/breach` —
assessBreachSeverity,generateAuthorityNotification,generateSubjectNotification,createBreachRecord, ported from a consumer'sbreachNotification.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-suppliedBreachNotificationConfiginstead of hardcoded to the consumer's name/contact, and Article 33(3)(c)/34(2)'s REQUIRED "likely consequences" content is a realreport.likelyConsequencesfield 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'saudit-logs.
65 new unit tests: factory option overrides,
collectionsInvolved/validateAgainstRegistryboth directions, ledger immutability + auto-registration, therequiresReconsentmajor-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.- `createProcessingRegisterCollection(options)` — an Article 30 processing register factory (neutral default slug
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-vocabularyselectfields —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— renamedalumnion the way in — and foursecurityMeasuresvalues) had no way to keep it.extendappends the consumer's options after the default vocabulary, deduped byvalue(default wins on collision);replacesubstitutes wholesale, the same full-replace contractaccessalready 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') forlastReviewedBy'srelationTo, mirroringcreateConsentLedgerCollection'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/retentionsubpath (Wave 5 / W5-I5): a purerunRetention({ payload, policies, now?, dryRun?, lock?, findDueErasures?, onErasureDue?, onStalledErasure?, stalledAfterDays? })retention engine, absorbed from a consumer'sgdprRetentionCleanuptask 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 normalizesRetentionPolicy = { collection, action: 'hard-delete' | 'anonymize' | 'retain', olderThan: { field, days }, where?, batchSize?, label? }(plusonAnonymize, required whenaction: '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 likedeleteInBatches; 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 policyerrors[]entry, not just a log line. - Erasure-due sweep: given a caller-supplied
findDueErasures(now)(its own query + assembled lifecycle trail) andonErasureDue(candidate)(loads user/member, callsrunErasure), the engine applies the supersede check viaresolveErasureTrailbefore dispatching — the6a4c2f47regression 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 outsidedryRunwheneverpoliciesorfindDueErasuresare non-empty —runRetentionthrows immediately rather than running unlocked by omission. ShipscreateInMemoryRetentionLock()for tests; no redis dependency added. `onUnavailable` defaults to `'skip'` (fail CLOSED) — this inverts the donor'sacquireDrainLock, which fails OPEN on a Redis outage. The6a4c2f47incident 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 viaonUnavailable: 'run'. - Officer-task escalation:
onStalledErasure?(info)fires once per candidate, only when it remains unresolved afteronErasureDuewas tried and is overdue paststalledAfterDays(default 1). - Adapters:
createRetentionTask(config)→ PayloadTaskHandler,createRetentionEndpoint(config)→CRON_SECRET-guarded endpoint. Both call the identicalrunRetention.
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. Seepackages/core/src/gdpr/README.md's./gdpr/retentionsection for the full contract.- Policy model as data:
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.
GdprCollectionRegistrationadds optionalmode('hard-delete' | 'soft-anonymize' | 'redact' | 'retain' | 'null-ref', default'hard-delete'),phase('pre-identity' | 'identity' | 'post-identity', default'pre-identity'),order(default0),memberField(a second FK for member-keyed collections), and mode handlersonRedact/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 }, oneStepResultper registration, with no barecatch {}— every failure lands in that step'serrors[]and the run continues.deleteAccountis now a thin wrapper overrunErasurethat reproduces its pre-existing behavior byte-for-byte for starter/wabbit-site-core (pinned by a call-sequence parity test);exportUserDatanow iteratesgetOrdered()instead ofgetAll()(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/onExporthandlers now receive the fullGdprStepContext(Wave 5 / I1.1) — the same objectonRedact/onAnonymize/onNullRefalready got — instead of a bare{ payload, userId, userEmail }, closing the gap the firstrunErasureconsumer hit: a member-keyed collection (member-notes.member,wallets.owner,group-memberships.member) had nomemberIdto query by, and neither handler could see the subject's pre-overwriteidentity.Fully additive — a handler destructuring only the old three keys is unaffected.
exportUserData'sExportArgsgains optionalmember?/identity?to thread this through; both default to absent/{}for every caller today.onDeletemay now return either a barenumber(legacy — normalized to{ count, errors: [] }) or aGdprStepOutcome({ 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 reportscount: 0.Verified the default (handler-less)
hard-deletepath already honoredmemberField(buildDefaultWhere'suserField = userId OR memberField = memberIdOR-clause predates this change); documented thatnull-refhas no default dispatch at all and has always requiredonNullRefunconditionally, 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-cachedgetAuthority. The platform owns the seam, shape and caching guarantee; it never implements the cascade, which is consumer org policy.scopesis 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/orWherewith defined boolean short-circuits, andwhereScopedTobridging a resolved authority to a row constraint. It returnsfalse, never{}, when a subject commands nothing: an emptyWherematches every row, so "no authority" expressed as{}is a total access bypass. - `/notifications` — task-notification emitter interface (no collection). Frozen
dedupKey/groupKeyconventions, 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
JobHandlerplusasPayloadTaskandasCronEndpoint, so layers ship logic and consumers choose a runner. Both adapters invoke the same function. Also exportsauthorizedCronRequest. - `/config/assertRelationTargets` — startup validator for dangling
relationTotargets. 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
authorizedCronRequestshort-circuited onauthHeader.length !== expected.length— the exact leakutilities/timingSafeEqualwas 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.
- `/authority` — injected
1 fix (1.7.1)
- 1.7.1
distis now loadable by raw Node. tsup builds withbundle: 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.jstwin (.cjsis 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, andresetPasswordTokenExpiresIn, all typed against better-auth's ownBetterAuthOptions. Previously the factory offered no way to wire these, so any deployment that leftrequireEmailVerificationon (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 viasheetTitleon MobileNavSheet) and opts out ofaria-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-reactas 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
vngdsource value now renders "Legacy" (enum value unchanged). - accounts / core / lms / ui / org / admin / motion / longform / cop / blocks: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named 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 aroutesoption — 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
/supportor/docs/get-startedwas 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:Configuration Emitted radio collections only reference, custom(unchanged)collections + routes reference, route, customroutes only route, custom(new)neither external-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.
naminggainstypeValues.route,typeLabels.routeandrouteFieldName, matching the existing reference/url escape hatches.Fully additive. With
routesomitted 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'scheckPermission/checkRoleAsync/checkPermissionAsync/checkAnyPermission/checkAllPermissions) are@deprecated(sunset core 2.0) with a decision tree in ARCHITECTURE.md;vendorScoped/orgScopedadmin 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 customadminRoles(20-test truth table ships with it).LMS v1 sunset: the entire
core/lmssurface (37 exports) carries dated@deprecatedtags 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 newassert:no-core-lmsscript is the removal gate (report-only until 2.0;--strictflips it).New
timingSafeEqualutility (exported at./utilities/timingSafeEqual): constant-time string comparison that hashes both operands to fixed-length SHA-256 digests beforenode:crypto.timingSafeEqual, so neither content nor length differences leak timing.Also anchors
productTypeHookRegistryonglobalThis(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
sideEffectsfield (38 added; motion/engine/forms already correct).Registration-bearing modules (render files'
registerRenderer,blocks/*/index.tsdefineBlockself-registration, widgetregister.tsfiles, productHooks, permission self-registrations, print templates, chrome built-in variants) are listed so bundlers can tree-shake everything else WITHOUT dropping import-time registrations — previously the field was unset, which blocked cross-module tree-shaking through the barrels entirely. Never blanketfalseon a package with registration or CSS.DRY adoption sweep (the audit's "adoption, not extraction" rule): crm/deals capability presets delegate to core's
sessionHasCapabilityOrLegacyAdmin; new corebuildOwnershipWhere/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; newformatDisplayDatein blocks-core utilities (UTC-pinned, hydration-safe) adopted at 5 verified-identical sites; lms-ui consolidates its two certificate date formatters locally;useMediaQuery/useIsMobilepublished from tome-ui and adopted by AppShell + admin's SidebarProvider; gamification'sawardPointsnow uses the authoritativegetPointsBalance(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 (
initializeRolesinauth/initRoles.ts,seedLegalPagesingdpr/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 callcreate; 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 apayloadValidationError({ data: { errors: [{ path, message }] } }) before the error reaches consumer code — the raw MongoE11000string never arrives, so the loser's error was rethrown, crashing that worker'sonInit.seedLegalPageshad 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
ValidationErrorshape (instanceof ValidationError+data.errors[].pathmatching 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 existsline 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:
nameandslugon therolescollection (auth/collections/Roles.ts) both carryunique: 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. ForseedLegalPagesthe target Pages collection is consumer-owned: when the consumer'sslugfield is unique the race surfaces as the handledValidationError; 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'sseedTomeAdminLayoutsin its own changeset. An atomicpayload.db.upsert()was considered and rejected for all of these: it bypasses the collection'shooks/accesspipeline thatpayload.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/permissionssub-module — super-permission hierarchy (MANAGE_XimpliesEDIT_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 existingRolescollection,PERMISSIONScatalog,rbaccheckers, andcapabilitiesengine are unchanged.This is now the single platform permission engine that
tome-organdtome-accountsbuild on.
2 fixes (1.2.1)
- 1.2.1
Docs-manifest emitter pipeline (W3 ship-readiness).
@wabbit/tome-blocks-corenow ships a standalone Node ESM CLI atscripts/emit-docs-manifests.mjsthat emits per-package documentation manifests (index.json, packages/<slug>.json, changelog.json) by reading what packages already carry — READMEs, the payload-free<pkg>/metablock-usage barrels, package.json exports maps, and CHANGELOG.md. It is the docs-pipeline sibling of the gallery source extractor and is consumed by host sites at prebuild:node node_modules/@wabbit/tome-blocks-core/scripts/emit-docs-manifests.mjs --output-dir <dir> --scope <scope.json>.To let the emitter import block metadata uniformly without dragging Payload config into a build script, the
./metapayload-free subpath (BlockMetaEntry[]) is extended to the remaining offered blocks packs — agency-essentials, catalog-pack, lms-pack, org-pack, and signal-theme — mirroring the existing editorial-pack / marketing-starter / content-writer / extras barrels. Each block'sBlockMetawas relocated verbatim into a payload-free sibling meta module and re-imported by its block config; no meta values changed.Every supported-core package additionally adds
CHANGELOG.mdto its publishedfilesarray so the next publish cascade ships changelogs the emitter can read from installed tarballs at prebuild. - 1.2.1
Fix
assignment-uploadsupload collection rejecting every file.It set
mimeTypes: ['*/*'], but Payload'svalidateMimeTypestrips only the first*('*/*'→'/*'), so the wildcard matched no detected MIME type and the upload guard blocked all student file submissions. Removed the broken config — omittingmimeTypesis the correct "accept any file" setting, and Payload still blocks dangerous executable types via its built-incheckFileRestrictionsallowlist.
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—formatSlugupgraded to the canonical algorithm (promoted from catalog's strictly-more-robust slugify: collapses whitespace/hyphen runs, trims edge hyphens); newbuildAutoSlugHook(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-rendererand./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-contractchecks 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 versioncascade: while tome-core sits at 0.x,^0.4.0peer ranges (resolved fromworkspace:^at publish) do NOT satisfy0.5.0per semver-zero rules, so@changesets/assemble-release-plancorrectly 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.0peer 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 againsttome-core@1.0.0until each republishes — warnings only, runtime works. They re-resolve their peer ranges to^1.0.0on their next publish naturally.Bumped manually rather than via
pnpm changeset versionto avoid retriggering the same cascade in the changesets run that documents this change. See memoryfeedback_changesets_0x_major_bump_bug.mdfor 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 withindex.ts; theexportsmap 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 isRoles.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.cssalongside the existing./tokens(both point at./dist/tokens.css). Lets consumers writeimport '@wabbit/tome-ui/tokens.css'to match the CSS-file naming convention as well as the existingimport '@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
apiKeyrename).betterAuthFactory.tsimportstwoFactor/customSession/organizationfrombetter-auth/plugins+passkeyfrom@better-auth/passkeyand no longer usesapiKey(extracted to@better-auth/api-keyin better-auth 1.6 / dropped from payload-better-auth 0.7). But the package'speerDependenciesfloors were stillbetter-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 crypticdoes 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.
- peerDependencies floors raised:
- 1.0.11
safeRevalidateTag/safeRevalidateTags(@wabbit/tome-core/data/cacheTags) now lazily loadnext/cachevia dynamicimport()instead ofrequire().tome-core is
"type": "module"and ships a dual tsup build withbundle: false. A barerequire('next/cache')in source was preserved verbatim in the emitted ESMdist/data/cacheTags.js, whererequireis undefined — true-ESM consumers hitrequire is not definedat first revalidation (this broke wabbit-site-core's admin when source-linked).Dynamic
import()is preserved verbatim by tsup in both the.jsand.cjsoutputs and is natively supported by Node under CommonJS, so it is the module-system-agnostic idiom — the same pattern@wabbit/tome-core/infra/posthogalready 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 missingnext/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 ofvoid(dynamicimport()is async). Every known call site invokes them fire-and-forget inside PayloadafterChangehooks / server actions and discards the return value, so this is non-breaking in practice.Consumers that want to observe revalidation completion may now
awaitthem. - 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:securityaudit deferred at the 1.0.0 cut.No API changes.
`packages/core/src/access/orgScoped.ts`:
- Added
create:access guard requiring authenticated user with currentactiveOrganizationIdAND non-nullorgRole(defense in depth alongside the hook fix). beforeChangecreate hook now force-overwritesorgFieldtouser.activeOrganizationIdfor 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 submittingdata.organization = '<foreign-org-id>'(finding 8.2).- Read/update/delete access now re-verifies current org membership via
u.orgRole !== null, not justactiveOrganizationIdpresence (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 mirroringupdate/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. beforeChangecreate hook now force-overwritesvendorFieldtouser.idfor 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 behindprocess.env.NODE_ENV !== 'production'in the BetterAuthtrustedOriginsarray (finding 1.1). Pass explicit prod origins viaopts.trustedOrigins.
`packages/core/src/access/checkRole.ts`:
- Added
@deprecatedJSDoc directing new code to@wabbit/tome-core/auth/rbac(finding 7.1). The function remains exported for backwards compatibility and is consumed internally byorgScoped/vendorScopedagainst 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 asyncauth/rbacpaths 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. - Added
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-core0.1.0 → 0.2.0@wabbit/tome-ui0.2.0 → 0.3.0@wabbit/tome-motion0.1.0 → 0.2.0@wabbit/tome-lms0.1.0 → 0.2.0@wabbit/tome-lms-ui0.1.0 → 0.2.0@wabbit/tome-blocks-core0.1.0 → 0.2.0 (via sprint-3)@wabbit/tome-blocks-extras0.1.0 → 0.2.0 (via sprint-3)@wabbit/tome-blocks-marketing-starter0.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.