@odla-ai/chapter 0.10.1 → 0.12.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
@@ -156,18 +156,46 @@ These bite silently — a smoke test won't catch them:
156
156
  ready), `"none"` skips it — all need `clerk_secret_key` in the tenant vault. A
157
157
  site that wants server-side create must set `account: "create"`; inheriting the
158
158
  default silently changes the model.
159
- - **What lands on the Clerk account.** Both models write `public_metadata` as
160
- `{ applicationId, profile }`, where `profile` is every configured
159
+ - **What lands on the Clerk account (and its `public_metadata` is
160
+ client-readable).** Both models write `public_metadata` as
161
+ `{ applicationId, profile }`. By default `profile` is every configured
161
162
  `application.required`/`optional` field Clerk doesn't already carry natively
162
- (so *not* email/firstName/lastName), plus `focus`. It follows your field names —
163
- `applicantProfile(chapter, fields)` is exported if you want to assert the exact
164
- shape in a test before deleting a local override.
163
+ (so *not* email/firstName/lastName), plus `focus` **which means free-text or
164
+ third-party fields like `message` or `referral` are exposed to the browser
165
+ unless you curate.** Set `application: { profileFields: ["phone", "state",
166
+ "focus", ...] }` to an allowlist and everything else stays db-only. Array fields
167
+ (`focus`) are clamped to `maxArrayLen` (default 100) and non-primitive elements
168
+ dropped, so a client can't post an unbounded array into metadata.
169
+ `applicantProfile(chapter, fields)` is exported to assert the exact shape in a
170
+ test before deleting a local override. (Default is back-compat today; expected
171
+ to tighten at 1.0.)
172
+ - **Email + input validation.** The field literally named `email` is checked
173
+ against a permissive email shape (400 on `"notanemail"`, so it fails cleanly
174
+ here rather than at the downstream Clerk create) — set
175
+ `application: { validateEmail: false }` to opt out; `isValidEmail` is exported.
176
+ A valid application is never newly rejected.
177
+ - **CRM enrichment.** `projectApplicant` writes the base identity/contact person.
178
+ To carry more of the application into the CRM, list `application: { crmFields:
179
+ [...] }` — each must be a field on your crm person type, else that field is
180
+ dropped and the base person still projects (it is never lost). Stage mirroring,
181
+ billing snapshots and Clerk-identity linking stay yours as host routes.
165
182
  - **Disclaimer acknowledgement.** A truthy `disclaimerAck` on the submit body
166
183
  (boolean or the string a plain form posts) stamps `disclaimerAckAt` from the
167
184
  *server* clock; no ack leaves the attr absent, and a client-supplied
168
- `disclaimerAckAt` is ignored. If your join page collects consent, post the flag —
169
- an unstamped row is the honest record that nobody acked, so a missing flag
170
- fails silently in exactly the way a compliance record must not.
185
+ `disclaimerAckAt` is ignored.
186
+
187
+ Unlike most data loss this is **unreconstructible** you cannot later
188
+ determine whether someone checked a box — so the submit response always echoes
189
+ `disclaimerAckAt` (a number, or `null` when nothing was recorded). Assert it in
190
+ one integration test and a join page that quietly stops posting the flag can't
191
+ go unnoticed.
192
+
193
+ **If the disclaimer is a compliance record, set
194
+ `application: { requireDisclaimerAck: true }`** and a submit with no ack is a
195
+ 400 instead of a row with no consent. It defaults to `false` so an upgrade
196
+ never starts rejecting traffic; enabling it fails *deterministically* on your
197
+ first test submit, not intermittently in production. Expect this to default to
198
+ `true` at 1.0.
171
199
  - **CRM projection points.** chapter projects the person on application submit
172
200
  (`projectApplicant`), not on booking or on webhook status change. If you mirror
173
201
  pipeline stage into the CRM, keep those routes.
@@ -177,6 +205,13 @@ These bite silently — a smoke test won't catch them:
177
205
 
178
206
  ### Install + scope notes
179
207
 
208
+ - **Your first admin is seeded in odla Studio, by hand — and the email must be
209
+ lowercased.** Neither `admins` nor `superAdmins` is ever written by a worker
210
+ route or a provisioning seed; that is deliberate, so nothing running in the app
211
+ (or injected into a page) can grant admin. The gate looks the email up
212
+ lowercased, so a row saved as `Ada@Example.com` matches nothing and the account
213
+ silently isn't an admin — with no error to tell you. Save it lowercase, then
214
+ confirm by signing in, not by looking at the row.
180
215
  - **`@odla-ai/auth-clerk` is not a chapter peer.** It is deliberately absent from
