@odla-ai/chapter 0.24.0 → 0.25.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
@@ -8,7 +8,7 @@ email. The host still builds the public pages, routing, and brand presentation;
8
8
  Chapter supplies their application mechanics.
9
9
 
10
10
  ```sh
11
- npm i --save-exact @odla-ai/chapter@0.24.0
11
+ npm install @odla-ai/chapter
12
12
  ```
13
13
 
14
14
  > **Agentic experiment.** Built and maintained by AI agents from bounded runbooks
@@ -16,7 +16,14 @@ npm i --save-exact @odla-ai/chapter@0.24.0
16
16
 
17
17
  > **Pre-1.0.** The member surface, operational worker, standard admin console,
18
18
  > and leader → follower record delivery ship. APIs may still tighten before 1.0;
19
- > pin the package and run schema/route contract tests in every site.
19
+ > commit the lockfile and run schema/route contract tests after every update.
20
+
21
+ > **Ask the runbooks first.** odla's operational procedures live in a database,
22
+ > not in this file: `npx @odla-ai/cli runbook ask "<question>"` returns the current
23
+ > steps, and unlike anything written here it cannot be out of date. Use it before
24
+ > searching the web or working from memory. This README and the JSDoc in the
25
+ > shipped `.d.ts` are the version-matched **API** reference; a runbook is the
26
+ > **procedure**. Most tasks need an answer from both.
20
27
 
21
28
  ## Agent runbooks
22
29
 
@@ -589,14 +596,18 @@ These bite silently — a smoke test won't catch them:
589
596
  that omits `account`, so forgetting the decision cannot silently disable or
590
597
  enable account provisioning. Both side-effecting models need
591
598
  `clerk_secret_key` in the tenant vault. Hub mode resolves to inert `"none"`.
592
- - **What lands on the Clerk account (and its `public_metadata` is
593
- client-readable).** Both side-effecting models write `applicationId` and,
594
- when explicitly selected fields are present, `profile`. The
595
- `application.profileFields` default is `[]`, so application details remain
596
- db-only. Set `profileFields: ["phone", "state", "focus"]` to a deliberate
597
- browser-readable allowlist. Array fields
598
- (`focus`) are clamped to `maxArrayLen` (default 100) and non-primitive elements
599
- dropped, so a client can't post an unbounded array into metadata.
599
+ - **What lands on the Clerk account.** `public_metadata` is reserved for small
600
+ browser/session-readable authorization claims such as `role`. Chapter writes
601
+ `applicationId` and the explicitly selected `profile` fields to backend-only
602
+ `private_metadata`; `application.profileFields` defaults to `[]`. Treat that
603
+ copy as a bounded account/admin snapshot—the application row and CRM remain
604
+ canonical. Array fields (`focus`) are clamped to `maxArrayLen` (default 100)
605
+ and non-primitive elements are dropped. `"create"` writes the private snapshot
606
+ with the account. Clerk application invitations cannot carry private
607
+ metadata, so `"invite"` completes the write on the accepted account's first
608
+ `/api/me` request. A one-time application marker prevents repeated Clerk API
609
+ writes. That repair also removes Chapter's former `applicationId` and
610
+ `profile` keys from public metadata without disturbing `role`.
600
611
  `applicantProfile(chapter, fields)` is exported to assert the exact shape in a
601
612
  test before deleting a local override.
602
613
  - **Email + input validation.** The field literally named `email` is checked
@@ -653,25 +664,23 @@ These bite silently — a smoke test won't catch them:
653
664
  `@odla-ai/auth-clerk/invitations` when you want to send your own branded
654
665
  invitation mail. Importing the full `@odla-ai/chapter/ui` barrel pulls the admin
655
666
  half, so prefer the narrower entry.
