@odla-ai/chapter
v0.31.4
Published
A leader/follower foundation for branded membership sites: shared CRM, admin, auth, payments, booking, and explicit record delivery from one defineChapter config.
Maintainers
Readme
@odla-ai/chapter
A foundation for membership sites. One defineChapter({...}) config resolves
the shared application engine — join/apply → Stripe membership → Google booking
→ member area — plus an admin console and CRM, or an admin-only hub, on
odla-db + Clerk + @odla-ai/crm + calendar +
email. The host still builds the public pages, routing, and brand presentation;
Chapter supplies their application mechanics.
npm install @odla-ai/chapterAgentic experiment. Built and maintained by AI agents from bounded runbooks with human review. Review the documented guarantees before relying on it.
Pre-1.0. The member surface, operational worker, standard admin console, and leader → follower record delivery ship. APIs may still tighten before 1.0; commit the lockfile and run schema/route contract tests after every update.
Ask the runbooks first. odla's operational procedures live in a database, not in this file:
npx @odla-ai/cli runbook ask "<question>"returns the current steps, and unlike anything written here it cannot be out of date. Use it before searching the web or working from memory. This README and the JSDoc in the shipped.d.tsare the version-matched API reference; a runbook is the procedure. Most tasks need an answer from both.
Agent runbooks
The API examples below are not a website-build plan. An LLM or coding agent must choose and read one of these version-matched runbooks from the installed npm package before changing a site:
- Empty repo or a genuinely new product: read
runbooks/greenfield.mdcompletely. It covers the brand brief, public information architecture, Chapter configuration, member/admin surfaces, Cloudflare/ODLA setup, and visual + journey acceptance. - Existing or deployed site: read
runbooks/adopt-existing.mdcompletely. It preserves the existing product, URLs, visual identity, data, auth, jobs, and rollback boundary while Chapter becomes the primary implementation.
Do not combine the two flows. A provisioned canary is not a completed adoption, and a greenfield build does not need migration machinery. Both runbooks keep secrets out of source and require development proof before production.
The repository also contains a build-tested generic Preact reference host. It is the executable companion to the greenfield runbook: site-owned global navigation, public routes, brand, voice, join fields, and member framing wrap Chapter-owned application, admin, CRM, payment, and scheduling behavior.
The shape
One config, two profiles.
defineChapter()validates at import and returns a resolved engine.mode: "chapter"enables the complete public-member application profile;mode: "hub"is admin-only and CRM-focused. The mode gates the member/join/payment route surface; everything else (auth, CRM, chrome, provisioning) is shared.chapter.brandandchapter.networkare resolved from this same config, so the browser UI does not need a second identity or follower registry.The worker is the package.
chapterWorker({ chapter })is the whole CloudflareExportedHandler. It serves/api/health,/api/config,/api/me,/api/crm/*,/api/network/{shared,snapshot,records}; configured leaders also get/api/admin/network/{targets,push,rollup,records,notes}. An enabled leader formation intake adds/api/formation/{config,applications}. Chapter mode adds the public member surface (/api/join-config,/api/join/resume,/api/applications,/api/schedule/{slots,book},/api/payments/subscription,/api/webhooks/stripe). The/api/admin/*handlers are registered in both modes; expose only the sections compatible with the selected profile and its provisioned namespaces. The Worker then falls back to static assets. Yoursrc/worker.tsis ~3 lines. Observability is a host concern — wrap it withwithObservabilityfrom@odla-ai/o11y. Unknown/api/*paths terminate as JSON404responses rather than falling through to an SPA document. Operational values that owners may change (prices, policy copy, email templates, scheduling rules) are read at runtime from a single odla-dbgroupsrow. Brand identity and build-time tokens remain in the checked-in Chapter config.A route seam, not a black box.
chapterWorker({ chapter, routes })runs your handlers before the built-ins (add routes, or override/alias a path), each receiving the same context the built-ins get. The worker entry also exportscreateWorkerContext+ theWorkerContext/Routetypes, so a wrapping worker reuses chapter's JWT verify, db client, and role resolution (verifyUser/makeDb/roleFor/isAdmin) instead of duplicating them.Source-aware auth. A JWT role-claim ladder (
provisional → member → admin) or an odla-dbadminsallowlist, plus a read-onlysuperAdminstier — selected byauth.source, defaulting per mode. Escalation guards (canChangeRole) are package-enforced.Correctness is packaged, not per-site. Replay-deduplicated email (
sendTemplated/isAlreadySent; concurrent sends still need a serialized outbox/provider idempotency for a true exactly-once guarantee), a non-prod delivery fail-safe (planDelivery), stable application replay identity (one UI submission key and the same resumable canonical id), status-never-backwards (canTransition), Stripe webhook integrity (verifyStripeSignature) with the webhook as the authoritative writer of paid/refunded, one-subscription-per-application idempotency, meetings-as-canonical booking (a rebooking reschedules the event, preserving the Meet link), Google-edit adoption (reconcileMeetings), and a one-way CRM projection.Provisioning is declarative.
createChapterIntegration(chapter)composes the crm namespaces + the chapter namespaces (applications,groups,meetings,emailLog, plus the auth tables) + a guarded group-row seed. Drop it inodla.config.mjsintegrations: [...].Network movement is explicit. A leader lists follower origins and per-target field allowlists in
network.targets; secret names live in config and secret values stay in the tenant vault. The standard record panel then shows “Share with …” actions for compatible people, businesses, deals, or other CRM types. The receiver validates the payload against its own CRM config and stores a structured origin row, so retries update the same local record. Delivery state is structured too; display tags remain a migration and cohort affordance rather than the machine identity contract. Shared identity/business fields move; each site's pipeline, account, and billing state remain authoritative locally.The UI kit, adoptable in pieces. Three entries, split on the dependency boundary so you never pay for what you don't use:
@odla-ai/chapter/ui/member— Preact-only:SlotPicker, the date/timezone helpers,JoinIsland(form → payment → booking),MembersArea,Rescheduler,PaymentStep,<BrandStyle>. It never imports@odla-ai/auth-clerkor@odla-ai/crm, so you can adopt a single presentational component without the Clerk browser SDK. (A bundle-graph test enforces this.)@odla-ai/chapter/ui/admin— the Clerk-gated consoleChapterAdmin+ the workspace catalog. In chapter mode,<ChapterAdmin chapter={chapter}/>defaults to just three top-level workspaces: Dashboard, People, and Settings. Operational detail belongs in nested page tabs; CRM record operations belong in record tabs. Passworkspacesto transform or replace that information architecture. This entry needs auth-clerk + crm/ui.@odla-ai/chapter/ui— the full barrel, for back-compat.
Authored and shipped against Preact. Brand tokens (
brandTokens/<BrandStyle>) re-skin all of it frombrand(now light and dark, viabrand.palette+brand.paletteDark).Voice is part of the brand.
copyis a recursively partialChapterCopy;defineChapter()resolves it into a completechapter.copycontract. Packaged join, member, and admin surfaces read that contract, so a follower can own terminology and tone without forking behavior. Text remains serializable; use render slots when the host needs markup or a different composition.The host owns global navigation. Admin defaults to
chrome="embedded"and three familiar workspaces. Dashboard/Billing and Calendar/Email remain link-backed page tabs; record operations remain record tabs. A host can keep one site header around public, join, member, and admin routes without Chapter introducing another top-level navigation model.
Theme tokens and scoped branding
The UI reads its colors from --ui-* design tokens whose values come from a
theme layer, not @odla-ai/ui/index.css. For an admin surface isolated from
the public site, import one scoped theme, the shared component sheet, and the
CRM layout:
import "@odla-ai/ui/fonts/plex.css";
import "@odla-ai/ui/themes/paper/scope.css";
import "@odla-ai/ui/index.css";
import "@odla-ai/crm/ui.css";ChapterAdmin guards against this at runtime: if a .panel has no background
(the theme layer is missing), it renders a loud red banner at the top of the
console instead of failing silently. Set brand.theme, brand.colorScheme,
and semantic brand.tokens / brand.tokensDark for normal configuration.
brand.palette / brand.paletteDark remain the low-level custom-property
escape hatch. Chapter scopes these overrides to [data-chapter-admin], so an
admin brand cannot recolor the document root or vendor sign-in UI.
brand.accent selects a named data-ui-accent family supplied by the chosen
theme. Each AdminWorkspace may set its own accent for a scoped page-level
variation. For a fully custom palette, compile the brand book through the pure
token entry and adapt it structurally:
import { compileBrandTokens } from "@odla-ai/brand/tokens";
import { chapterBrandFromTokens } from "@odla-ai/chapter";
const compiled = compileBrandTokens({ swatches });
const brand = chapterBrandFromTokens(compiled, {
theme: "paper",
wordmark: "Example Chapter",
});This carries the compiler's complete light map, derived dark map, and invert
map into Chapter without adding an @odla-ai/brand runtime dependency to
Chapter itself.
API quick start
// src/chapter.config.mjs
import { defineChapter } from "@odla-ai/chapter";
export const chapter = defineChapter({
id: "example-chapter",
name: "Example Chapter",
mode: "chapter",
brand: {
badge: "EX",
wordmark: "Example Chapter",
tagline: "Capital and craft for durable local businesses.",
theme: "paper",
colorScheme: "light",
tokens: {
accent: "#2f6f4f",
accentStrong: "#244f3b",
accentSoft: "#dfece4",
},
fonts: { display: "GT Sectra" },
},
copy: {
join: { form: { submit: "Start the conversation" } },
admin: { workspaces: { people: "Community" } },
},
prices: { standardCents: 100000, foundingDiscountCents: 10000 },
emails: { notificationEmail: "[email protected]" },
account: "none",
});// src/worker.ts
import { chapterWorker } from "@odla-ai/chapter/worker";
import { chapter } from "./chapter.config.mjs";
export default chapterWorker({ chapter });// src/app/admin.tsx — brand + familiar workspaces come from the same config.
import "@odla-ai/ui/fonts/plex.css";
import "@odla-ai/ui/themes/paper/scope.css";
import "@odla-ai/ui/index.css";
import "@odla-ai/crm/ui.css";
import { render } from "preact";
import { ChapterAdmin } from "@odla-ai/chapter/ui/admin";
import { chapter } from "../chapter.config.mjs";
render(<ChapterAdmin chapter={chapter} />, document.getElementById("admin-root"));// odla.config.mjs
import { createChapterIntegration } from "@odla-ai/chapter";
import { chapter } from "./src/chapter.config.mjs";
export default {
app: { id: chapter.id, name: chapter.name },
services: chapter.services,
integrations: [createChapterIntegration(chapter)],
};That is the reusable application shell. Public pages remain site-owned: start
from the approved product and brand brief plus @odla-ai/ui marketing
components. Do not copy a reference site's identity or fork auth, admin routing,
CRM, payment, booking, or account logic to achieve a different brand.
Join and member composition
JoinIsland keeps application mechanics while exposing presentation seams:
<JoinIsland
config={joinConfig}
renderStepHeader={({ state }) => <FlowHeading step={state.step} />}
renderSubmit={({ disabled, submitting }) => (
<BrandedSubmit disabled={disabled}>{submitting ? "Sending…" : "Apply"}</BrandedSubmit>
)}
renderDone={({ booked, membersHref }) => (
<Confirmation booked={booked} membersHref={membersHref} />
)}
payment={{
appearance: stripeAppearance,
fonts: stripeFonts,
renderPriceLines: (lines) => <PriceSummary lines={lines} />,
}}
>
<ApplicationFields />
</JoinIsland>initialState accepts trusted server state. On a browser redirect,
JoinIsland reads the application capability from the query string and asks
GET /api/join/resume for the canonical payment, booking, or done state; raw
redirect_status and reschedule values never choose a UI step. Supply
loadResume when a host stores the capability elsewhere.
MembersArea.renderProvisional receives the loaded application, authenticated
API function, reload callback, apply URL, and defaultContent, so the host can
wrap or replace the provisional card just as admin workspaces can be composed.
Nested admin/page/record tabs use fragment anchors by default, not query-string
state.
Leader portfolio and formation
leaderCrmConfig() composes generic portfolio records into an existing CRM
config without replacing its people, businesses, pipelines, templates, or
relations. Duplicate ids fail at startup:
import { defineChapter, leaderCrmConfig } from "@odla-ai/chapter";
const existingCrmConfig = {
types: {
person: {
label: "Person",
labelPlural: "People",
nameField: "name",
emailField: "email",
fields: {
name: { type: "string", required: true },
email: { type: "email" },
},
},
},
};
export const chapter = defineChapter({
id: "example-hub",
name: "Example Hub",
mode: "hub",
crm: leaderCrmConfig({ base: existingCrmConfig }),
formation: {},
network: {
targets: [{
id: "example-chapter",
name: "Example Chapter",
url: "https://chapter.example.com",
fields: { person: ["name", "email"] },
}],
},
});The leader may make chapter its own durable aggregate while retaining the
preset's required identity and relations. chapter.fields and
chapter.facets merge with the defaults; chapter.pipeline replaces the
generic proposed/forming/active lifecycle. The final configuration is still
validated by defineCrm():
crm: leaderCrmConfig({
base: existingCrmConfig,
chapter: {
fields: {
marketKey: { type: "string", label: "Market key", slot: "s3" },
signalScore: { type: "number", label: "Signal score", slot: "n1" },
},
pipeline: {
stages: [{ id: "investigation" }, { id: "growth" }],
transitions: { investigation: ["growth"] },
},
},
}),The preset adds chapter, chapter_application, and deal record types in
the portfolio workspace, with formation, operating, and deal-flow pipelines.
It also composes relations to base person and company types when present.
These are ordinary CRM records: the host may add fields, types, render slots,
and relations by composing its own config before passing it to Chapter.
formation: {} opts into a host-rendered public application:
GET /api/formation/configreturns only configured public field metadata.POST /api/formation/applicationsaccepts only those fields and creates a normalchapter_applicationCRM record.- A stable base64url
submissionIdmakes ambiguous retries resolve to the same record. Hosts must generate one per form journey and retain it until the response is known. - Request size, string length, required fields, and public field names are
bounded by
formation; internal fields such as notes are not accepted.
Formation is off unless configured. Chapter intentionally does not ship a public form design: the site renders the fields in its own voice and brand.
For a hub with network targets, the standard admin catalog is Dashboard,
Network, People, Portfolio, and Settings. Dashboard Overview renders aggregate network
health; People contains CRM types whose workspace is absent or "people";
Portfolio contains types marked workspace: "portfolio". Operational details
remain nested page tabs and record operations remain record tabs. A
workspaces transform can preserve or extend this catalog just like the
standard follower console.
Follower records, private notes, and shared notes
A follower must opt in before a leader can browse any of its CRM records. The follower owns both the signed-reader allowlist and the exact fields that may leave:
export const chapter = defineChapter({
id: "example-chapter",
name: "Example Chapter",
account: "none",
prices: { standardCents: 100_000 },
emails: { notificationEmail: "[email protected]" },
network: {
readers: [{
id: "example-hub",
fields: {
person: ["name", "email", "firstName", "lastName", "phone", "linkedin"],
},
// Separate write consent: browsing never implies note permission.
sharedNotes: ["person"],
}],
},
});GET /api/network/records requires the edge HMAC and rejects signed senders not
listed in network.readers. Results are capped at 50 records, offset-paged,
search-bounded, and projected to the reader's field list. Pipeline stage and
record timestamps are the only metadata outside that list; application
narrative, billing, account state, activities, and notes do not cross unless
the follower explicitly names a corresponding CRM field.
The leader's authenticated GET /api/admin/network/records proxy signs the
follower request server-to-server, so neither the browser session nor the
vaulted edge secret crosses sites. The Network detail view has two deliberate
lanes:
GET/POST /api/admin/network/notesstores private annotations in the leader's deny-allnetworkNotesnamespace. They are keyed to the follower's stable target/type/record identity and never leave the leader.GET/POST /api/admin/network/shared-notescrosses the signed edge only when both the leader target and follower reader declaresharedNotesfor that record type. The follower appends the note to its normal CRM activity feed, where its own admins see it. The leader can read back only notes that it shared; follower-private CRM activity is never returned.
Leader → follower delivery
The leader declares where records may go and exactly which fields each follower receives:
export const chapter = defineChapter({
id: "example-hub",
name: "Example Hub",
mode: "hub",
network: {
targets: [{
id: "example-chapter",
name: "Example Chapter",
url: "https://chapter.example.com",
binding: "EXAMPLE_CHAPTER",
fields: {
person: ["name", "email", "firstName", "lastName", "phone", "linkedin"],
company: ["name", "domain", "industry", "location", "linkedin"],
},
sharedNotes: ["person"],
}],
},
});Treat the network as websites connected by explicit delivery edges:
- each website is a node with its own
appId, ODLA tenants/keys, CRM config, Clerk application, publishable keys, issuer, roles, and local users; - each
network.targets[]entry is a directed edge from one leader to one follower:{ id, name, url, binding?, secretName?, fields }; - the payload is a versioned, allowlisted business record
{ version: 2, source: { siteId, recordId }, type, input }, never an identity-provider session, another site's pipeline/account/billing state, or an ODLA admin key; - retries address the follower record by leader provenance, so the same edge updates rather than duplicates.
The authorization chain has three separate hops. The leader's browser sends
its own Clerk session only to the leader's admin route. The leader Worker
authorizes that local operator, reads the edge's share secret from its own
vault, and signs the exact method, path/query, sender id, timestamp, nonce, and
body digest. The follower validates that HMAC envelope against its vaulted
copy, requires the signed sender to match the v2 payload source, validates the
payload against its own CRM config, and writes with its own server-side
ODLA_API_KEY. The share secret itself is never sent. A follower Clerk token
is never needed, the leader never receives the follower's database key, and
sites do not share a Clerk application.
When both sites are Cloudflare Workers, configure binding and add the matching
service binding to the leader's Wrangler environment. Cloudflare does not
reliably dispatch same-account workers.dev subrequests into the target
Worker; Chapter therefore signs against url but sends through the bound
Worker:
{
"env": {
"dev": {
"services": [{
"binding": "EXAMPLE_CHAPTER",
"service": "example-chapter-dev"
}]
}
}
}The example assumes the follower sets its development Worker name explicitly
to example-chapter-dev. If it instead uses Cloudflare's named-environment
addressing, declare the base service plus "environment": "dev". The binding
name is public configuration, not a credential. If a configured binding is
missing at runtime, the target fails explicitly instead of falling back to a
potentially misrouted public fetch.
Vault the same random value as network_share_secret in the follower and as
network_share_example_chapter in the leader (or set a different
secretName). No secret is placed in source, browser data, or provisioning
config. The standard collection drawer discovers the target and calls the
admin-gated push route. A follower must declare the receiving CRM type/fields;
otherwise it rejects the record cleanly instead of dropping fields.
The signed v2 protocol is the contract for new integrations. The follower temporarily accepts the old bearer/v1 envelope so an existing edge can be upgraded without downtime; new leaders must not use it.
Every received v2 record gets a crm_record_origin row keyed by the upstream
site and record id. Every push attempt gets a crm_record_delivery row keyed by
the local record and target, including attempt count, current status, payload
version, remote record id, and bounded latest error. Record detail responses
include both collections and the standard record drawer adds a Network tab
when either exists.
GET /api/network/snapshot is an HMAC-signed, aggregate-only follower endpoint.
It returns type totals and pipeline-stage counts, never record fields, people,
sessions, Clerk tokens, share secrets, or ODLA keys. The leader's admin-gated
GET /api/admin/network/rollup fans out to every configured target, verifies
that each returned site id equals the target id, and returns combined totals
plus explicit per-target failures. Consequently each target id must equal the
follower's chapter.id.
Existing follower CRMs must opt into the shared graph
When crm is omitted, chapter's default already contains compatible person
and company types plus a works_at relation. Passing a custom CRM replaces
that default; chapter does not merge missing types or fields into it. A follower
that wants both people and businesses must therefore declare compatible types
itself:
import { defineCrm } from "@odla-ai/crm";
const crm = defineCrm({
types: {
person: {
label: "Person",
labelPlural: "People",
nameField: "name",
emailField: "email",
fields: {
name: { type: "string", label: "Name", required: true },
email: { type: "email", label: "Email" },
firstName: { type: "string", label: "First name" },
lastName: { type: "string", label: "Last name" },
phone: { type: "string", label: "Phone" },
linkedin: { type: "string", label: "LinkedIn" },
},
},
company: {
label: "Business",
labelPlural: "Businesses",
nameField: "name",
fields: {
name: { type: "string", label: "Name", required: true },
domain: { type: "string", label: "Domain / website", slot: "s1" },
industry: { type: "string", label: "Industry" },
location: { type: "string", label: "Location" },
linkedin: { type: "string", label: "LinkedIn" },
notes: { type: "string", label: "Notes" },
},
},
},
relations: {
works_at: {
from: "person",
to: "company",
label: "works at",
reverseLabel: "team",
},
},
});The follower must declare every field its leader may send. Unknown types or
fields fail the request before any CRM write. Record delivery currently moves
records, not crm_link relation rows; create or curate works_at links locally.
Custom leader consoles must mount the sharing UI
Automatic “Share with …” actions come from Chapter's standard People workspace.
They are present when the console uses <ChapterAdmin chapter={chapter} />.
Passing a workspaces array replaces that catalog, so a custom console must
deliberately compose it:
import {
ChapterAdmin,
defaultAdminWorkspaces,
} from "@odla-ai/chapter/ui/admin";
const workspaces = [
customOperationsWorkspace,
...defaultAdminWorkspaces(chapter),
];
render(
<ChapterAdmin chapter={chapter} workspaces={workspaces} />,
document.getElementById("admin-root"),
);If the custom console renders its own record drawer instead, mount
NetworkShareActions inside that drawer:
<NetworkShareActions
recordId={record.id}
recordType={record.type}
getToken={sectionContext.getToken}
/>The component discovers compatible targets through
GET /api/admin/network/targets; it never receives follower secrets in browser
data.
Verify delivery in development before production
Use distinct development tenants and follower origins for the first delivery:
- Confirm every target
idexactly equals the follower'schapter.id. For Cloudflare Workers, confirm itsbindingexists in the leader's development Wrangler environment. Vault one random value asnetwork_share_secretin the follower and under the target's resolvedsecretNamein the leader. - Confirm
GET /api/admin/network/targetslists the development follower with the expected compatible record types. - Share one test person and one test business from the leader's record drawer. Confirm each appears in the follower with only allowlisted fields.
- Share each record again. The second delivery must update the same follower record, not create a duplicate.
- Confirm the follower's pipeline, account, and billing state did not change; those remain locally authoritative.
- Change one allowlisted leader field and share again to prove later deliveries
update the existing record. A target with the wrong secret must return
401without writing CRM data. - Prove each browser JWT is accepted only by its own website. Inspect the
network request and client bundle to confirm neither site's Clerk token nor
either
ODLA_API_KEYcrosses the delivery edge, and confirm the share secret itself is absent from the request. - Load
GET /api/admin/network/rollupthrough the leader session. Confirm aggregate totals, then break one development edge and confirm that target is reported unavailable without hiding healthy targets.
Only after this contract passes against development origins should the leader target be changed to a production follower origin and the matching production vault values be installed.
Admin navigation defaults to link-backed fragments:
/admin/#people/person/record-id/profile. The four segments are workspace,
nested view, selected record, and record-detail tab. This keeps meaningful,
reloadable URLs without requiring a server-side SPA fallback, and it avoids
competing with Clerk's sign-in hash: while signed out, Chapter carries the
requested state through the redirect query and canonicalizes it after sign-in.
Legacy ?tab= and path links still open, while routing="query" and
routing="path" remain compatibility modes.
Admin information architecture and migration
The default follower console deliberately has only three top-level workspaces:
- Dashboard — Overview and Billing page tabs.
- People — configured CRM collections as page tabs, then a master/detail record view with Stage, Profile, communications, scheduling, Billing, Notes, Connections, Access, and Sharing record tabs when those capabilities apply.
- Settings — Calendar and Email page tabs.
A leader hub with configured targets adds Portfolio between People and Settings and uses Dashboard Overview for the aggregate network rollup. A hub without targets retains the minimal People-only default. These defaults compose the same grammar; they do not introduce a second admin shell.
The default chrome="embedded" assumes the host supplies the site header.
Use chrome="standalone" for Chapter's packaged masthead or renderHeader for
a custom header rendered inside the Chapter theme boundary. editorial,
none, topbar, and sections remain compatibility APIs. Do not add
operational detail back to the global site navigation.
Only the active workspace, page tab, and record tab mount their content. Inactive operational panels therefore do not fetch, subscribe, or run effects. Their native anchors remain in the document, so every level is still reloadable and participates in browser back/forward navigation.
Preserve an existing People structure
The responsive state and data loading belong to @odla-ai/crm; the branded
summary, list presentation, record heading, and specialized panels can remain
host-owned:
For an operating site, first preserve the validated workspace whole. A matching
people id or “People” label says where to compose; it does not establish
feature parity:
const workspaces = (defaults) => {
if (!defaults.some((workspace) => workspace.id === "people")) {
throw new Error("expected ChapterAdmin people workspace");
}
return defaults.map((workspace) =>
workspace.id === "people"
? { ...workspace, render: (ctx) => <ExistingPeople context={ctx} /> }
: workspace
);
};
<ChapterAdmin chapter={chapter} workspaces={workspaces} />;Match the exact people id and fail closed if Chapter changes its catalog;
matching only the displayed label can silently replace the wrong workspace.
This keeps Chapter's reviewed workspace metadata and surrounding shell while
the host retains its summaries, exploration model, role signals, record
operations, and state handling. Replace that adapter incrementally only after
the corresponding behavior has an executable parity check. A greenfield site
can begin with Chapter's defaults because there is no operating workspace to
preserve.
const people = collectionSection({
crm: chapter.crm,
type: "person",
lifecycle: true,
collapseClosedDetail: true,
renderSummary: ({ query }) => (
<PeopleSummary total={query.page?.total ?? 0} />
),
renderMaster: ({ defaultMaster }) => (
<PeopleRail>{defaultMaster}</PeopleRail>
),
renderDetailHeader: ({ detail }) => (
<RecordHeading record={detail.record} />
),
defaultDetailTab: "overview",
renderDetail: (context, defaultDetail) => (
<RelationshipProfile record={context.detail.record}>
{defaultDetail}
</RelationshipProfile>
),
extendRecordTabs: (tabs, context) => replaceRecordPanels(tabs, context),
});collapseClosedDetail defaults to true: while no record is selected, the
collection list uses the full workspace width; after selection, it becomes
master/detail. Set it to false only when the empty detail pane contains
intentional, useful content. This is an operational usability rule, not a
request to preserve an old admin's cramped columns. renderMaster and
renderSummary customize content; they do not control pane geometry.
renderMaster receives the loaded query, selection helpers, native record-link
builder, and defaultMaster, so a host may wrap the standard list or replace
its presentation without taking over fetching. renderDetail receives the
fully composed default record panel and can wrap or replace the complete detail
experience without taking over CRM state. defaultDetailTab controls the tab
selected by native record links. extendRecordTabs supports per-record
visible and disabled predicates. Hidden panels do not mount.
For lifecycle: true, Chapter routes stage transitions through the
application-authoritative approve/refund/manual-transition endpoints and
requires that adapter. A custom operational collection can pass
lifecycleAdapter; generic CRM collections continue to use the CRM mutation.
Adapt existing authentication routes
An existing site does not need to rename its config or current-user endpoints:
<ChapterAdmin
chapter={chapter}
auth={{
configPath: "/api/auth/config",
mapConfig: (body) => ({
publishableKey: typeof body.publishableKey === "string"
? body.publishableKey
: null,
}),
mapCurrentUser: (body) => ({
...body,
authorized: body.role === "admin" || body.superAdmin === true,
}),
}}
/>loadConfig and loadCurrentUser may replace fetching entirely. The normalized
current-user object is also available to workspace renderers for presentation
decisions; server authorization remains authoritative.
When upgrading a flat console:
- Import the scoped UI theme,
@odla-ai/ui/index.css, and@odla-ai/crm/ui.css. - Remove top-level Billing, Email, Calendar, or collection links that duplicate the standard nested tabs.
- Replace
sectionswithworkspaces, or omit it to accept the defaults. - Move host-owned summaries, list presentations, and record headings into
collectionSectionrender slots; compose specialized operations withextendRecordTabs. - Connect application-backed stage changes through
lifecycleAdapter; never replace an operational transition with a raw CRM stage write. - Change generated links to
adminRouteHref; keep legacy query/path URLs only as inbound compatibility links. - Verify refresh, back/forward navigation, keyboard tab behavior, mobile list/detail switching, and brand containment before deleting old routes.
Adopting into an existing site
For the complete ordered conversion and cutover procedure, use
runbooks/adopt-existing.md. The notes below
are the package-specific behavior reference, not a complete adoption plan.
A real conversion (the site this was extracted from) went from a 2,094-line worker to 6 lines and deleted ~2,500 lines. The order that worked:
- Config first, assert parity BEFORE deleting anything.
defineChapter()your site, then diffchapter.schemaagainst your existing schema in a test and require byte-equality. That single assertion is what makes the deletion safe rather than hopeful. - Freeze the old schema as a test fixture (e.g.
test/fixtures/legacy-schema.mjs) and keep asserting against it, so an upstream default change fails a test instead of a liveprovision. It is the only durable guard against drift in a generated schema. - Swap provisioning —
createChapterIntegration(chapter), inert until the next provision run. It supplies schema + rules + seeds, so yourodla.config.mjsdbblock goes away entirely. - Then the worker, keeping every bespoke route as a host route
(
chapterWorker({ chapter, routes })). Don't hand routes to chapter in the same change as the framework swap. - Override rather than inherit wherever local behavior was a decision.
Behavior deltas to audit
These bite silently — a smoke test won't catch them:
Application fields generate the application schema. The resolved
application.requiredandapplication.optionallists now control both submit validation and theapplicationsentity emitted bydefineChapter(). Built-in fields retain their types and indexes; site-defined fields become string attrs. A field removed from the reference form no longer remains accidentally required atdb.transact, and a field listed in both arrays is rejected at definition time. Keep package-owned operational attrs such asstatusout of both lists.Per-field caps.
application.defaultMaxLenis 2000. If your form accepts longer input, passmaxLenexplicitly or the default starts rejecting it.servicesdefault is["db","calendar","o11y"];smokecompares config against the platform, so setservicesexplicitly if you don't run the o11y collector.Which email fires from which route. Content and addressing are owner-editable on the
groupsrow; the trigger is config:| Template | Fires from | Configurable | |---|---|---| |
adminNotification|POST /api/applications(submit) — or the Stripe webhook (first payment) |sends.adminNotification: "submit" \| "payment" \| "never"(default"submit") | |prepEmail|POST /api/schedule/book| templateenabledflag | |paymentConfirmation| Stripe webhook, first successful invoice | templateenabledflag |defineChapter({ /* … */ sends: { adminNotification: "payment" } });Re-check this on every chapter upgrade. Wiring a send changes a site's outbound mail with no local diff — release notes call out send changes explicitly for that reason.
Account model is an explicit chapter-mode decision.
account: "create"makes the Clerk account server-side (so join can say the account is ready),"invite"emails the applicant a Clerk invitation, and"none"provisions nothing.defineChapter()rejects a chapter-mode config that omitsaccount, so forgetting the decision cannot silently disable or enable account provisioning. Both side-effecting models needclerk_secret_keyin the tenant vault. Hub mode resolves to inert"none".What lands on the Clerk account.
public_metadatais reserved for small browser/session-readable authorization claims such asrole. Chapter writesapplicationIdand the explicitly selectedprofilefields to backend-onlyprivate_metadata;application.profileFieldsdefaults to[]. Treat that copy as a bounded account/admin snapshot—the application row and CRM remain canonical. Array fields (focus) are clamped tomaxArrayLen(default 100) and non-primitive elements are dropped."create"writes the private snapshot with the account. Clerk application invitations cannot carry private metadata, so"invite"completes the write on the accepted account's first/api/merequest. A one-time application marker prevents repeated Clerk API writes. That repair also removes Chapter's formerapplicationIdandprofilekeys from public metadata without disturbingrole.applicantProfile(chapter, fields)is exported to assert the exact shape in a test before deleting a local override.Email + input validation. The field literally named
emailis checked against a permissive email shape (400 on"notanemail", so it fails cleanly here rather than at the downstream Clerk create) — setapplication: { validateEmail: false }to opt out;isValidEmailis exported. A valid application is never newly rejected.CRM enrichment.
projectApplicantwrites the base identity/contact person. To carry more of the application into the CRM, listapplication: { crmFields: [...] }. Each name is cross-checked against your crmpersontype atdefineChapter()time and throws if it isn't declared there — an undeclared field would otherwise be dropped by the projection while the base person still lands, making a typo invisible. (The runtime projection still falls back to the base person if a write fails, so an applicant is never lost.) Stage mirroring, billing snapshots and Clerk-identity linking stay yours as host routes.Disclaimer acknowledgement. A truthy
disclaimerAckon the submit body (boolean or the string a plain form posts) stampsdisclaimerAckAtfrom the server clock; no ack leaves the attr absent, and a client-supplieddisclaimerAckAtis ignored.Unlike most data loss this is unreconstructible — you cannot later determine whether someone checked a box — so the submit response always echoes
disclaimerAckAt(a number, ornullwhen nothing was recorded). Assert it in one integration test and a join page that quietly stops posting the flag can't go unnoticed.requireDisclaimerAcknow defaults totrue, and a submit with no ack is a 400 instead of a row with no consent. Set it tofalsedeliberately only when the site renders no consent control. Existing adopters must test this before cutover.CRM projection points. chapter projects the person on application submit (
projectApplicant), not on booking or on webhook status change. If you mirror pipeline stage into the CRM, keep those routes.Route contracts, not route names. Chapter serves
/api/config,/api/join-config, etc. Alias a legacy URL with a host route only after method, auth, request, response shape, units, status, and header parity are proven. Equal values with different JSON contracts are not compatible. For a join flow, adoptJoinIslandend to end or keep an explicit tested adapter; do not merely repoint an existing page at/api/join-config.PaymentStepobtains Stripe'sclientSecret,publishableKey, andlineItemsfromPOST /api/payments/subscription, not from the public join-config response.
Install + scope notes
Bootstrap the first admin according to the selected auth source. Chapter mode defaults to Clerk claims: set the first operator's Clerk
public_metadata.roletoadmin. If that operator also needs the read-only cross-site tier, add their lowercase email tosuperAdminsin odla Studio. Hub mode defaults to table auth: add the first operator's lowercase email toadminsin Studio. Neither allowlist is ever written by a worker route or provisioning seed; that is deliberate, so the running app cannot grant itself admin. Confirm the result by signing in, not merely by inspecting the row.@odla-ai/auth-clerkis an optional Chapter peer. Only one entry imports it: the worker verifies JWTs withjoseviactx.verifyUser, and@odla-ai/chapter/ui/memberdoes not load the auth package. Install@odla-ai/auth-clerkyourself if (and only if) you adopt@odla-ai/chapter/ui/adminor want the themed sign-in components; reach for@odla-ai/auth-clerk/invitationswhen you want to send your own branded invitation mail. Importing the full@odla-ai/chapter/uibarrel pulls the admin half, so prefer the narrower entry.Use normal dependency declarations during active development. Install Chapter, its selected peers, and the Preact host toolchain without exact pins. Commit
package-lock.json, usenpm cifor repeatable installs, recordnpm lsoutput in PM evidence, and rerun conformance after updates. The package manifest's dependency and peer ranges are the compatibility contract.--legacy-peer-depsis a diagnostic, not a setting. It suppresses exactly the peer conflict that tells you a pair is unsupported. If you need it, find out why first.The admin operational surface ships (0.16.0).
/api/admin/*is now a full membership-operations API, admin-gated (verifyUser+isAdmin):- Roster/identity:
GET /people(union of$users+ applications by email, roles and backend-only profile signals fromclerkListUsers),GET /people/access,POST /people/role,POST /crm/sync(backfill/reproject), andPOST /clerk/private-profiles/sync?offset=0&limit=50(bounded, resumable migration/repair from Chapter's former public profile keys). - Pipeline/meetings:
GET /dashboard(flow counts, stage counts + weekly delta, agenda, live revenue),GET /meetings(reconciled agenda),GET/PUT /scheduling,POST /meetings/:id/{reschedule,cancel},PATCH /applications/:id. - Money:
GET /billing(applications ⋈ live Stripe subs;truncatedflags a100 page instead of losing rows;
billingReady:falsewhen no Stripe key is vaulted),POST /applications/:id/{approve,refund}. - Email:
GET/PUT /group/email,GET /email/log,POST /email/test,GET /people/:id/comms(sent mail + reconstructed calendar invitations).
Two behaviours are config, not code — the mechanics are chapter's, the decisions are yours (same model as
sends):defineChapter({ operations: { onApprove: { promoteTo: "member", send: "onboardingInvite" }, // promoteTo defaults to the rung below admin; either can be false refund: { allowedFrom: ["paid_pending_vetting"], cancelSubscription: true }, // allowedFrom validated against the pipeline }});The escalation rules (super-admin tier, self-demotion lockout) are not seams —
canChangeRolepackage-enforces them so a site can't weaken them.approveis the calleronboardingInvitewas missing.- Roster/identity:
The server-side Clerk primitives are exported too — for host routes beyond the built-in
/people/role. BesidecreateClerkUser/createClerkInvitation/canChangeRole, chapter exports the odla→Clerk write half:clerkGetUserByEmail,clerkGetUser,clerkListUsers(auto-paginated — never a silent 100-user cap),clerkSetRole,updateClerkUserMetadata, andupdateClerkUserMetadataByEmail. An absentpublic_metadata.rolereads as the lowest rung ("provisional"); normalizedprofileandapplicationIdreads come only from backend-only private metadata; andclerkSetRolemerge-PATCHes only the public role, so it cannot clobber the private profile. All take an injectablefetchand the vaultclerk_secret_key.
Verify from the types, not this file
At several releases a day, prose lags. Treat this README as intent and verify the
real surface from dist/*.d.ts and by grepping the built bundle for route
strings. One testing gotcha: Clerk session tokens expire in ~60s, so a script
that mints a JWT then runs a batch of curls must re-mint per batch.
MIT © odla
