@odla-ai/chapter
v0.61.0
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 and routing. Chapter supplies the
application mechanics plus an optional, versioned membership-card renderer so
a hub preview and follower site can render the same reviewed content exactly.
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. Durably claimed email (
sendTemplated/isAlreadySent; ambiguous non-idempotent provider outcomes require evidence-based reconciliation rather than blind resend), 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 verified Stripe events and refund reconciliation as the payment authority, 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,GiftPurchases,GiftCodeRedeemer,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.
Packaged workspaces are styled by Chapter, through @odla-ai/ui
Chapter owns the styling of every workspace it ships (Admission, Builder
Library, People, Settings, and the rest) and does it only through @odla-ai/ui
form components and the --ui-* tokens the active theme scope supplies. A
packaged picker is always the @odla-ai/ui Select, a native select carrying
the select class, so it takes the host's theme without any host CSS, keeps
its accessible name through its Field label, and keeps native keyboard
behaviour. A guard test in the package fails if a bare select tag reappears in
a Chapter UI source.
The boundary runs the other way too. A host must not patch package-owned
markup with its own CSS: if a packaged control looks browser-default, the theme
layer is missing (see the banner above) or the package has a bug to report,
never a selector to override. Any custom picker a host adds beside a packaged
workspace should be the same @odla-ai/ui Select (with Field for its
label) rather than a bare element, so host and package controls read as one
surface.
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 },
policy: {
merchantDisclosureText: "Processed and billed by Example Merchant on behalf of Example Chapter.",
},
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.
Set policy.merchantDisclosureText to the exact plain-language identity the
customer will see on the charge or receipt. Chapter carries that text through
the public join config and renders it before the payment consent control in
membership and gift checkout.
Join and member composition
The packaged journey (Chapter 0.59.0): inside JourneyFrame, every text
control renders at 16px or more on a touch device, above a host's own
input[type=text] rule, because iOS Safari zooms a page whose focused field is
smaller and keeps that zoom through the payment step. The booking step keeps
its button in view above the dates, inactive until a date and a time are both
chosen, with a line beneath it reading copy.booking.pickDate ("Pick a date"),
then pickTime ("Pick a time"), then nothing; the island starts the
/api/schedule/slots read while the member pays (useJoinSlotsPreload), so
the times are already on screen when payment confirms. The payment receipt
renders after each step's body, so a booked member sees their confirmation
first and the receipt beneath it.
For a centrally managed membership page, validate a bounded presentation
document against the live, server-authoritative tiers, then render it with
MembershipCards:
import {
MEMBERSHIP_PAGE_RENDERER,
membershipPageDocument,
} from "@odla-ai/chapter";
import { MembershipCards } from "@odla-ai/chapter/ui/member";
import { ThemeScope } from "@odla-ai/ui/components";
const membershipPage = membershipPageDocument({
schemaVersion: 1,
renderer: MEMBERSHIP_PAGE_RENDERER,
tiers: [
{
tierId: "steward",
variant: "supporting",
badge: "Sustains the collective",
benefits: ["Everything in **Founding Member**", "The annual retreat"],
callout: {
label: "A word of thanks",
bodyMarkdown: "*You are the reason this collective can exist.*",
},
ctaLabel: "Fund the movement",
},
{
tierId: "founding",
variant: "featured",
badge: "Most members start here",
compareAtPriceCents: 100_000,
billingLabel: "a year",
capacity: { total: 100, label: "only" },
priceNoteMarkdown: "Your founding rate is **held for as long as you stay**.",
benefits: ["Live pitch nights", "A seat in diligence"],
ctaLabel: "Join the collective",
},
{
tierId: "associate",
summaryMarkdown: "Open to everyone, anywhere",
benefits: ["Founder pitch recordings", "The monthly member letter"],
ctaLabel: "Join for free",
},
],
}, { tiers: joinConfig.tiers });
<ThemeScope tokens={approvedChapterTokens}>
<MembershipCards
tiers={joinConfig.tiers}
document={membershipPage}
applicationPath="/apply"
/>
</ThemeScope>The document controls tier order and bounded copy slots. It cannot author
prices, free/paid state, tier identity, links, CSS, or HTML; those remain live
Chapter authority. Unknown fields, missing/duplicate tiers, oversized content,
and compare-at prices that do not exceed the charged price fail validation.
Markdown is rendered by the safe renderer from @odla-ai/ui.
MembershipCards is token-native, including --ui-font-sans,
--ui-font-display, and --ui-font-numeral as well as the semantic color,
surface, border, radius, spacing, and shadow tokens. Its responsive layout and
component CSS are versioned with the renderer. A CMS preview uses
mode="preview"; the public page uses the default link mode. Both therefore
share the same component, document, tier authority, and token scope.
The same renderer can also replace the placeholder chooser inside
JoinIsland without reimplementing selection:
<JoinIsland
config={joinConfig}
renderTiers={({ tiers, selectedTierId, selectTier }) => (
<MembershipCards
tiers={tiers}
document={membershipPage}
mode="select"
selectedTierId={selectedTierId}
onSelectTier={selectTier}
/>
)}
>
<ApplicationFields />
</JoinIsland>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,
Shared application and additional-member controls
JoinIsland owns application readiness. Its default Submit button and the
disabled value passed to renderSubmit reflect required controls, native
constraints, host-provided custom validity checks, conditional fields and recipient
prefill. Use JoinSubmitButton in a custom submit slot to retain the packaged
theme and disabled styling. submitDisabled remains an additional host gate.
Use AdditionalMemberFields on Apply and payment.additionalMember for the
packaged editor. Supply copy, application field names and theme tokens; do not
reimplement validation, save/error handling, or action spacing in the host.
Declare the same conditional name/email requirements in the server application
field contract. The quote remains authoritative for price and saved details.
const memberOptions = {
copy: { label: "A membership for your mother, daughter, or sister" },
names: { selected: "familySelected", name: "familyName", email: "familyEmail" },
};
const [member, setMember] = useState({ selected: false, name: "", email: "" });
<JoinIsland config={config} payment={{
initialNamedSeat: additionalMemberSelection(member) ?? undefined,
additionalMember: { ...memberOptions, onSaved: setMember },
namedSeatLabel: "Additional family member",
}}>
{/* Other site application fields */}
<AdditionalMemberFields {...memberOptions} value={member} onChange={setMember} offer={selectedTier?.seatOffer} />
</JoinIsland>Import these controls from @odla-ai/chapter/ui/member. The editor uses Save
details for both included and paid members, a separate Cancel action, retryable
errors, and disabled controls while saving. Checkout remains read-only until
Edit details is chosen. Legacy renderNamedSeatEditor remains available for
specialized composition; new hosts should use the packaged controls.
JourneyFrame supplies responsive layout and uses --ui-accent,
--ui-on-accent, --ui-text-muted and --ui-surface-2 for enabled and disabled
states. The default checkout editor also uses these shared fields and readiness.
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.
Closed/refunded/declined applications return a scoped 409 with
code: "application_closed"; loadJoinResume maps this to step: "closed".
The island shows a terminal notice without another form, checkout, or booking.
Unknown continuation states and mismatched application ids are rejected rather
than defaulting to payment. Temporary configuration failures remain retryable.
Studio and automated previews can traverse that same state machine without
creating an application, loading Stripe, charging a card, or booking a real
calendar event. Supply deterministic simulation boundaries and keep the
production config, tier selection, copy, render slots, and step transitions:
<JoinIsland
config={joinConfig}
simulation={{
applicationId: "preview-application",
payment: {
lineItems: {
standardCents: 100_000,
discountCents: 10_000,
dueTodayCents: 90_000,
},
},
booking: {
timezone: "America/Los_Angeles",
slots: [{ startAt: Date.UTC(2026, 8, 3, 17) }],
},
}}
>
<PreviewApplicationFields />
</JoinIsland>Simulation is deliberately a UI/test boundary, not acceptance evidence for a live Chapter. A follower still has to prove its real application write, Stripe webhook, and calendar booking journey before production.
Authenticated member pages
A purchaser whose named-seat invitation is open (sent, not yet accepted, not
held for their own approval) can forward it themselves: NamedSeatInvitationLink
from @odla-ai/chapter/ui/member renders the same /join?seat=… link the
invitation email carries, with "Copy invitation link" and "Copy a ready-to-send
message" controls that name the recipient's email, which is the only address the
link opens for. NamedSeatPurchase includes it; a host with its own seat card
renders it under the recipient line and passes joinPath when its application
page is not /join. namedSeatInvitationShareable, namedSeatInvitationUrl
and namedSeatInvitationMessage are the pure pieces.
Enable the standard member catalog explicitly:
const chapter = defineChapter({
// ...normal chapter config...
memberContent: {
localAuthoring: true,
parent: {
sourceId: "built-not-found",
// default follower vault key: member_content_control_secret
},
},
});This creates two deliberately separate authoring layers. The Parent delivers
immutable signed revisions; the Chapter admin edits one local revision. The
effective catalog merges them without permitting a Parent replay to replace a
Chapter page. Each page declares a stable id/slug, navigation section/order,
safe Markdown, and either all-members or an explicit set of tier ids. Tier
sets are not treated as an ordinal ladder.
const parentDocument = {
schemaVersion: 1,
revision: 4,
generatedAt: Date.now(),
source: { type: "parent", appId: "built-not-found" },
target: { chapterId: "example-chapter", environment: "dev" },
pages: [{
id: "steward-library",
slug: "steward-library",
title: "Steward library",
navigation: { section: "Network", order: 20 },
audience: { kind: "tiers", tierIds: ["steward"] },
teaserMarkdown: "Advanced material for stewards.",
bodyMarkdown: "The **private** operating library.",
// Built Not Found delegates only this offer to the Chapter admin.
upsell: { owner: "chapter" },
}],
};An upsell references only a live tier id and bounded copy. Authored content
cannot set price, Stripe ids, checkout state, HTML, CSS, or arbitrary links.
The Worker attaches the currently offerable tier name and price. A Parent page
may own its offer (owner: "parent") or declare owner: "chapter"; the local
document then uses upsellOverrides for that exact page. Chapter-authored
pages always keep Chapter offer authority.
GET /api/member-content requires a signed-in member. Chapter resolves the
member's tier from their local application and removes every unauthorized
bodyMarkdown before serializing JSON. Locked metadata may include a teaser
and resolved offer. Admins may preview every body through the same projection.
Client-side hiding is not an authorization boundary.
Render the projection through the stock member area:
<MembersArea api={api} signOut={signOut} memberPages />Or compose MemberContentArea directly and provide a host-owned upgrade URL or
callback. The package does not pretend a second application is an upgrade:
<MemberContentArea
api={api}
upsellHref={(tierId) => approvedUpgradeRoute(tierId)}
/>The standard Chapter admin keeps the top-level Dashboard/People/Settings
grammar and adds a Member content tab under Settings. It labels inherited
pages as read-only, shows source/access/offer ownership, and edits the complete
local JSON contract with revision guards and actionable validation errors.
Agents may use the same authenticated /api/admin/member-content GET/PUT
contract. Page bodies remain out of CRM; CRM continues to own people and
lifecycle state.
For central delivery, pair createMemberContentEnvelope(document) with
deliverMemberContent from @odla-ai/chapter/worker. The dedicated signed
route rejects wrong source/target/environment, stale or conflicting revisions,
bad digests, undeclared tiers, and any Parent revision that would invalidate
existing Chapter-owned content. The CRM edge and signup-control secret are
never reused.
Agents can run the same contract through the supported CLI. tiers.json
contains a JSON array of the live safe authority projection (id, name,
priceCents, and active) and deliberately excludes provider Price ids:
odla-ai chapter member-content validate --file parent.json --tiers tiers.json --json
odla-ai chapter member-content compose --parent parent.json --chapter chapter.json --tiers tiers.json --json
odla-ai chapter member-content project --parent parent.json --chapter chapter.json --tiers tiers.json --tier community --json
odla-ai chapter member-content deliver --file parent.json --tiers tiers.json \
--url https://chapter.example --from-env MEMBER_CONTENT_SECRET --jsonUse --stdin instead of --from-env when the signing secret comes from a
producer process. The value is never accepted as an argument. project is the
server-disclosure proof and omits locked bodies; compose is a local operator
authoring view and contains the supplied content.
Private Builder Library
Builder source material is deliberately separate from member publishing. Enable it on both the Built Not Found hub and each participating Chapter:
// Built Not Found
network: {
targets: [
{ id: "oakland", url: "https://oakland.example", binding: "OAKLAND" },
{ id: "denver", url: "https://denver.example", binding: "DENVER" },
],
},
builderLibrary: {
sources: [{ sourceId: "oakland" }, { sourceId: "denver" }],
},
// Oakland
network: {
readers: [{ id: "built-not-found", fields: { person: ["name"] } }],
},
builderLibrary: {
sources: [{ sourceId: "built-not-found" }],
},Every resource has exactly one scope: { kind: "chapter", chapterId },
{ kind: "built_not_found" }, or { kind: "global" }. There are no groups or
audience arrays. A Chapter can author only its own scope. Built Not Found can
author material for itself, every Chapter, or exactly one Chapter from its
configured source list. A Chapter's admins and agents see its own published
material plus global material, never a sibling Chapter or Built Not Found-only
material. Built Not Found admins see every configured
Chapter source and can promote a Chapter resource to a global copy; the server
records the source Chapter, source resource/document revisions, actor, and time.
Resources are bounded documents/notes, HTTPS links/inspiration, or canonical
Brand { bookId, assetId } references. Asset URLs, bytes, storage credentials,
HTML, and executable content are not part of the contract. The standard admin
adds a visibly private Builder Library tab separate from Member content.
It is a compact CMS inventory with search and type/status filters, type-specific
add/edit fields, previews, explicit Draft/Published/Archived state, and a
visibility picker whose Chapter choices come only from checked-in network
configuration. Saving a library item never creates or changes a member page.
An authenticated admin or site-building agent reads the bounded, published-only
manifest from /api/admin/builder-library/manifest. On Built Not Found, pass a
configured chapterId query parameter; on a Chapter, the server fixes the
viewer to that Chapter. Every Builder source must already be the corresponding
network.target (on Built Not Found) or network.reader (on a Chapter). Signed
delivery reuses that Chapter network edge: the Chapter's existing
network_share_secret is the same value Built Not Found already stores as
network_share_<chapter_id>. Builder Library declares and provisions no secret
of its own.
odla-ai chapter builder-library validate --file oakland-library.json --source-type chapter --source oakland --json
odla-ai chapter builder-library manifest --file oakland-library.json --chapter oakland --json
odla-ai chapter builder-library deliver --file oakland-library.json \
--url https://builtnotfoundcapital.com --from-env NETWORK_SHARE_SECRET --jsonThe existing network share secret is accepted only from a named environment
variable or stdin; it is never copied into Builder Library config or argv.
For a Built Not Found local document, add --target oakland --environment dev;
the CLI signs only its global resources for that Chapter, never BNF-only items.
Hub-authoritative live signup control
When a hub must control the live membership page as well as preview it, use the dedicated signup-control contract. Do not widen the CRM network route: CRM record sharing and operational signup authority are different capabilities. The follower opts into one exact source and Stripe mode:
const chapter = defineChapter({
// ...normal chapter config and local bootstrap values...
signupControl: {
sourceId: "built-not-found",
stripeMode: "test",
// default follower vault key: signup_control_secret
},
});The hub builds a complete SignupControlDocument, calls
createSignupControlEnvelope(document), and sends it with
deliverSignupControl from @odla-ai/chapter/worker. The same strong random
secret is vaulted separately on each side; it is never a Clerk token, ODLA key,
or CRM network secret.
For multiple authoritative runtimes sharing a data environment, set
signupControl.runtimeSecrets to an explicit map such as
{ cory: "signup_control_cory", tori: "signup_control_tori" }. Each value names
a different follower vault key; paired hub credentials remain separate too.
Unlisted runtimes and untargeted legacy deliveries then fail closed. Include
deliveryTarget: { appId, environment, runtime } beside the envelope's
document and digest. This transport identity is signed but excluded from
the document digest, so both runtimes accept or replay the same reviewed state.
The receipt attests the receiving runtime; the sender validates exact target,
revision, digest, status, and timestamp. Delivery does not follow redirects and
bounds receipt reads to 8 KiB and ten seconds.
Provision the additive signupControlHeads namespace before deploying this
receiver. Its transaction guard serializes concurrent first publications and
stale work across runtimes. The hub must likewise allocate revisions atomically
and freeze the document in the same transaction as its per-runtime outbox rows.
No process should regenerate an old revision from the current catalog on retry.
The document carries the source, exact chapter/environment/Stripe-mode target,
monotonic revision, ordered and available tiers, immutable Stripe Price ids,
the membership-page renderer document, and explicitly delegated application,
policy, scheduling, and theme sections. Theme tokens include typography as
allowlisted --ui-* CSS custom properties such as --ui-font-display and
--ui-font-sans. Chapter rejects non---ui-* names, non-finite values,
oversized token sets, control characters, style-tag breakout, declaration
breakout, comments, imports, expressions, and URL-bearing values. Application
aliases such as --teal or --deepocean remain host-owned CSS.
The packaged follower receiver validates the signed request timestamp, sender,
target, environment, Stripe mode, schema, and SHA-256 digest. It rejects a stale
revision and rejects the same revision with different content; the same revision
and digest is an idempotent replay. Acceptance transactionally stores immutable
history, switches the active revision, and materializes the group/tier rows that
join-config, application tier validation, and checkout already read. Public
GET /api/join-config returns the active revision/digest, renderer document,
application contract, and theme. Applicant requests therefore use local
last-known-good state and never depend on the hub being reachable.
A successful empty tier query retains legacy single-tier compatibility.
Database failures, malformed responses, and missing stored prices fail closed;
they never invent a free membership. Retiring all stored tiers leaves no offers
instead of reviving the group's legacy price. Public Worker routes return
503 with tiers_unavailable for this failure rather than charging a fallback.
One field contract for follower forms and CMS management
Chapter owns field identity and storage authority; a hub such as Built Not
Found owns the editor. Declare the follower's maximum application field set in
defineChapter({ application: { required, optional, conditions, maxLen } }).
A signed signup-control revision may select, reorder, and change requiredness
only within that declared set. It cannot invent a field, change its value type,
or expose an operational application attribute.
chapterFlowFieldContract(chapter, "join") and
chapterFlowFieldContract(chapter, "formation") return the same versioned,
ordered shape: stable field id, label, CRM value type, requiredness, order,
length cap, enum options, and conditions where the flow supports them.
GET /api/join-config exposes the effective fieldContract after active
signup control is applied. GET /api/formation/config exposes the same
fieldContract and retains its legacy fields array as a compatibility alias.
Use that document for a CMS preview or host renderer; submit to the packaged
route so the server enforces the same effective selection.
Email identity is literal. normalizeChapterEmail() trims and lowercases only;
it never applies Gmail/provider alias rules. Consequently
[email protected] and [email protected] are distinct application, admission,
purchase, and gift-beneficiary identities even if a mailbox provider delivers
both to the same inbox. Email fields advertise emailIdentity: "literal" in
the flow contract so editors and test fixtures do not collapse them.
Materialization addresses provisioned groups and tiers by their unique public
id attributes, not by assuming those values are odla-db entity ids. A failed
materialization returns a bounded JSON materialization_failed rejection and
the transaction leaves the prior active revision unchanged.
The first delivery is empty-history safe: Chapter checks a bounded aggregate count before asking odla-db to order revision history. A freshly provisioned follower therefore acknowledges revision 1 immediately instead of depending on an ordered query over an empty result set.
Render active tokens before first paint
Put Chapter's marker after the local bootstrap theme and before any application boot script in the HTML head:
<link rel="stylesheet" href="/bootstrap-theme.css">
<!--odla:signup-control-theme-->
<script type="module" src="/app.js"></script>A host route can transform the static asset with the follower-local active row:
import { chapterWorker, withActiveSignupControlTheme } from "@odla-ai/chapter/worker";
import { chapter } from "./chapter.config.mjs";
export default chapterWorker({
chapter,
routes: [async (request, url, env, context) => {
if (request.method !== "GET" || url.pathname !== "/apply") return null;
const asset = await env.ASSETS.fetch(new URL("/index.html", url));
return withActiveSignupControlTheme(asset, {
db: context.makeDb(env),
chapterId: chapter.id,
environment: env.ODLA_ENV,
// The site's existing deterministic fallback when no revision is active.
bootstrapTokens: { "--ui-accent": "#096ea8" },
});
}],
});withActiveSignupControlTheme reads only the follower database, revalidates the
stored document and its canonical digest, and injects one bounded style block
with matching revision/digest metadata. It never calls the controlling hub. A
missing active row emits the documented bootstrap state; a failed local read
or corrupt active row fails closed instead of looking like absence. The response
defaults to Cache-Control: no-store, carries digest/revision headers, and gets
an ETag over the complete transformed HTML. This prevents a pre-activation page
from surviving activation. A host-owned cache may instead call
renderActiveSignupControlHtml() and include
result.theme.metadata.cacheKey in its complete page cache key.
For an SSR or static-HTML transform, use the same helper on the generated string:
import { renderActiveSignupControlHtml } from "@odla-ai/chapter/worker";
const result = await renderActiveSignupControlHtml(generatedHtml, {
db, chapterId: chapter.id, environment,
bootstrapTokens: { "--ui-accent": "#096ea8" },
});
await writeHtml(result.html); // cache with result.theme.metadata.cacheKeyAfter GET /api/join-config, hydrate the existing style element rather than
writing a second theme system. Matching revision/digest metadata produces no DOM
write and therefore no visual token change:
import {
SIGNUP_CONTROL_THEME_STYLE_ID,
hydrateSignupControlTheme,
} from "@odla-ai/chapter";
const style = document.getElementById(SIGNUP_CONTROL_THEME_STYLE_ID);
if (style instanceof HTMLStyleElement) {
hydrateSignupControlTheme(style, joinConfig.signupControl ?? null, {
bootstrapTokens: { "--ui-accent": "#096ea8" },
});
}The host still decides which routes use managed theming and owns any legacy
token aliases. Keep the marker after bootstrap declarations so the active
--ui-* values win in the initial parsed document.
GiftPurchases is the authenticated purchaser surface: it renders configured
offers, checkout, one-time code recovery, and the buyer-safe
pending/redeemed/expired/revoked ledger. MembersArea composes this surface and
omits it when there are neither current offers nor prior purchases.
MemberGifts remains a deprecated compatibility name for the same
purchaser-only component; it no longer renders a recipient claim control.
GiftCodeRedeemer is the separate recipient control. It requires the exact
payment-stage applicationId plus an authenticated ApiFn, posts trimmed codes
to /api/gifts/redeem, and keeps its input, action, loading lock, and reserved
live status region aligned with the shared @odla-ai/ui inline-action-field
contract. Pass that adapter as JoinIsland.giftClaimApi to compose the claim
before card setup. The Worker accepts only a signed-in user whose email/account,
application, unpaid state, and tier match the pending claim; a successful claim
advances through the canonical join continuation without starting Stripe.
Code entry uses one-time-code autocomplete, disables automatic capitalization
and correction, and retains the submitted code after an error so normal card
payment remains available.
The hub owns a durable outbox: create/verify immutable Stripe Prices first, persist the desired revision, retry signed delivery, and mark it active only after the exact revision/digest receipt. Version history is for audit and rollback, not a second manual publish gate. A failed delivery leaves the follower's prior active revision untouched. Production remains behind the normal deployment approval checkpoint.
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"] },
// Independent consent to compare the chapter's commercial projection.
commercialParity: true,
}],
},
});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 in the same versionedfieldContractshape as the join flow.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.
Shared-Stripe commercial shadow
A hub may opt into a read-only network commercial projection when multiple chapters intentionally bill through one Stripe account. Every accepted source is an exact partition, including Stripe test/live mode:
networkCommercialReadModel: {
mode: "shadow",
sources: [{
stripeMode: "test",
appId: "example-chapter",
chapterId: "example-chapter",
environment: "dev",
dataEnvironment: "dev",
runtime: "cory",
}],
},The dedicated /api/network-commercial/stripe-webhook verifies Stripe's
signature with network_commercial_stripe_webhook_secret before writing an
append-only, PII-free event fact. The projector is rebuildable and deterministic:
late metadata-less events may inherit a partition only through an unambiguous
provider customer/subscription/invoice/charge reference. Wrong, missing,
ambiguous, unsupported, and colliding events remain explained exceptions.
GET /api/admin/network-commercial-read-model is super-admin-only and exposes
current customer, subscription, invoice, refund, and currency-total facts for a
BNF operator surface. POST with { command: "rebuild", mutationId } rebuilds
from the local event log without calling or mutating Stripe. POST with
{ command: "reconcile-stripe" } performs bounded, GET-only current-object
reads through the hub's vaulted stripe_secret_key and reports missing,
unexpected, mismatched, truncated, and totals exceptions without changing the
event log or projection. Objects outside the configured exact partitions are
counted as ignored rather than adopted. This shadow never
authorizes charges, refunds, subscription changes, or Chapter lifecycle writes;
removing it changes no customer-facing behavior. The BNF frontend may be down
while the webhook continues to ingest. Registering a Stripe endpoint and
enabling production remain separate reviewed operations.
A second delivery of a retained event id is a replay when its digest matches, or
when its canonical fact (id, type, created, and the allowlisted data.object
fields) equals the retained fact however the delivery envelope changed. A
canonical replay also resolves an open collision for that event, recording a
paymentRecoveryReviews row with the system as actor, unless the collision
recorded a different fact. Only a delivery whose fact differs registers a
collision, and it stays active through rebuilds until an operator decides:
POST with { command: "resolve", eventId, mutationId, reason } records the
actor and reason, dismisses the colliding delivery so the same one replays
quietly, and keeps the retained fact as the evidence of record; it refuses
without a valid mutationId and a reason. POST with
{ command: "recover-event", eventId } instead trusts an independent read of
the provider Event and rewrites the retained digest when every fact matches.
/api/admin/payment-recovery is the drain on both sides. A chapter lists and
retries its quarantined Stripe receipts; a hub that owns the read model lists
every active commercial exception (count plus provider ids, no PII) and
retries one through the same ingest the signed webhook uses after an
independent provider read, with a review row per retry.
Both Workers raise an error-level operator alert when a receipt enters
quarantine or a commercial exception becomes active, carrying the current count
and provider event ids only. Pass recordError from @odla-ai/o11y as
chapterWorker({ chapter, recordError }) to deliver those alerts to o11y; the
default is one structured console.error line per alert.
POST with { command: "reconcile-chapters" } is a separate, two-sided opt-in
comparison. A hub target and the matching follower reader must both set
commercialParity: true; neither record browsing nor Stripe shadow mode grants
it implicitly. The hub signs a GET /api/network/commercial-parity request with
the edge's network_share_* secret. The chapter returns only bounded status,
cancellation, counts, completeness, and domain-separated HMAC-SHA-256 digests of
application, Stripe customer, and Stripe subscription identifiers. Name, email,
raw identifiers, and arbitrary application fields never cross the edge. The hub
compares those digests with its exact Stripe partition and reports chapter-only,
shadow-only, cancellation, count, unavailable, invalid, and truncation
exceptions without persisting or changing either side. Chapter application
status and Stripe subscription status remain distinct vocabularies and are shown
side by side rather than falsely compared for equality.
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"],
// Separate again: name the exact profile fields and record types the
// parent may mutate. Omission keeps the edge read-only.
editableFields: { person: ["name", "email", "phone", "linkedin"] },
stageTransitions: ["person"],
admissionGrants: true,
// Independent disclosure consent. This may be the reader's only ability.
commercialParity: true,
}],
},
});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, optimistic revision, and explicitly delegated edit
metadata 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. When both halves of an edge opt in,
PATCH /api/admin/network/records accepts one bounded field patch or lifecycle
destination with the follower-authored expectedRevision, current stage, and a
stable mutation id. The follower revalidates its CRM schema, identity/email
rules, pipeline gates, and atomic revision guard; it remains source of truth and
records the leader peer plus initiating admin in the CRM activity audit. A
retry with the same input is idempotent, while stale state is 409, an
undelegated operation is 403, invalid fields or transitions are 422, and
ordinary browse-only edges remain read-only.
Admission decisions use a separate default-deny lane. With
admissionGrants: true on both halves of an edge, the leader's
POST /api/admin/network/admissions resolves the person's current email from
its CRM, persists the complete desired target document, and delivers it through
the configured service binding or signed URL. Delivery state is durable as
saved, pending, acknowledged, retrying, or rejected; retry and revoke
routes publish the same or a higher desired revision. A follower may declare an
admission-only reader without fields, which does not make CRM records
browsable or editable.
The Network detail view also has two deliberate note 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"],
editableFields: { person: ["name", "email", "phone", "linkedin"] },
stageTransitions: ["person"],
admissionGrants: true,
}],
},
});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, editableFields?, stageTransitions?, admissionGrants? }; - 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.
Pair the edge by vaulting one generated value as network_share_secret in the
follower and as network_share_example_chapter in the leader (or the target's
secretName), never printed. The integration descriptor declares both slots,
so provisioning tooling can hold them against the vault, and until both hold a
value every leader-side surface reports the edge as code: "edge_unpaired"
with a hint naming the CLI pairing command (chapter edge pair, which
writes both slots from one generated value and ships with the next
@odla-ai/cli release). 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. Pair the edge: vault one generated value asnetwork_share_secretin the follower and under the target's resolvedsecretNamein the leader (the CLI'schapter edge paircommand does both writes from the next@odla-ai/clirelease on). The leader rollup reportsedge_unpaireduntil both slots hold a value. - 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 operati