656
- - **Known-good application set** (installs clean, no flags):
657
- `@odla-ai/chapter` 0.24.0, `@odla-ai/brand` 0.2.0,
658
- `@odla-ai/ui` 0.12.1, `@odla-ai/crm` 0.3.1,
659
- `@odla-ai/db` 0.6.7,
660
- `@odla-ai/calendar` 0.2.0, `@odla-ai/email` 0.3.1,
661
- `@odla-ai/auth-clerk` 0.4.1, `@odla-ai/o11y` 2.2.2, `jose` 6.2.3, and
662
- Preact 10.29.7.
663
- - **Known-good Preact host toolchain:** `@odla-ai/cli` 0.17.1,
664
- `@odla-ai/security` 0.3.1, `@cloudflare/workers-types` 4.20260702.1,
665
- TypeScript 6.0.3, Vite 8.1.4, Vitest 4.1.10, and Wrangler 4.107.0. The
666
- greenfield runbook standardizes on this exact Preact matrix.
667
+ - **Use normal dependency declarations during active development.** Install
668
+ Chapter, its selected peers, and the Preact host toolchain without exact
669
+ pins. Commit `package-lock.json`, use `npm ci` for repeatable installs, record
670
+ `npm ls` output in PM evidence, and rerun conformance after updates. The
671
+ package manifest's dependency and peer ranges are the compatibility
672
+ contract.
667
673
  - **`--legacy-peer-deps` is a diagnostic, not a setting.** It suppresses exactly
668
674
  the peer conflict that tells you a pair is unsupported. If you need it, find out
669
675
  why first.
670
676
  - **The admin operational surface ships (0.16.0).** `/api/admin/*` is now a full
671
677
  membership-operations API, admin-gated (`verifyUser` + `isAdmin`):