181
216
  this package's manifest, because only one entry imports it: the worker verifies
182
217
  JWTs with `jose` via `ctx.verifyUser`, and `@odla-ai/chapter/ui/member` is
@@ -201,6 +236,11 @@ These bite silently — a smoke test won't catch them:
201
236
  per-person comms, people list, role changes, approve and refund are out of
202
237
  scope** — they are yours as host routes via the seam. Don't plan around them
203
238
  shipping.
239
+ - **`onboardingInvite` is a template with no built-in caller.** It is seeded into
240
+ the group's email settings (so you can edit its copy) but chapter never fires
241
+ it, because the moment it would fire — approval — is a host route (approval is
242
+ out of scope, above). Send it from your own approve handler via `sendTemplated`;
243
+ the seed existing does not mean the built-ins send it.
204
244
 
205
245
  ### Verify from the types, not this file
206
246
 
package/dist/index.cjs CHANGED
@@ -33,6 +33,7 @@ __export(index_exports, {
33
33
  canTransition: () => canTransition,
34
34
  canceledPatch: () => canceledPatch,
35
35
  chapterDb: () => chapterDb,
36
+ clampArray: () => clampArray,
36
37
  clerkInviteRequest: () => clerkInviteRequest,
37
38
  clerkUserRequest: () => clerkUserRequest,
38
39
  createChapterIntegration: () => createChapterIntegration,
@@ -45,11 +46,13 @@ __export(index_exports, {
45
46
  findApplicationRef: () => findApplicationRef,
46
47
  firstPaymentPatch: () => firstPaymentPatch,
47
48
  getVaultSecret: () => getVaultSecret,
49
+ hasDisclaimerAck: () => hasDisclaimerAck,
48
50
  introIdempotencyKey: () => introIdempotencyKey,
49
51
  isAdminRole: () => isAdminRole,
50
52
  isAlreadySent: () => isAlreadySent,
51
53
  isReconcilable: () => isReconcilable,
52
54
  isSlotAvailable: () => isSlotAvailable,
55
+ isValidEmail: () => isValidEmail,
53
56
  joinConfig: () => joinConfig,
54
57
  meetingCreateRow: () => meetingCreateRow,
55
58
  meetingRescheduleUpdate: () => meetingRescheduleUpdate,
@@ -481,23 +484,47 @@ function resolveApplication(a) {
481
484
  throw new Error(`defineChapter.application.${name}: must be an array of field-name strings`);
482
485
  }
483
486
  }
487
+ if (a?.profileFields !== void 0 && (!Array.isArray(a.profileFields) || !a.profileFields.every((f) => typeof f === "string" && f !== ""))) {
488
+ throw new Error("defineChapter.application.profileFields: must be an array of field-name strings");
489
+ }
490
+ if (a?.crmFields !== void 0 && (!Array.isArray(a.crmFields) || !a.crmFields.every((f) => typeof f === "string" && f !== ""))) {
491
+ throw new Error("defineChapter.application.crmFields: must be an array of field-name strings");
492
+ }
484
493
  return {
485
494
  required,
486
495
  optional,
487
496
  maxLen: a?.maxLen ?? {},
488
497
  defaultMaxLen: a?.defaultMaxLen ?? 2e3,
489
- bodyCap: a?.bodyCap ?? 32768
498
+ bodyCap: a?.bodyCap ?? 32768,
499
+ requireDisclaimerAck: a?.requireDisclaimerAck ?? false,
500
+ profileFields: a?.profileFields ?? null,
501
+ crmFields: a?.crmFields ?? [],
502
+ maxArrayLen: a?.maxArrayLen ?? 100,
503
+ validateEmail: a?.validateEmail ?? true
490
504
  };
491
505
  }
506
+ var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
507
+ function isValidEmail(value) {
508
+ return typeof value === "string" && EMAIL_RE.test(value);
509
+ }
510
+ function clampArray(value, max) {
511
+ if (!Array.isArray(value)) return value;
512
+ return value.filter((x) => typeof x === "string" || typeof x === "number" || typeof x === "boolean").slice(0, max);
513
+ }
514
+ function hasDisclaimerAck(fields) {
515
+ return fields.disclaimerAck === true || fields.disclaimerAck === "true";
516
+ }
492
517
  var IDENTITY_FIELDS = /* @__PURE__ */ new Set(["email", "firstName", "lastName"]);
493
518
  function applicantProfile(chapter, fields) {
519
+ const app = chapter.application;
520
+ const allowed = (f) => app.profileFields === null || app.profileFields.includes(f);
494
521
  const profile = {};
495
- for (const f of [...chapter.application.required, ...chapter.application.optional]) {
496
- if (IDENTITY_FIELDS.has(f)) continue;
522
+ for (const f of [...app.required, ...app.optional]) {
523
+ if (IDENTITY_FIELDS.has(f) || !allowed(f)) continue;
497
524
  const v = fields[f];
498
525
  if (typeof v === "string" && v.trim() !== "") profile[f] = v.trim();
499
526
  }
500
- if (fields.focus !== void 0) profile.focus = fields.focus;
527
+ if (fields.focus !== void 0 && allowed("focus")) profile.focus = clampArray(fields.focus, app.maxArrayLen);
501
528
  return Object.keys(profile).length > 0 ? profile : void 0;
502
529
  }
503
530
  async function submitApplication(db, chapter, fields, opts) {
@@ -511,19 +538,26 @@ async function submitApplication(db, chapter, fields, opts) {
511
538
  const cap = app.maxLen[f] ?? app.defaultMaxLen;
512
539
  if (typeof v === "string" && v.length > cap) return { ok: false, error: `${f} exceeds ${cap} characters` };
513
540
  }
541
+ if (app.validateEmail && typeof fields.email === "string" && !isValidEmail(fields.email)) {
542
+ return { ok: false, error: "email must be a valid email address" };
543
+ }
544
+ const acked = hasDisclaimerAck(fields);
545
+ if (app.requireDisclaimerAck && !acked) {
546
+ return { ok: false, error: "disclaimerAck is required" };
547
+ }
514
548
  const id2 = opts.newId();
515
549
  const row = { id: id2, status: chapter.pipeline.initial, createdAt: opts.now };
516
550
  for (const f of [...app.required, ...app.optional]) {
517
551
  if (typeof fields[f] === "string") row[f] = fields[f].trim();
518
552
  }
519
- if (fields.focus !== void 0) row.focus = fields.focus;
553
+ if (fields.focus !== void 0) row.focus = clampArray(fields.focus, app.maxArrayLen);
520
554
  if (opts.groupId) row.groupId = opts.groupId;
521
- if (fields.disclaimerAck === true || fields.disclaimerAck === "true") row.disclaimerAckAt = opts.now;
555
+ if (acked) row.disclaimerAckAt = opts.now;
522
556
  const { duplicate } = await db.transact(
523
557
  [{ t: "update", ns: "applications", id: id2, attrs: row }],
524
558
  opts.submissionId ? { mutationId: `join:${opts.submissionId}` } : void 0
525
559
  );
526
- return { ok: true, id: id2, duplicate, status: chapter.pipeline.initial };
560
+ return { ok: true, id: id2, duplicate, status: chapter.pipeline.initial, disclaimerAckAt: acked ? opts.now : null };
527
561
  }
528
562
  function joinConfig(group, paymentsReady2) {
529
563
  return {
@@ -858,7 +892,7 @@ async function projectSharedRecord(deps, person) {
858
892
  return upsertPerson(deps, { email: person.email, input: sharedPersonInput(person), mutationId: `share:${person.hubRecordId}` });
859
893
  }
860
894
  async function projectApplicant(deps, applicant) {
861
- const input = sharedPersonInput({
895
+ const base = sharedPersonInput({
862
896
  email: applicant.email,
863
897
  firstName: applicant.firstName,
864
898
  lastName: applicant.lastName,
@@ -866,7 +900,14 @@ async function projectApplicant(deps, applicant) {
866
900
  linkedin: applicant.linkedin,
867
901
  hubRecordId: applicant.applicationId
868
902
  });
869
- return upsertPerson(deps, { email: applicant.email, input, mutationId: `apply:${applicant.applicationId}` });
903
+ const mutationId = `apply:${applicant.applicationId}`;
904
+ const extra = applicant.extra ?? {};
905
+ if (Object.keys(extra).length === 0) return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
906
+ try {
907
+ return await upsertPerson(deps, { email: applicant.email, input: { ...base, ...extra }, mutationId });
908
+ } catch {
909
+ return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
910
+ }
870
911
  }
871
912
 
872
913
  // src/clerk.ts