@odla-ai/chapter 0.36.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -404,6 +404,112 @@ Simulation is deliberately a UI/test boundary, not acceptance evidence for a
404
404
  live Chapter. A follower still has to prove its real application write, Stripe
405
405
  webhook, and calendar booking journey before production.
406
406
 
407
+ ### Authenticated member pages
408
+
409
+ Enable the standard member catalog explicitly:
410
+
411
+ ```ts
412
+ const chapter = defineChapter({
413
+ // ...normal chapter config...
414
+ memberContent: {
415
+ localAuthoring: true,
416
+ parent: {
417
+ sourceId: "built-not-found",
418
+ // default follower vault key: member_content_control_secret
419
+ },
420
+ },
421
+ });
422
+ ```
423
+
424
+ This creates two deliberately separate authoring layers. The Parent delivers
425
+ immutable signed revisions; the Chapter admin edits one local revision. The
426
+ effective catalog merges them without permitting a Parent replay to replace a
427
+ Chapter page. Each page declares a stable id/slug, navigation section/order,
428
+ safe Markdown, and either `all-members` or an explicit set of tier ids. Tier
429
+ sets are not treated as an ordinal ladder.
430
+
431
+ ```ts
432
+ const parentDocument = {
433
+ schemaVersion: 1,
434
+ revision: 4,
435
+ generatedAt: Date.now(),
436
+ source: { type: "parent", appId: "built-not-found" },
437
+ target: { chapterId: "example-chapter", environment: "dev" },
438
+ pages: [{
439
+ id: "steward-library",
440
+ slug: "steward-library",
441
+ title: "Steward library",
442
+ navigation: { section: "Network", order: 20 },
443
+ audience: { kind: "tiers", tierIds: ["steward"] },
444
+ teaserMarkdown: "Advanced material for stewards.",
445
+ bodyMarkdown: "The **private** operating library.",
446
+ // Built Not Found delegates only this offer to the Chapter admin.
447
+ upsell: { owner: "chapter" },
448
+ }],
449
+ };
450
+ ```
451
+
452
+ An upsell references only a live tier id and bounded copy. Authored content
453
+ cannot set price, Stripe ids, checkout state, HTML, CSS, or arbitrary links.
454
+ The Worker attaches the currently offerable tier name and price. A Parent page
455
+ may own its offer (`owner: "parent"`) or declare `owner: "chapter"`; the local
456
+ document then uses `upsellOverrides` for that exact page. Chapter-authored
457
+ pages always keep Chapter offer authority.
458
+
459
+ `GET /api/member-content` requires a signed-in member. Chapter resolves the
460
+ member's tier from their local application and removes every unauthorized
461
+ `bodyMarkdown` before serializing JSON. Locked metadata may include a teaser
462
+ and resolved offer. Admins may preview every body through the same projection.
463
+ Client-side hiding is not an authorization boundary.
464
+
465
+ Render the projection through the stock member area:
466
+
467
+ ```tsx
468
+ <MembersArea api={api} signOut={signOut} memberPages />
469
+ ```
470
+
471
+ Or compose `MemberContentArea` directly and provide a host-owned upgrade URL or
472
+ callback. The package does not pretend a second application is an upgrade:
473
+
474
+ ```tsx
475
+ <MemberContentArea
476
+ api={api}
477
+ upsellHref={(tierId) => approvedUpgradeRoute(tierId)}
478
+ />
479
+ ```
480
+
481
+ The standard Chapter admin keeps the top-level Dashboard/People/Settings
482
+ grammar and adds a **Member content** tab under Settings. It labels inherited
483
+ pages as read-only, shows source/access/offer ownership, and edits the complete
484
+ local JSON contract with revision guards and actionable validation errors.
485
+ Agents may use the same authenticated `/api/admin/member-content` GET/PUT
486
+ contract. Page bodies remain out of CRM; CRM continues to own people and
487
+ lifecycle state.
488
+
489
+ For central delivery, pair `createMemberContentEnvelope(document)` with
490
+ `deliverMemberContent` from `@odla-ai/chapter/worker`. The dedicated signed
491
+ route rejects wrong source/target/environment, stale or conflicting revisions,
492
+ bad digests, undeclared tiers, and any Parent revision that would invalidate
493
+ existing Chapter-owned content. The CRM edge and signup-control secret are
494
+ never reused.
495
+
496
+ Agents can run the same contract through the supported CLI. `tiers.json`
497
+ contains a JSON array of the live safe authority projection (`id`, `name`,
498
+ `priceCents`, and `active`) and deliberately excludes provider Price ids:
499
+
500
+ ```bash
501
+ odla-ai chapter member-content validate --file parent.json --tiers tiers.json --json
502
+ odla-ai chapter member-content compose --parent parent.json --chapter chapter.json --tiers tiers.json --json
503
+ odla-ai chapter member-content project --parent parent.json --chapter chapter.json --tiers tiers.json --tier community --json
504
+ odla-ai chapter member-content deliver --file parent.json --tiers tiers.json \
505
+ --url https://chapter.example --from-env MEMBER_CONTENT_SECRET --json
506
+ ```
507
+
508
+ Use `--stdin` instead of `--from-env` when the signing secret comes from a
509
+ producer process. The value is never accepted as an argument. `project` is the
510
+ server-disclosure proof and omits locked bodies; `compose` is a local operator
511
+ authoring view and contains the supplied content.
512
+
407
513
  ### Hub-authoritative live signup control