672
678
  - **Roster/identity:** `GET /people` (union of `$users` + applications by email,
673
- roles from `clerkListUsers`), `GET /people/access`, `POST /people/role`,
674
- `POST /crm/sync` (backfill/reproject).
679
+ roles and backend-only profile signals from `clerkListUsers`),
680
+ `GET /people/access`, `POST /people/role`, `POST /crm/sync`
681
+ (backfill/reproject), and
682
+ `POST /clerk/private-profiles/sync?offset=0&limit=50` (bounded,
683
+ resumable migration/repair from Chapter's former public profile keys).
675
684
  - **Pipeline/meetings:** `GET /dashboard` (flow counts, stage counts + weekly
676
685
  delta, agenda, live revenue), `GET /meetings` (reconciled agenda),
677
686
  `GET/PUT /scheduling`, `POST /meetings/:id/{reschedule,cancel}`,
@@ -698,12 +707,14 @@ These bite silently — a smoke test won't catch them:
698
707
  - **The server-side Clerk primitives are exported too** — for host routes beyond
699
708
  the built-in `/people/role`. Beside
700
709
  `createClerkUser`/`createClerkInvitation`/`canChangeRole`, chapter exports
701
- the odla→Clerk write half: `clerkGetUserByEmail`, `clerkGetUser`, `clerkListUsers`
702
- (auto-paginated — never a silent 100-user cap), and `clerkSetRole`. Two
703
- load-bearing semantics: an absent `public_metadata.role` reads as the lowest rung
704
- (`"provisional"`), and `clerkSetRole` merge-`PATCH`es only `{ role }` so it never
705
- clobbers a separately-written `public_metadata.profile`. All take an injectable
706
- `fetch` and the vault `clerk_secret_key`.
710
+ the odla→Clerk write half: `clerkGetUserByEmail`, `clerkGetUser`,
711
+ `clerkListUsers` (auto-paginated — never a silent 100-user cap),
712
+ `clerkSetRole`, `updateClerkUserMetadata`, and
713
+ `updateClerkUserMetadataByEmail`. An absent `public_metadata.role` reads as
714
+ the lowest rung (`"provisional"`); normalized `profile` and `applicationId`
715
+ reads come only from backend-only private metadata; and `clerkSetRole`
716
+ merge-`PATCH`es only the public role, so it cannot clobber the private profile.
717
+ All take an injectable `fetch` and the vault `clerk_secret_key`.
707
718
 
708
719
  ### Verify from the types, not this file
709
720
 
@@ -300,10 +300,9 @@ interface ChapterApplication {
300
300
  * writing a row with no consent record. Default `true`. Set `false`
301
301
  * deliberately only when the site renders no consent control. */
302
302
  requireDisclaimerAck?: boolean;
303
- /** Allowlist of fields that reach the Clerk account's client-readable
304
- * `public_metadata.profile`. Default `[]`, so application details remain
305
- * db-only. Set a curated list (e.g. `["phone", "state", "focus"]`) for
306
- * fields the browser may read. */
303
+ /** Allowlist of application fields mirrored into the Clerk account's
304
+ * backend-only `private_metadata.profile`. Default `[]`. Use this for small
305
+ * account/admin signals; the application row and CRM remain canonical. */
307
306
  profileFields?: readonly string[];
308
307
  /** Extra application fields carried into the one-way CRM projection, on top of
309
308
  * the built-in identity/contact set. Each MUST be declared on your crm person
package/dist/index.cjs CHANGED
@@ -101,6 +101,8 @@ __export(index_exports, {
101
101
  submitApplication: () => submitApplication,
102
102
  subscriptionIdempotencyKey: () => subscriptionIdempotencyKey,
103
103
  syncApplicationToCrm: () => syncApplicationToCrm,
104
+ updateClerkUserMetadata: () => updateClerkUserMetadata,
105
+ updateClerkUserMetadataByEmail: () => updateClerkUserMetadataByEmail,
104
106
  validateScheduling: () => validateScheduling,
105
107
  verifyStripeSignature: () => verifyStripeSignature,
106
108
  webhookMutationId: () => webhookMutationId
@@ -153,6 +155,10 @@ var applications = {
153
155
  meetingAt: attr("number", { indexed: true, optional: true }),
154
156
  meetingLink: attr("string", { optional: true }),
155
157
  clerkUserId: attr("string", { indexed: true, optional: true }),
158
+ // One-time/effect-repair marker. Absent means /api/me should copy the
159
+ // allowlisted application snapshot into Clerk private_metadata and remove
160
+ // the former public_metadata copy.
161
+ clerkPrivateMetadataSyncedAt: attr("number", { optional: true }),
156
162
  phone: attr("string", { optional: true }),
157
163
  state: attr("string", { optional: true }),
158
164
  groupId: attr("string", { indexed: true, optional: true }),
@@ -1611,23 +1617,30 @@ function clerkUserRequest(input) {
1611
1617
  skip_password_requirement: true,
1612
1618
  ...input.firstName ? { first_name: input.firstName } : {},
1613
1619
  ...input.lastName ? { last_name: input.lastName } : {},
1614
- ...input.publicMetadata ? { public_metadata: input.publicMetadata } : {}
1620
+ ...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
1621
+ ...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
1615
1622
  }
1616
1623
  };
1617
1624
  }
1618
- async function refreshUserMetadata(secretKey, email, publicMetadata, fetchImpl) {
1625
+ async function updateClerkUserMetadata(secretKey, userId, input, fetchImpl = fetch) {
1626
+ const res = await fetchImpl(`https://api.clerk.com/v1/users/${encodeURIComponent(userId)}/metadata`, {
1627
+ method: "PATCH",
1628
+ headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
1629
+ body: JSON.stringify({
1630
+ ...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
1631
+ ...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
1632
+ })
1633
+ });
1634
+ return res.ok;
1635
+ }
1636
+ async function updateClerkUserMetadataByEmail(secretKey, email, input, fetchImpl = fetch) {
1619
1637
  const auth = { authorization: `Bearer ${secretKey}` };
1620
1638
  const found = await fetchImpl(`https://api.clerk.com/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, { headers: auth });
1621
1639
  if (!found.ok) return false;
1622
1640
  const users = await found.json().catch(() => null);
1623
1641
  const id2 = Array.isArray(users) && typeof users[0]?.id === "string" ? users[0].id : void 0;
1624
1642
  if (!id2) return false;
1625
- const patched = await fetchImpl(`https://api.clerk.com/v1/users/${id2}/metadata`, {
1626
- method: "PATCH",
1627
- headers: { ...auth, "content-type": "application/json" },
1628
- body: JSON.stringify({ public_metadata: publicMetadata })
1629
- });
1630
- return patched.ok;
1643
+ return updateClerkUserMetadata(secretKey, id2, input, fetchImpl);
1631
1644
  }
1632
1645
  async function createClerkUser(secretKey, input, fetchImpl = fetch) {
1633
1646
  const { path, body } = clerkUserRequest(input);
@@ -1638,8 +1651,15 @@ async function createClerkUser(secretKey, input, fetchImpl = fetch) {
1638
1651
  });
1639
1652
  if (res.ok) return { ok: true, status: res.status };
1640
1653
  const healed = heal(res.status);
1641
- if (!healed.existed || !input.publicMetadata) return healed;
1642
- const refreshed = await refreshUserMetadata(secretKey, input.email, input.publicMetadata, fetchImpl).catch(() => false);
1654
+ const repair = input.repairMetadata ?? {
1655
+ ...input.publicMetadata !== void 0 ? { publicMetadata: input.publicMetadata } : {},
1656
+ ...input.privateMetadata !== void 0 ? { privateMetadata: input.privateMetadata } : {}
1657
+ };
1658
+ if (!healed.existed || repair.publicMetadata === void 0 && repair.privateMetadata === void 0) return healed;
1659
+ const refreshed = await updateClerkUserMetadataByEmail(secretKey, input.email, {
1660
+ ...repair.publicMetadata !== void 0 ? { publicMetadata: repair.publicMetadata } : {},
1661
+ ...repair.privateMetadata !== void 0 ? { privateMetadata: repair.privateMetadata } : {}
1662
+ }, fetchImpl).catch(() => false);
1643
1663
  return { ...healed, refreshed };
1644
1664
  }
1645
1665
 
@@ -1650,9 +1670,21 @@ var PAGE = 100;
1650
1670
  function toRecord(u) {
1651
1671
  if (typeof u.id !== "string") return null;
1652
1672
  const pm = u.public_metadata ?? {};
1673
+ const privateMetadata = u.private_metadata ?? {};
1674
+ const rawProfile = privateMetadata.profile;
1675
+ const profile = rawProfile && typeof rawProfile === "object" && !Array.isArray(rawProfile) ? rawProfile : {};
1676
+ const applicationId = typeof privateMetadata.applicationId === "string" ? privateMetadata.applicationId : void 0;
1653
1677
  const role = typeof pm.role === "string" && pm.role ? pm.role : DEFAULT_ROLE;
1654
1678
  const email = u.email_addresses?.[0]?.email_address;
1655
- return { id: u.id, email: typeof email === "string" ? email : void 0, role, publicMetadata: pm };
1679
+ return {
1680
+ id: u.id,
1681
+ email: typeof email === "string" ? email : void 0,
1682
+ role,
1683
+ publicMetadata: pm,
1684
+ privateMetadata,
1685
+ profile,
1686
+ ...applicationId ? { applicationId } : {}
1687
+ };
1656
1688
  }
1657
1689
  async function clerkGet(path, secretKey, fetchImpl) {
1658
1690
  const res = await fetchImpl(`${CLERK_API}${path}`, { headers: { authorization: `Bearer ${secretKey}` } });
@@ -1715,7 +1747,7 @@ var clerkIntegration = {
1715
1747
  },
1716
1748
  {
1717
1749
  key: "clerk_secret_key",
1718
- description: "Clerk backend key (sk_*) \u2014 powers the odla->Clerk writes (account create, role + profile via public_metadata) and lets odla-db resolve user email/name via the Clerk API.",
1750
+ description: "Clerk backend key (sk_*) \u2014 powers account creation, the public role claim, and backend-only private profile signals; it also lets odla-db resolve user email/name via the Clerk API.",
1719
1751
  pattern: "sk_",
1720
1752
  mode: "full",
1721
1753
  vault: true
@@ -1735,8 +1767,13 @@ var clerkIntegration = {
1735
1767
  engine: "@odla-ai/chapter clerk.ts (Clerk Backend API via vault clerk_secret_key)",
1736
1768
  direction: "odla->provider",
1737
1769
  entity: "clerk user",
1738
- // Separate merge-PATCHes so a role write never clobbers a profile write.
1739
- fields: ["public_metadata.role", "public_metadata.profile"],
1770
+ // Separate metadata fields so a public role write cannot expose or
1771
+ // clobber backend-only account/profile signals.
1772
+ fields: [
1773
+ "public_metadata.role",
1774
+ "private_metadata.applicationId",
1775
+ "private_metadata.profile"
1776
+ ],
1740
1777
  onDelete: "n/a (writes only)"
1741
1778
  }
1742
1779
  ],