@odla-ai/chapter 0.7.0 → 0.8.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
@@ -97,18 +97,66 @@ export default {
97
97
 
98
98
  ## Adopting into an existing site
99
99
 
100
- 1. **Schema + provisioning (safe, verifiable).** Replace a hand-rolled
101
- `schema.mjs` / `rules.mjs` / group seed / provisioner with `defineChapter` +
102
- `createChapterIntegration`. The emitted schema is directly comparable — diff
103
- `chapter.schema` against your namespaces and expect byte-equality so this is
104
- a no-behavior-change deletion of hand-maintained code that also installs the
105
- deny-all `crm_*` rules a hand-rolled provisioner tends to skip.
106
- 2. **The worker.** Either use `chapterWorker({ chapter })` directly, or keep your
107
- bespoke routes and pass them via `chapterWorker({ chapter, routes })` — they
108
- run first and reuse chapter's auth through the shared context, so you never
109
- verify a JWT twice. Path renames your frontend depends on are your own alias
110
- routes in that array.
111
-
112
- Read `dist/*.d.ts` for the authoritative surface.
100
+ A real conversion (the site this was extracted from) went from a 2,094-line
101
+ worker to 6 lines and deleted ~2,500 lines. The order that worked:
102
+
103
+ 1. **Config first, assert parity BEFORE deleting anything.** `defineChapter()`
104
+ your site, then diff `chapter.schema` against your existing schema in a test
105
+ and require byte-equality. That single assertion is what makes the deletion
106
+ safe rather than hopeful.
107
+ 2. **Freeze the old schema as a test fixture** (e.g. `test/fixtures/legacy-schema.mjs`)
108
+ and keep asserting against it, so an upstream default change fails a test
109
+ instead of a live `provision`. It is the only durable guard against drift in a
110
+ generated schema.
111
+ 3. **Swap provisioning** — `createChapterIntegration(chapter)`, inert until the
112
+ next provision run. It supplies schema + rules + seeds, so your
113
+ `odla.config.mjs` `db` block goes away entirely.
114
+ 4. **Then the worker**, keeping every bespoke route as a host route
115
+ (`chapterWorker({ chapter, routes })`). Don't hand routes to chapter in the
116
+ same change as the framework swap.
117
+ 5. **Override rather than inherit** wherever local behavior was a decision.
118
+
119
+ ### Behavior deltas to audit
120
+
121
+ These bite silently — a smoke test won't catch them:
122
+
123
+ - **Per-field caps.** `application.defaultMaxLen` is 2000. If your form accepts
124
+ longer input, pass `maxLen` explicitly or the default starts rejecting it.
125
+ - **`services` default** is `["db","calendar","o11y"]`; `smoke` compares config
126
+ against the platform, so set `services` explicitly if you don't run the o11y
127
+ collector.
128
+ - **Which email fires from which route.** `adminNotification` fires from
129
+ `POST /api/applications` (on submit), `prepEmail` from `/api/schedule/book`,
130
+ `paymentConfirmation` from the Stripe webhook. If your policy differs (e.g.
131
+ notify on payment, not submit), override that route.
132
+ - **Account model.** `account: "invite"` (default) mints a Clerk invitation,
133
+ `"create"` makes the account server-side (so join can say the account is
134
+ ready), `"none"` skips it — all need `clerk_secret_key` in the tenant vault. A
135
+ site that wants server-side create must set `account: "create"`; inheriting the
136
+ default silently changes the model.
137
+ - **CRM projection points.** chapter projects the person on application submit
138
+ (`projectApplicant`), not on booking or on webhook status change. If you mirror
139
+ pipeline stage into the CRM, keep those routes.
140
+ - **Route names.** chapter serves `/api/config`, `/api/join-config`, etc. Alias
141
+ legacy names in ~4 lines with a host route in `chapterWorker({ routes })` rather
142
+ than rewriting pages.
143
+
144
+ ### Install + scope notes
145
+
146
+ - **Skip `@odla-ai/auth-clerk` for a worker-only adoption.** The worker never
147
+ imports it (JWTs are verified with `jose` via `ctx.verifyUser`); it is an
148
+ optional peer for `chapter/ui` only. Reach for `@odla-ai/auth-clerk/invitations`
149
+ when you want to send your own branded invitation mail.
150
+ - **The admin surface is intentionally minimal** — `/api/admin/scheduling` +
151
+ `/api/admin/meetings`, plus `peopleSection`. Dashboard, billing, per-person
152
+ comms, approve/refund, etc. are your own host routes via the seam; don't plan
153
+ around them shipping.
154
+
155
+ ### Verify from the types, not this file
156
+
157
+ At several releases a day, prose lags. Treat this README as intent and verify the
158
+ real surface from `dist/*.d.ts` and by grepping the built bundle for route
159
+ strings. One testing gotcha: Clerk session tokens expire in ~60s, so a script
160
+ that mints a JWT then runs a batch of curls must re-mint per batch.
113
161
 
114
162
  MIT © odla
package/dist/index.cjs CHANGED
@@ -33,8 +33,10 @@ __export(index_exports, {
33
33
  canceledPatch: () => canceledPatch,
34
34
  chapterDb: () => chapterDb,
35
35
  clerkInviteRequest: () => clerkInviteRequest,
36
+ clerkUserRequest: () => clerkUserRequest,
36
37
  createChapterIntegration: () => createChapterIntegration,
37
38
  createClerkInvitation: () => createClerkInvitation,
39
+ createClerkUser: () => createClerkUser,
38
40
  defaultCrm: () => defaultCrm,
39
41
  defineChapter: () => defineChapter,
40
42
  emailGroupFrom: () => emailGroupFrom,
@@ -308,7 +310,7 @@ Warmly,
308
310
  ${name}`;
309
311
  return {
310
312
  adminNotification: {
311
- subject: `New application \u2014 {{firstName}} {{lastName}}`,
313
+ subject: `New application: {{firstName}} {{lastName}}`,
312
314
  text: `A new application came in for ${name}.
313
315
 
314
316
  Name: {{firstName}} {{lastName}}
@@ -327,7 +329,7 @@ Your membership payment is confirmed. We'll be in touch to schedule your intro c
327
329
  Looking forward to our call at {{meetingTime}}. {{meetingLink}}${sign}`
328
330
  },
329
331
  onboardingInvite: {
330
- subject: `You're in \u2014 ${name}`,
332
+ subject: `You're in, ${name}`,
331
333
  text: `Hi {{firstName}},
332
334
 
333
335
  Welcome to ${name}. Your member area is here: {{membersUrl}}${sign}`
@@ -552,6 +554,10 @@ function defineChapter(config) {
552
554
  const application = resolveApplication(config.application);
553
555
  const { schema, rules } = chapterDb(mode, auth);
554
556
  const services = config.services ?? ["db", "calendar", "o11y"];
557
+ const account = config.account ?? "invite";
558
+ if (account !== "invite" && account !== "create" && account !== "none") {
559
+ throw new Error(`defineChapter.account: must be "invite", "create", or "none" \u2014 got "${String(account)}"`);
560
+ }
555
561
  const chapter = {
556
562
  config,
557
563
  id: id2,
@@ -564,6 +570,7 @@ function defineChapter(config) {
564
570
  schema,
565
571
  rules,
566
572
  services,
573
+ account,
567
574
  groupSeed: () => mode === "chapter" ? buildGroupSeed(config) : null
568
575
  };
569
576
  if (config.url !== void 0) chapter.url = config.url;
@@ -590,7 +597,7 @@ function createChapterIntegration(chapter, options = {}) {
590
597
  }
591
598
  return {
592
599
  id: "chapter",
593
- title: `Chapter \u2014 ${chapter.name}`,
600
+ title: `Chapter: ${chapter.name}`,
594
601
  npm: "@odla-ai/chapter",
595
602
  schema: {
596
603
  entities: { ...crmDesc.schema.entities, ...chapter.schema.entities },
@@ -860,6 +867,26 @@ async function createClerkInvitation(secretKey, input, fetchImpl = fetch) {
860
867
  });
861
868
  return { ok: res.ok, status: res.status };
862
869
  }
870
+ function clerkUserRequest(input) {
871
+ return {
872
+ path: "/v1/users",
873
+ body: {
874
+ email_address: [input.email],
875
+ skip_password_requirement: true,
876
+ ...input.firstName ? { first_name: input.firstName } : {},
877
+ ...input.lastName ? { last_name: input.lastName } : {}
878
+ }
879
+ };
880
+ }
881
+ async function createClerkUser(secretKey, input, fetchImpl = fetch) {
882
+ const { path, body } = clerkUserRequest(input);
883
+ const res = await fetchImpl(`https://api.clerk.com${path}`, {
884
+ method: "POST",
885
+ headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
886
+ body: JSON.stringify(body)
887
+ });
888
+ return { ok: res.ok, status: res.status };
889
+ }
863
890
 
864
891
  // src/session.ts
865
892
  function applicationSummary(app) {