Documentation menu

Documentation / CRM & Marketing engine / intake

Intake

Package
@wabbit/tome-intake
Version
v0.6.0
npm install @wabbit/tome-intake
First install from our registry?
  1. Get a registry token from your credentials page. You need a purchase that includes this package, or a Craft Library membership.

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

    @wabbit:registry=https://npm.wabbit.com/
    //npm.wabbit.com/:_authToken=YOUR_TOKEN
CRM & Marketing engineStable

Tome intake layer — the canonical lead-capture submit pipeline (verify → persist → route → notify) behind a single submitIntakeAction composition seam. Render is owned by @wabbit/tome-forms.

Overview

The canonical Tome lead-capture submit pipeline — verify → persist → route → notify — behind a single submitIntakeAction composition seam. Render is owned by @wabbit/tome-forms; intake owns everything downstream of the submit.

Peer dependencies

Required peers: payload, @wabbit/tome-core, typescript. @wabbit/tome-catalog is a declared optional peerDependency (product routing). @wabbit/tome-territory (zip routing) is not a declared peer at all — src/routing/strategies/zip.ts resolves it via a runtime-only import('@wabbit/tome-territory/routing').catch(...); if it isn't installed at submit time, routeZip logs a warning and returns { assigneeId: null, strategy: 'unrouted' } rather than throwing.

Peer dependencies

PeerRangeRequired
payload>=3.67.0yes
typescript>=5.7.0yes
@wabbit/tome-core>=1.25.0 <2.0.0yes
@wabbit/tome-catalog>=1.1.0 <2.0.0no (optional)

Both optional layers are checked at config time, not only at runtime: createIntakeLayer throws a TomeIntakeConfigError if any form uses routing.strategy: 'zip' while @wabbit/tome-territory is not in the layer registry, or 'product' while @wabbit/tome-catalog is not. Register those layers before createIntakeLayer. The runtime 'unrouted' fallback above is only a second line of defense.

Wire it (payload.config.ts)

createIntakeLayer must run before `createFormsLayer` so @wabbit/tome-forms' config-time hasLayer('@wabbit/tome-intake') check passes. initIntake is a deprecated pure alias, kept for back-compat only.

import { buildConfig } from 'payload'
import { createIntakeLayer, defineIntakeForm } from '@wabbit/tome-intake'

const contactIntake = defineIntakeForm({
  slug: 'contact',
  fields: [
    { name: 'name', type: 'text', required: true },
    { name: 'email', type: 'email', required: true },
    { name: 'message', type: 'textarea' },
    { name: '_hp', type: 'hidden', honeypot: true },
  ],
  verification: { provider: 'none' }, // forms owns anti-fraud upstream
  routing: { strategy: 'static', staticAssigneeId: 'rep-1' },
  notify: { stakeholderEmail: { to: 'sales@acme.com' }, submitterEmail: { subject: 'Thanks!' } },
})

const intake = createIntakeLayer({
  forms: [contactIntake],
  // Lazy resolver — submitIntakeAction receives no Payload from its caller.
  getPayload: async () => (await import('payload')).getPayload({ config: (await import('@payload-config')).default }),
})

export default buildConfig({
  collections: [/* ... */ ...intake.collections],
  // ...then createFormsLayer(...) for the render layer
})

createIntakeLayer(options) requires options (there is no empty default) and returns { collections, plugin } — spread collections, or pass plugin to plugins instead. Other options: collectionSlug (default 'intake-submissions'), adminGroup (default 'Intake'), submissionsReadAccess ('admin-only', the default, or 'authenticated', which on a site with open signup means any account) and grantRoles (roles granted intake:admin at startup, default ['admin']). defineIntakeForm validates eagerly and throws TomeIntakeConfigError on a missing slug, empty or duplicate fields, a non-none verification provider without secretKey (a literal or a "$ENV_VAR" reference), static without staticAssigneeId, round-robin without a pool, custom without a customRouter, or a form that breaks its data policy (see below).

What a submit does

  1. Looks up the form by slug and resolves Payload through getPayload. With no resolver it returns { ok: false } rather than throwing.
  2. Honeypot: a filled honeypot field is flagged (honeypotTriggered: true on the row), not rejected. The row is still saved and the emails still go out.
  3. Verification: when the provider is not none, the captcha token is read from data._captchaToken; a failed check returns { ok: false } and nothing is saved.
  4. Required fields are re-checked, the form is routed, and one row is written to the submissions collection with overrideAccess: true.
  5. Stakeholder and submitter emails are sent, then your onIntake(submission) hook runs.

