Intake

CRM & Marketing engineStable
@wabbit/tome-intakev0.3.4

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.

Install
  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
  3. Then install:

    npm install @wabbit/tome-intake

Overview

@wabbit/tome-intake

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.

Install

pnpm add @wabbit/tome-intake

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

| Peer | Range | Required | |---|---|---| | payload | >=3.67.0 | yes | | typescript | >=5.7.0 | yes | | @wabbit/tome-core | >=1.0.0 <2.0.0 | yes | | @wabbit/tome-catalog | >=1.1.0 <2.0.0 | no (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 defence.

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'), and submissionsReadAccess ('authenticated' or 'admin-only'). 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, or custom without a customRouter.

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.

The gotcha that bites: email only sends when `RESEND_API_KEY` is set. dispatchOrLog calls payload.sendEmail only when that environment variable is present (and your Payload config has an email adapter). Otherwise it logs [tome-intake] email (logged, not sent) at info level and reports delivered: false. If the key is set but the send throws, the error is logged first ([tome-intake] email send failed; falling back to log, error level, with err), then the same fallback applies. A local run with no key "works" but sends nothing.

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.

Public API (subpaths)

  • . — createIntakeLayer, defineIntakeForm, TomeIntakeConfigError, submitIntakeAction, createIntakeFormAction, createIntakeSubmissionsCollection, withIntakeAccess, listIntakeFormSlugs, getIntakeForm, and the TomeIntake* types (TomeIntakeFormConfig, TomeIntakeOptions, TomeIntakeResult, TomeIntakeSubmission, …)
  • ./verify — verifyHuman (recaptcha-v3 / turnstile / hcaptcha / none adapters), resolveSecret (turns a "$ENV_VAR" reference into its value), and the TomeIntakeVerifyAdapter type
  • ./routing — routeIntake (strategies: static, round-robin, zip, product, custom)
  • ./notify — dispatchOrLog, notifyStakeholder, notifySubmitter, plus the template helpers applyTemplateTokens, defaultStakeholderHtml, defaultSubmitterHtml
  • ./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 normalisation, 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

  • @wabbit/tome-intake
  • @wabbit/tome-intake/notify
  • @wabbit/tome-intake/verify
  • @wabbit/tome-intake/routing
  • @wabbit/tome-intake/test

Changelog

v0.3.4patch

775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence 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 licence: `/*! @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 behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence 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.

  • 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence 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 licence: `/*! @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 behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence 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.
v0.3.3patch

3bba98a: Internal refactor: collection slugs are now typed through the shared `typedSlug()` helper instead of inline casts. No API or behaviour change.

  • 3bba98a: Internal refactor: collection slugs are now typed through the shared `typedSlug()` helper instead of inline casts. No API or behaviour change.
  • 7859c7e: `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.
  • 520bcbd: customer-facing wording: internal references removed from admin descriptions, error messages and block metadata.
v0.3.2patch

0836ef5: 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.

  • 0836ef5: 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.
  • ce3d12d: 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 honoured, 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 labelled and every unchanged row labelled. `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.
  • b01ca1f: 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. | Package | Registered | Actual | | --------------------------- | ------------------------------- | ------ | | `@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-forms` | `TOME_FORMS_VERSION = '0.1.0'` | 0.3.2 | | `@wabbit/tome-intake` | `TOME_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.
  • 73081e6: 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.
  • 670d2a1: **`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.
  • 73081e6: 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 favour 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.
  • 670d2a1: First test suites for the four packages the 2026-09-01 sale-readiness audit named as "security-relevant code with no test" (§7). No behaviour changed in marketing or intake; forms and crm ship one behaviour 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.
v0.3.1patch

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.

  • 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.
v0.3.0minor

6bc419c: 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.

  • 6bc419c: 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.
  • 36e537a: 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.
v0.1.8patch

bed3f90: 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.

  • bed3f90: 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.
v0.1.7patch

4b2f368: 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.

  • 4b2f368: 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.
v0.1.6patch

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

  • Updated dependencies [8947ff1] - @wabbit/tome-core@1.0.12
v0.1.5patch

Updated dependencies [36dc023]

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