@jini-ai/cms
v0.7.2
Published
Content-model capability for Jini-hosted products: framework-free contracts, ports, and domain services in `/core` and per-domain subpaths; Node-bound adapters in domain-specific subpaths.
Readme
@jini-ai/cms
Content-model capability for Jini-hosted products.
The source tree includes content domains and Node adapters. CMS depends on shared kernel
contracts and has no user-management dependency. Generic tool wiring is exported by
@jini-ai/core; hosts provide CMS authorization and active-principal lookup ports.
See API integration for current signatures and migration examples, and
integration-report.md for pending protected work and deferred verification.
Forms email sender
@jini-ai/cms/forms/email-sender: createFormsEmailSender({ deps }), FormsEmailSenderDeps, FormEmailAction, FormEmailActionInput.
Install the optional peer @jini-ai/platform@^0.6.15 when using this sender; that release includes
@jini-ai/platform/mail/senders. The @jini-ai/cms/forms entry loads without platform installed.
Tokens use stable field ids, never labels; built-in collisions use field.<id>.
Built-ins: site_name, canonical site_url, and ISO submitted_at; templates/hosts own formatting.
Files, hidden fields, IPs, credentials, undeclared keys, defaults and owner recipients are excluded.
Only Email fields may supply recipients; choices export option values and booleans use "true"/"false".
Configuration writes only through the injected guarded settings writer; no delivery effects occur here.
Layers
| subpath | runtime | contents |
|---|---|---|
| . / ./core | universal | Content contracts and types, ports, pure domain services, kernel registries. No Express, no node:*, no DOM. |
| ./media | node | Media services, transform ports and preview policies. |
| ./media/node | node | Filesystem blob stores, sharp image transforms and ffmpeg adapters. |
| ./settings | universal | One-release compatibility re-export of @jini-ai/core/settings. |
| ./settings/express | node | One-release compatibility re-export of @jini-ai/core/settings/express. |
| ./widgets / ./widgets/markers / ./widgets/resolvers | universal | Widget contracts, markers and resolution services; no HTML parser. |
| ./widgets/html | node | HTML scanning/substitution, fragment validation and plugin-export template resolution. |
Hosts that sanitize SVG uploads or ingest documents through ./media must install the optional peers
dompurify and jsdom. They load lazily: without them, other media still works, but SVG and Office uploads fail.
The optional jsdom peer accepts ^25.0.1 || ^26.0.0 || ^27.0.0; media uses the shared JSDOM window, DOMParser, XMLSerializer, basic DOM queries and close APIs.
Node-bound domain adapters depend on /core. The reverse is a boundary violation — if a core module wants
something from a Node adapter, the dependency is backwards and the fix is a port (interface) in core,
not a widened export.
Domain HTTP adapters live under their domain subpaths. Hosts can compose them
using @jini-ai/http-kit and @jini-ai/server.
Identity tool registration composition belongs to @jini-ai/user-management/server.
Generic settings now belong to @jini-ai/core/settings; the former CMS settings subpaths
remain thin compatibility re-exports for one release.
@jini-ai/core/settings/express exports registerSettingsRoutes, the settings HTTP
contracts and the CMS service/change-feed factories. Hosts provide route paths,
permissions, workspace, readiness, principal resolution, authorization, scheduler,
service and feed ports. Registration returns a disposer that closes active feeds.
CMS adapters pass named request objects to settings dispatch and change-feed services;
queryable index-name helpers return promises that callers must await.
Install the optional peer express when using ./settings/express.
The HTTP toolkit is a host composition choice and is not a CMS dependency.
Readiness or principal-resolution failures return 401 UNAUTHENTICATED.
Stream diagnostics are best-effort: a throwing reporter cannot prevent retries,
cancellation of other schedules or ending the response.
Plugin embeds resolve only through the node entry: hosts pass their site-scoped admission port as
ResolveHtmlPageEmbedsDeps.pluginExports to resolveHtmlPageEmbeds from @jini-ai/cms/widgets/html,
which fills templates with parse5. The universal registry never resolves plugins. A missing port or a
refused export keeps the authored fallback HTML. See the host adoption note.
Why the split exists before there is any code to split
The source of this port is an existing CMS runtime, and the boundary is drawn where a measured defect was.
There, the content model and its HTTP composition root share one src/ tree with nothing
enforcing a boundary between them. Measured 2026-08-02:
- 53 import edges point into the composition root. 22 of them are domain modules importing a
454-line
RouteDepstype whose first line isimport type { Express } from "express". - Git history shows the cost: no content module could be changed without also changing the
server.
features/themeandserverco-changed in 71% of commits touching the former;identityandserverin 45%. - 33 of 38 modules formed a single strongly-connected component — none could be extracted without dragging the other 32.
The decomposition itself was sound (propagation cost 11%, a textbook Martin instability gradient,
domain modules that essentially never co-change with each other). What was missing was
enforcement. Full analysis: ADS-memory/reports/refactors/2026-08-02-module-graph-analysis.md
in the originating repo.
A package's exports map is precisely the enforcement a single src/ tree cannot provide. A
consumer of @jini-ai/cms/core physically cannot reach a Node adapter, and /core physically
cannot import a transport type, because the module resolver refuses. Porting into this shape from
the first commit means that failure mode cannot recur here.
Port inventory
The kernel went first — the source repo's src/core/{ports,commands,events,tools} — because every
domain module depends on it. Domain modules followed in no particular order: the source repo's git
history shows they essentially never co-change with each other, so each ports independently without
coordination.
Landed (directories under src/, each with its own subpath export):
core, content-types, entries, media, navigation, presentation, settings,
taxonomy, workspace, and trash. settings is now a compatibility shim, with implementation
in @jini-ai/core/settings. ./core/tools re-exports the shared core permission helpers; ./media/import exports bounded image fetching with injected policies and ports.
./navigation stays universal: submitted menu HTML requires an injected normalizeMenuHtml
dependency (normalizeHtml for mergeMenuHtml). Item-only writes and retained markup need no
parser. Node hosts use ./navigation/node, which re-exports the navigation API and supplies
parse5 normalization to create/update, merge and tool registration. The existing validation,
raw-HTML permission checks, OCC and outbox owners remain unchanged; an unconfigured universal
HTML write fails before saving rather than persisting markup that could escape its theme container.
Still to move: post, seo, comments, redirects, newsletter,
members. Widgets are exposed through the universal and node entries above.
The nested forms package is maintained by its separate owner and is excluded from this parent package.
Explicitly not ported:
src/server/**— Express composition root; superseded by@jini-ai/http-kit/@jini-ai/server.- Admin UI —
@jini-ai/adminalready owns that surface.
When porting tests, git mv them rather than re-authoring: re-authoring costs a full pass and
still loses coverage.
Scripts
pnpm --filter @jini-ai/cms build
pnpm --filter @jini-ai/cms typecheck
pnpm --filter @jini-ai/cms testOpen cleanup
- Add coverage thresholds.
vitest.config.tsstill has none. That was a deliberate omission while the package was a placeholder; it no longer is, so the reason has expired. Set them against what the ported modules actually measure — siblings run 98–100%.
Design decisions
- Schema indexes use closed fragments and unambiguous identities.
- Content lifecycle separates new authoring from existing-entry writes.
- Settings preserve total defaults, scope fences and revision evidence.
- Blob collection rechecks references before journaled unlink.
- Taxonomy validation preserves first-failure order and full cycle checks.
- Mutation, audit record and outbox intent share a commit boundary.
- Workspace administration preserves tenancy and ownership floors.
- Navigation mutations enqueue one event only on success.
The ./trash surface owns snapshot-only listing, keyset cursors, atomic marker/index writes,
transactional follow-ups and the retention sweeper. Hosts inject their table adapters, reentrant
transaction runner, IdGenerator, entity policy, Clock and scheduler. Native timer unref and
error logging belong to the host. Actor/prior-marker/list filters are passed in the optional second
object. Purge callers own human confirmation and must authorize each resolved index row; unavailable
domains remain adapter-unavailable after authorization even when their entity policy denies them.