408
514
 
409
515
  When a hub must control the live membership page as well as preview it, use the
@@ -448,6 +554,32 @@ join-config, application tier validation, and checkout already read. Public
448
554
  application contract, and theme. Applicant requests therefore use local
449
555
  last-known-good state and never depend on the hub being reachable.
450
556
 
557
+ #### One field contract for follower forms and CMS management
558
+
559
+ Chapter owns field identity and storage authority; a hub such as Built Not
560
+ Found owns the editor. Declare the follower's maximum application field set in
561
+ `defineChapter({ application: { required, optional, conditions, maxLen } })`.
562
+ A signed signup-control revision may select, reorder, and change requiredness
563
+ only within that declared set. It cannot invent a field, change its value type,
564
+ or expose an operational application attribute.
565
+
566
+ `chapterFlowFieldContract(chapter, "join")` and
567
+ `chapterFlowFieldContract(chapter, "formation")` return the same versioned,
568
+ ordered shape: stable field id, label, CRM value type, requiredness, order,
569
+ length cap, enum options, and conditions where the flow supports them.
570
+ `GET /api/join-config` exposes the effective `fieldContract` after active
571
+ signup control is applied. `GET /api/formation/config` exposes the same
572
+ `fieldContract` and retains its legacy `fields` array as a compatibility alias.
573
+ Use that document for a CMS preview or host renderer; submit to the packaged
574
+ route so the server enforces the same effective selection.
575
+
576
+ Email identity is literal. `normalizeChapterEmail()` trims and lowercases only;
577
+ it never applies Gmail/provider alias rules. Consequently
578
+ `foo+bar@gmail.com` and `foo@gmail.com` are distinct application, admission,
579
+ purchase, and gift-beneficiary identities even if a mailbox provider delivers
580
+ both to the same inbox. Email fields advertise `emailIdentity: "literal"` in
581
+ the flow contract so editors and test fixtures do not collapse them.
582
+
451
583
  Materialization addresses provisioned groups and tiers by their unique public
452
584
  `id` attributes, not by assuming those values are odla-db entity ids. A failed
453
585
  materialization returns a bounded JSON `materialization_failed` rejection and
@@ -620,7 +752,8 @@ and relations by composing its own config before passing it to Chapter.
620
752
 
621
753
  `formation: {}` opts into a host-rendered public application:
622
754
 
623
- - `GET /api/formation/config` returns only configured public field metadata.
755
+ - `GET /api/formation/config` returns only configured public field metadata in
756
+ the same versioned `fieldContract` shape as the join flow.
624
757
  - `POST /api/formation/applications` accepts only those fields and creates a
625
758
  normal `chapter_application` CRM record.
626
759
  - A stable base64url `submissionId` makes ambiguous retries resolve to the same
@@ -1155,7 +1288,8 @@ These bite silently — a smoke test won't catch them:
1155
1288
  against a permissive email shape (400 on `"notanemail"`, so it fails cleanly
1156
1289
  here rather than at the downstream Clerk create) — set
1157
1290
  `application: { validateEmail: false }` to opt out; `isValidEmail` is exported.
1158
- A valid application is never newly rejected.
1291
+ A valid application is never newly rejected. Identity normalization trims
1292
+ and lowercases but keeps the full local part: plus-addresses remain distinct.
1159
1293
  - **CRM enrichment.** `projectApplicant` writes the base identity/contact person.
1160
1294
  To carry more of the application into the CRM, list `application: { crmFields:
1161
1295
  [...] }`. Each name is **cross-checked against your crm `person` type at