The result is always { ok, submissionId?, assignee?, error? } and the action never throws. assignee is always null today: routing resolves only an id, so the stakeholder email goes to notify.stakeholderEmail.to — set it, or no stakeholder mail is addressed.

Email goes out through your Payload email adapter. dispatchOrLog picks one transport per email, in this order: (1) your Payload config has an email adapter (nodemailer/SMTP, the Resend adapter, or any custom one) — it sends through payload.sendEmail; (2) no adapter, but RESEND_API_KEY is set — the legacy path, also payload.sendEmail; (3) neither — it logs [tome-intake] email (logged, not sent) at info level (recipient, subject and a 200-character preview only) and reports delivered: false. Payload's built-in console fallback does not count as an adapter. Only one send is attempted per email, so a site with both an adapter and a key never gets a double send, and a failed send is logged ([tome-intake] email send failed; falling back to log, error level) and not retried on another transport. The from-address is notify.*.from, then EMAIL_FROM_ADDRESS, then your adapter's default from-address. The submitter copy goes out only when the form sets notify.submitterEmail and the submission has an email address.

The composition seam

@wabbit/tome-forms (a form with submission.target: 'intake') renders + validates, then calls:

submitIntakeAction(formSlug: string, data: Record<string, unknown>): Promise<TomeIntakeResult>

This flat two-argument contract is frozen (it's what tome-forms@0.1.3 calls). Forms reaches it through the layer registry (createIntakeLayer stores submitIntakeAction and dispatchOrLog in the layer's metadata), not through a dynamic import. For a direct HTML <form> without the forms engine, use the createIntakeFormAction(form, getPayload) HOF instead.

Neither function carries a 'use server' directive. To call one from a Client Component, re-export it from your own 'use server' module.

Data policy: contact-only

By default (dataPolicy: 'standard') intake stores every posted value, and the staff email prints all of it. A form, or a whole site, can opt in to 'contact-only', which limits what is stored and where it travels.

import { createIntakeLayer, defineIntakeForm } from '@wabbit/tome-intake'

const inquiry = defineIntakeForm({
  slug: 'inquiry',
  dataPolicy: 'contact-only',
  fields: [
    { name: 'name', type: 'text', required: true },
    { name: 'email', type: 'email', required: true },
    { name: 'message', type: 'textarea', maxLength: 500 },
    { name: '_hp', type: 'hidden', honeypot: true },
  ],
  verification: { provider: 'none' },
  routing: { strategy: 'static', staticAssigneeId: 'front-desk' },
  notify: { stakeholderEmail: { to: 'staff@example.com' }, submitterEmail: { subject: 'Thanks' } },
})

createIntakeLayer({
  forms: [inquiry],
  getPayload,
  // Same shape as the forms layer: default, lock, sensitiveTerms, retention.contactOnlyDays.
  policy: { default: 'contact-only', lock: true, sensitiveTerms: ['symptom'], retention: { contactOnlyDays: 90 } },
})

What it does under 'contact-only':

SurfaceBehavior
Field typesThe intake types are allowed except that only one textarea is, and it must set maxLength (500 or fewer; the submit action refuses a longer value). An unknown type is refused.
Sensitive termsA field name or label containing a policy.sensitiveTerms entry is refused (case-insensitive, substring).
When it is checkeddefineIntakeForm throws TomeIntakeConfigError at module load, and createIntakeLayer checks every form again once it knows the site policy.
StorageDeclared fields only, plus email and name. A key posted but not declared is dropped, and so is the honeypot. A direct payload.create is trimmed the same way by the collection's beforeValidate hook.
Staff emailThe form, reference id, time and a link to the record in the admin. No value, no email address, no name. notify.stakeholderEmail.htmlBuilder is refused.
Subject linesOnly {formSlug} resolves; {email}, {name} and {field} are empty. The default template becomes {formSlug} submission.
onIntakeReceives email, name, formSlug and the reference id, with payload empty.
Custom routerRefused unless the form sets dataPolicyAcknowledged: true.
LogsNo log line from submitIntakeAction carries a field value; the recipient is left out of the logged-not-sent line, and error lines carry the error name.
Forms engineA @wabbit/tome-forms form that is contact-only forwards a marker, so a submission it sends here is handled as contact-only even if this intake form says 'standard'.

What it does not do: it does not scan or filter what a person types into the message box. The message is stored until a retention policy removes it, and staff can read it in the admin. It does not encrypt data beyond what your host provides, and it does not make your email provider, host or database a party to any agreement. It is not a legal or regulatory determination: using it does not make a site compliant with anything, so have counsel review how you describe and use it.

standard forms behave exactly as before.

Retention and erasure

intakeRetentionPolicies({ days, formSlugs }) returns @wabbit/tome-core/gdpr/retention policy rows: hard-delete intake-submissions rows whose submittedAt is older than days, in the listed forms only. Without arguments it covers every registered contact-only form and uses policy.retention.contactOnlyDays. It builds rows and runs nothing: schedule them with createRetentionTask or createRetentionEndpoint, and run runRetention({ dryRun: true }) first, because a hard delete is permanent. A CRM contact created from a submission is not covered; whether and when it is deleted is the site's decision. createIntakeLayer logs one warning at startup for a contact-only form when policy.retention.contactOnlyDays is not set.

createIntakeLayer also registers the submissions collection with core's GDPR registry, matched on its email column, so data export and account erasure find those rows. Matching is by exact stored address (stored lowercase), so an account whose email differs in case from the stored value is not matched.

Public API (subpaths)

  • . — createIntakeLayer, defineIntakeForm, TomeIntakeConfigError, submitIntakeAction, createIntakeFormAction, createIntakeSubmissionsCollection, withIntakeAccess, listIntakeFormSlugs, getIntakeForm, intakeRetentionPolicies, the data-policy helpers (resolveIntakePolicy, isIntakeContactOnly, getIntakeSitePolicy, contactOnlyIntakeFieldProblems, CONTACT_ONLY_MESSAGE_MAX_LENGTH), and the TomeIntake* types (TomeIntakeFormConfig, TomeIntakeOptions, TomeIntakeResult, TomeIntakeSubmission, …)
  • @wabbit/tome-intake/verify — verifyHuman (recaptcha-v3 / turnstile / hcaptcha / none adapters), resolveSecret (turns a "$ENV_VAR" reference into its value), and the TomeIntakeVerifyAdapter type
  • @wabbit/tome-intake/routing — routeIntake (strategies: static, round-robin, zip, product, custom)
  • @wabbit/tome-intake/notify — dispatchOrLog, notifyStakeholder, notifySubmitter, plus the template helpers applyTemplateTokens, defaultStakeholderHtml, defaultSubmitterHtml, defaultWithheldStakeholderHtml
  • ./test — createMockIntakePayload harness (and its MockIntakeHarness / MockIntakeRecord / MockIntakeEmail types)

Deprecated aliases (pure renames, @deprecated-tagged, removal at this package's next major): initIntake → createIntakeLayer; defineIntakeSubmissionsCollection → createIntakeSubmissionsCollection.

This package ships no .tsx — it is a pure server-side submit pipeline (verify → persist → route → notify), so there is no server/client posture to declare; every subpath above is safe to import from any Node/server context, including payload.config.ts.

Smoke gate

pnpm --filter ./packages/intake smoke

Exercises the real pipeline against the mock Payload harness. Non-zero exit = publish gate fail. pnpm --filter @wabbit/tome-intake test runs the Vitest suite (access, email normalization, layer version). In your own tests, createMockIntakePayload() from @wabbit/tome-intake/test gives you an in-memory Payload stand-in that records created rows and sent emails.

Exports

Import path
@wabbit/tome-intake
@wabbit/tome-intake/notify
@wabbit/tome-intake/verify
@wabbit/tome-intake/routing
@wabbit/tome-intake/test

Changelog

0.6.xMinor

  • A new field routing strategy sends a submission to an assignee chosen by the value of one form field, for example "party" to the events desk and "rental" to the front desk.

    Set routing: { strategy: 'field', fieldRouting: { fieldName, map, fallbackAssigneeId } }. Values are compared trimmed and lower-cased, and a multi-select uses its first value.

    A value that isn't in the map goes to the fallback when one is set (recorded as field-fallback so staff can see it fired) and is otherwise unrouted. defineIntakeForm checks at load that the field name is a field of the form, the map isn't empty, and no two keys collide once lower-cased.

    TomeIntakeRoutingStrategy gains the field member, so code that switches on it exhaustively needs a new case. The custom strategy is unchanged.

0.5.xMinor

  • Forms and intake gain an opt-in contact-only data policy, per form or site-wide, plus retention policies and GDPR registration for submissions.

    standard forms behave as before.

    dataPolicy: 'contact-only' (on defineForm, defineIntakeForm, or policy.default on createFormsLayer and createIntakeLayer, with policy.lock and policy.sensitiveTerms) limits a form to contact fields and one textarea of at most 500 characters, and refuses other field types, sensitive terms, a webhook target, the forms email target (use intake or payload), an unacknowledged custom target or router, and an intake email htmlBuilder. It is checked when the form is defined, when the layer is created, and when an admin form is saved.

    Under the policy, intake stores declared fields only; staff email and subject lines carry no submitted values; onIntake receives identity with an empty payload; partial saves are forced off; and a notice renders above the submit button (hook data-form-data-notice). The policy reduces what a form asks for and where the answers travel.

    It does not scan what people type, and it is not a legal or regulatory determination.

    New exports: formsRetentionPolicies and intakeRetentionPolicies (hard-delete rows in core's retention model; nothing runs until you schedule them), and the data-policy helpers listed in each README. @wabbit/tome-crm takes an optional dataPolicy on the intake submission input; with 'contact-only' the form-submission activity writes no summary and intakeTagFields is not applied.

    Check this when you upgrade: forms-submissions gains an indexed email column (filled from the form's first email field), so a database that applies migrations needs one; older rows have no value until backfilled. Both packages now register their submissions collection with core's GDPR registry, matched on email, so core's data export and account erasure include those rows from now on.

1 fix (0.5.1)
  • 0.5.1
    The changelog, README and license text now use American spellings.

    No code or behavior changes.

0.4.xMinor

  • Submissions are now staff-only by default.

    submissionsReadAccess defaults to 'admin-only' on intake-submissions and forms-submissions; before, read and update were open to any signed-in session, which on a site with open signup means any account.

    Check this when you upgrade: if non-admin staff read submissions, pass submissionsReadAccess: 'authenticated', on a site where every account is staff.

    createIntakeLayer now grants intake:admin to the admin role at startup (new option grantRoles, default ['admin'], false to grant it yourself). Without that grant, a plain admin on a site whose roles is a relationship could not read submissions under admin-only.

    withFormsAccess's 'submissions' preset is now admin-only; 'submissions-authenticated' keeps the old rules. @wabbit/tome-intake now needs @wabbit/tome-core 1.25.0 or later.

0.3.xMinor

  • Naming convergence (all additive; every old name keeps working as a @deprecated alias until that package's next major).

    create* is canonical for collection/layer factories (define* stays reserved for the blocks descriptor system): crm/deals/marketing/intake/forms gain create*Collection names for their former define*Collection factories. Layer entries converge on createXLayer(config?) → bundle: createCrmLayer/createDealsLayer/createMarketingLayer/createCatalogLayer/createEconomyLayer/createChromeLayer/createLmsLayer/createAiLayer (+ createFormsLayer/createIntakeLayer), returning bare CollectionConfig[] where the layer contributes only collections or an honest named bundle where it hands back more (chrome: { globals }; lms/ai: { collections, hooks }); void-returning initCatalog/initEconomy stay as the single registration call sites, delegated to internally.

    Naming note for forms consumers: createFormsCollection (singular factory) vs createFormsCollections (plural composer) vs createFormsLayer (layer entry) — each docblock states the distinction.

  • Every package now declares an explicit sideEffects field (38 added; motion/engine/forms already correct).

    Registration-bearing modules (render files' registerRenderer, blocks/*/index.ts defineBlock self-registration, widget register.ts files, productHooks, permission self-registrations, print templates, chrome built-in variants) are listed so bundlers can tree-shake everything else WITHOUT dropping import-time registrations — previously the field was unset, which blocked cross-module tree-shaking through the barrels entirely. Never blanket false on a package with registration or CSS.

14 fixes (0.3.1 – 0.3.6)
  • 0.3.6
    Intake emails now send through your site's configured email adapter; before, they only sent with a Resend key.

    If your Payload config has an email adapter (nodemailer/SMTP, Resend, or any other), both the stakeholder copy and the submitter copy now go out through it, with no RESEND_API_KEY needed. When you set no from, the adapter's own default from-address is used; a from in the intake config or EMAIL_FROM_ADDRESS still wins.

    Sites with no adapter but a RESEND_API_KEY behave as before, and with neither, the email is logged and not sent. Only one send is ever attempted per email, so nothing is delivered twice.

  • 0.3.5

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

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

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

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

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

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

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

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

  • 0.3.3
    Internal refactor: collection slugs are now typed through the shared typedSlug() helper instead of inline casts.

    No API or behavior change.

  • 0.3.3
    dispatchOrLog now logs a failed email send through payload.logger.error before falling back to its logged-not-sent path.

    Previously the send error was discarded, so a misconfigured email adapter or a provider outage looked identical to a deliberate "logged, not sent". It still never throws.

  • 0.3.3

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

  • 0.3.2
    dist now raw-Node loadable: relative specifiers get explicit extensions post-build.

    build gains && node ../../scripts/fix-dist-extensions.mjs --strict as its last step, joining the 13 packages that already ran it. tsup builds bundle: false and emits relative specifiers exactly as the TypeScript source wrote them — extensionless — which bundlers resolve and raw Node does not (ESM ERR_MODULE_NOT_FOUND; CJS worse, require('./x') finds the ESM .js twin and Node 22+ require(esm) then dies on that file's own extensionless import). Every consumer outside a bundler hit this: the payload CLI under plain node, generate:types, generate:importmap, ops scripts, codegen tools.

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

    Two supporting changes made the wiring possible, both in repo scripts rather than package source. fix-dist-extensions.mjs now skips bundler-asset specifiers (.css, .module.css, .scss, fonts, images, shaders) by explicit extension allowlist instead of reporting them as unresolvable — that single gap is why the 13 prior adopters were exactly the 13 packages that ship no CSS, since --strict exited 1 on any package with a relative stylesheet import.

    Dotted MODULE names (./config.meta, ./x.variants, ./y.demo) are deliberately NOT treated as assets and still get .js/.cjs appended. assert-node-loadable.mjs gained the matching carve-outs so the new repo-wide CI gate reports real defects only: a resolution failure whose path lands under node_modules is a peer SKIP (next@15 has no exports map, so next/image fails as an absolute path), and a bundler-asset load failure is an environmental SKIP (CJS surfaces it as SyntaxError: Unexpected token '.' raised from inside the stylesheet).

    Verified before/after on four packages built one at a time: print 8 FAIL → 0, readout 22 FAIL → 0, ai 3 FAIL → 0, gamification 2 FAIL → 0 (its failure was the other signature — a directory import missing /index). cop was already clean on a fresh build, so the audit's "27 of 46 fail" figure includes at least one package whose local dist was merely stale.

  • 0.3.2
    Put both packages' admin checks on core's primitive, and fix the role-singular defect in forms (2026-09-01 sale-readiness audit §5.2, §7, T3(g)).

    Both of these WIDEN who counts as an admin. Neither narrows it — no session that passed before fails now.

    `@wabbit/tome-intake`. withIntakeAccess's requireAdmin matched a roles array against three literal strings and carried a TODO at the top of the file: "wire to tome-core capability registry when intake:admin capability is seeded". The TODO is discharged and deleted; the check is sessionHasCapabilityOrLegacyAdmin(req, 'intake:admin') from @wabbit/tome-core/auth/repScoping, the same primitive crm and deals use.

    `@wabbit/tome-forms`. buildDraftScopedAccess read user.role — SINGULAR. Every sibling layer, and core's own capability engine, read the roles ARRAY.

    That is a defect, not a style difference: on a site whose users carry roles: ['admin'] — the shape core's legacy fallback and five other layers assume — this gate saw no admin at all, and every admin silently received the rep-scoped { user: { equals: self } } Where clause instead of unrestricted access. It failed CLOSED, which is why nobody noticed: a form draft an admin could not see looked exactly like a draft that did not exist.

    It is now sessionHasCapabilityOrLegacyAdmin(req, 'forms:admin').

    The primitive answers in order: a real capability grant via canAsync (through _populatedRoles, the enriched role: string[], or a role fetch on req.payload; a populated super-admin role matches every capability by the engine's wildcard rule), then the legacy bootstrap fallback — a roles ARRAY containing admin / superadmin / super-admin.

    Neither package declares its capability. Grep for defineCapabilities finds it in core's defaults and in @wabbit/tome-lms, nowhere else — so intake:admin and forms:admin are not in any default vocabulary, and on a site that has not granted them itself the capability branch always misses and the legacy role array is what actually makes admin access work. That is not a defect; it is the bootstrap path, and it is why a site can install either layer and have a working admin gate on day one.

    Both file headers say so in as many words, so the next reader does not mistake the capability name for a wired-up feature. A site that DOES seed and grant the capability now gets it honored, which is what intake's TODO was asking for.

    The exact widening, per package:

    • intake — a role holding an intake:admin grant passes; a populated super-admin role passes; the enriched role: string[] shape is consulted where it was previously ignored. Unchanged: create is denied for EVERYONE including admins (every legitimate write goes through submitIntakeAction with overrideAccess: true, which is what makes the verify→persist pipeline unbypassable); the default preset still lets any authenticated session read and update; the legacy branch is still case-sensitive, still requires an array, still rejects near-misses.
    • forms — roles: ['admin' | 'superadmin' | 'super-admin'] now passes (the defect fix); a forms:admin grant passes; a populated super-admin role passes. No longer special: role: 'admin' as a bare STRING, which the old code coerced into a one-element array and matched. No consumer in this repo writes that shape and core's engine does not read it, so such a session now falls through to the scoped Where clause. Unchanged: anonymous denied outright, non-admins scoped to their own drafts, create denied for everyone, delete admin-only, and the anonymous save/resume flow which goes through the server actions with overrideAccess: true + HMAC verification and never touched this gate.

    Both checks are asynchronous now, because the capability engine may resolve roles through req.payload; Payload access functions may return a promise, so this changes each module's internals, not its contract.

    Tests: intake/tests/access.test.ts was written by an earlier wave to pin the old heuristic "exactly as it behaves today, including the parts that are arguably wrong", explicitly so that "the capability wiring, when it lands, arrives as a deliberate diff against a stated baseline." This is that diff — the file is rewritten as the NEW truth table, with every moved row labeled and every unchanged row labeled. forms/tests/draft-access.test.ts is new and does the same job for the drafts preset.

    Both vitest configs gain a bare @wabbit/tome-core → source alias, which is load-bearing rather than cosmetic: the capability GRANT REGISTRY is module-scoped state, so a partial alias would split it in two and make the capability rows pass for the wrong reason.

  • 0.3.2
    Pin each layer's registered version to package.json instead of a hand-typed literal.

    registerLayer(name, { version }) is the contract a consumer reads back through hasLayer/getLayer to gate on a layer's capability. Eight packages passed a literal that nobody compared to the manifest, so an up-to-date install advertised an old contract and every gate keyed on it failed silently — nothing throws when a version string is stale.

    PackageRegisteredActual
    @wabbit/tome-rpg'0.1.2'0.2.2
    @wabbit/tome-gamification'0.1.0'0.3.1
    @wabbit/tome-crm'0.3.0'0.5.0
    @wabbit/tome-ai'0.1.0'0.4.0
    @wabbit/tome-formsTOME_FORMS_VERSION = '0.1.0'0.3.2
    @wabbit/tome-intakeTOME_INTAKE_VERSION = '0.1.0'0.3.1
    @wabbit/tome-marketing'0.1.0'0.4.0
    @wabbit/tome-chrome'0.6.0'0.8.5

    Each package now carries a leaf src/version.ts exporting <NAME>_LAYER_VERSION, read by its registerLayer call — the shape nine sibling packages (accounts, catalog, crowdfund, deals, economy, fulfillment, ledger, lms, org, workflow) already used and stayed accurate with. Forms' and intake's module-local TOME_*_VERSION consts move into that module: a _named_ constant was never the guarantee, a _pinned_ one is.

    The forcing function ships with the fix. pnpm assert:layer-version (new, wired into platform-discipline.yml pre-build) parses every registerLayer call in the repo, resolves its version argument through literals and consts, and fails on any disagreement with the manifest — so this cannot recur in a package that never gets around to writing the test.

    Seven of these eight were found by the 2026-09-01 sale-readiness audit; chrome was found by the assert itself on its first run.

    crm, forms, intake, marketing and rpg gained their first test suite in the process (tests/layer-version.test.ts) and were removed from the assert:test-floor starting-debt allowlist.

    No runtime behavior changes for a consumer already on a current install — the version a layer reports simply becomes true.

  • 0.3.2
    Manifest metadata: homepage, bugs, engines.

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

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

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

  • 0.3.2
    `normalizeEmail` adoption on the email fields the 2026-09-01 audit flagged, plus the one swallowed error that had no logger.

    Email is the cross-layer join key — forms hands a submission to intake, intake to CRM contact matching, CRM to marketing suppression — and findContactByEmail / matchOrCreateContact normalize before they query. Any layer that stores a raw address forks the same person into two records at the first hand-off.

    • intake — intake-submissions.email (required, indexed, the CRM join column) now normalizes through core's canonical normalizeEmail via a new normalizeEmailField field hook. Field-level rather than folded into beforeValidateIntake, which returns early on anything but a create: an admin retyping an address on update is exactly the case a create-only hook misses.
    • forms — the emailRecipient config field gains the same hook, and the email field-type descriptor's sanitize stops hand-rolling v.trim().toLowerCase() and delegates to normalizeEmail. That fork agreed byte-for-byte today, which is the problem: the day the shared helper learns anything, forms silently stops agreeing. (assert:no-forked-primitives catches exact-body forks, not inline expressions like this one.)
    • crm — record-crm-activity-with-dedup.ts's account-resolution catch was the one swallowed error in the repo's sample with no logger call at all (audit §6). The swallow is correct — an activity row without an account link beats a dropped webhook — but a contacts lookup failing there is normally a slug misconfiguration or a permissions change, and every later activity lands unlinked until someone notices. It now warns through req.payload.logger.warn with a console.warn fallback, naming the contact and the slug, matching access/presets.ts and hooks/stage-change-dispatch.ts.

    Normalization stays deliverable-address preserving (trim + lowercase only; no dot-stripping, no plus-tag removal) — asserted, because the stored value is what gets emailed.

  • 0.3.2
    README peer tables, and the gate that now requires them.

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

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

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

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

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

  • 0.3.2
    First test suites for the four packages the 2026-09-01 sale-readiness audit named as "security-relevant code with no test" (§7).

    No behavior changed in marketing or intake; forms and crm ship one behavior change each, described below and covered by the same suites.

    • marketing — webhooks/verify-utils gets published HMAC-SHA256 known-answer vectors (RFC 4231 TC2, quick-brown-fox) plus a cross-check against node:crypto as an independent oracle, and a full contract table for timingSafeEqualHex (case folding, single-nibble mismatch, length short-circuit, and the fact that it does not validate hex — two empty strings compare equal, so callers must check presence first). Both adapters' verify functions are covered end to end: valid token accepts, one-character change rejects, missing/empty/differently-cased header rejects, malformed URL fails closed, and the unconfigured-secret pass-through is asserted explicitly rather than left implicit. The secret (Encharge) vs webhookSecret (Kit) parameter-name split is pinned as a contract, not harmonised: passing the other package's key name leaves the secret undefined, which means bypass mode — a rename would open both endpoints silently. Also documents a real Web Crypto/node divergence: an empty secret THROWS rather than signing, which is the fail-closed outcome and is now pinned.
    • intake — withIntakeAccess gets the full truth table: create denied for everyone including admins (all writes go through submitIntakeAction with overrideAccess: true), read/update per preset, delete admin-only regardless of preset, plus wrapper semantics (overrides incoming access, shallow clone, defines exactly four keys). The file's TODO: wire to tome-core capability registry is untouched — the suite pins the CURRENT roles-array heuristic, including its case-sensitivity and the fact that it ignores role/_populatedRoles, so the wiring change arrives as a deliberate diff.
    • forms — server/targets/webhook covered for request shape (method, header merge and override, payloadTransform) and every failure path (non-2xx with detail, 200-char body truncation, body-read failure, network rejection, non-Error throw, never throwing to the caller). The absence of any timeout is asserted explicitly rather than glossed: fetch is called with no AbortSignal, so a hanging endpoint hangs the submission — that assertion is the ticket, and it flips loudly when a timeout lands.
    • crm — access/presets covered for all three paths: seeded capability grants, the legacy admin/superadmin/super-admin roles-array fallback, and the bootstrap fallback that opens crm:read to any authenticated session. The last one is asserted in both directions — what it opens (read on every collection) and what it still refuses (write, delete) — because an un-seeded production site is running on it. buildAccountDeleteGuard and the once-per-process production warning are covered too.

    All four packages gain a test script (vitest run) and a vitest devDependency, and are removed from scripts/assert-test-floor.mjs's ALLOWLIST — a stale allowlist entry fails the assert in both directions.

  • 0.3.1
    Add a submissionsReadAccess option to initForms/initIntake (and their underlying collection factories).

    Set it to 'admin-only' to restrict read (and update) of forms-submissions / intake-submissions to admins, so non-admin authenticated sessions — e.g. a public demo login — cannot read inbound submission PII via the data API. Defaults to 'authenticated', preserving the existing contract.

    Delete remains admin-only regardless.

    The feature code merged to main previously but was never released; this changeset ships it as the first published version to carry it.

0.1.x

5 fixes (0.1.5 – 0.1.8)
  • 0.1.8
    Docs-manifest emitter pipeline (W3 ship-readiness).

    @wabbit/tome-blocks-core now ships a standalone Node ESM CLI at scripts/emit-docs-manifests.mjs that emits per-package documentation manifests (index.json, packages/<slug>.json, changelog.json) by reading what packages already carry — READMEs, the payload-free <pkg>/meta block-usage barrels, package.json exports maps, and CHANGELOG.md. It is the docs-pipeline sibling of the gallery source extractor and is consumed by host sites at prebuild: node node_modules/@wabbit/tome-blocks-core/scripts/emit-docs-manifests.mjs --output-dir <dir> --scope <scope.json>.

    To let the emitter import block metadata uniformly without dragging Payload config into a build script, the ./meta payload-free subpath (BlockMetaEntry[]) is extended to the remaining offered blocks packs — agency-essentials, catalog-pack, lms-pack, org-pack, and signal-theme — mirroring the existing editorial-pack / marketing-starter / content-writer / extras barrels. Each block's BlockMeta was relocated verbatim into a payload-free sibling meta module and re-imported by its block config; no meta values changed.

    Every supported-core package additionally adds CHANGELOG.md to its published files array so the next publish cascade ships changelogs the emitter can read from installed tarballs at prebuild.

  • 0.1.7
    Platform-wide peer-range sweep: every workspace:*/workspace:^ entry in peerDependencies replaced with an explicit semver range (@wabbit/tome-core >=1.0.0 <2.0.0, tome-ui >=0.9.0 <1.0.0, tome-motion >=0.2.0 <1.0.0, tome-catalog >=1.1.0 <2.0.0, tome-admin >=0.5.0 <1.0.0; tome-crm ranges standardized to >=0.2.0 <1.0.0).

    The workspace protocol publishes as an exact-version pin, so every substrate bump stranded installed dependents — the breakage class proven by marketing@0.1.0/deals@0.1.1 requiring tome-crm@0.2.0 exactly. devDependencies keep workspace:* for the local link. (@wabbit/tome-admin-pro got the same source fix but is rc-versioned; it carries the change on its next intentional release.)

    tome-crm additionally gains a once-per-process production warning when the capability-registry fallback grants access — the bootstrap heuristic (any authenticated user passes crm:read) now announces itself instead of running silently on sites that forgot to seed capability grants (2026-06-10 audit hardening item).

    Graph-truth additions (same hygiene wave): tome-deals declares its lazy print integration as an optional peer (@wabbit/tome-print >=0.1.0 <1.0.0); tome-intake declares its lazy catalog routing strategy (@wabbit/tome-catalog >=1.1.0 <2.0.0, optional). These were undeclared dynamic imports — invisible to consumers and to pnpm's build topology.

  • 0.1.6
    Updated dependencies [8947ff1]
    • @wabbit/tome-core@1.0.12
  • 0.1.5

    Updated dependencies [36dc023]

  • 0.1.5
    Updated dependencies [2612799]
    • @wabbit/tome-core@1.0.11

Full changelog across all packages