@odla-ai/chapter 0.10.1 → 0.11.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
@@ -165,9 +165,20 @@ These bite silently — a smoke test won't catch them:
165
165
  - **Disclaimer acknowledgement.** A truthy `disclaimerAck` on the submit body
166
166
  (boolean or the string a plain form posts) stamps `disclaimerAckAt` from the
167
167
  *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.
168
+ `disclaimerAckAt` is ignored.
169
+
170
+ Unlike most data loss this is **unreconstructible** you cannot later
171
+ determine whether someone checked a box — so the submit response always echoes
172
+ `disclaimerAckAt` (a number, or `null` when nothing was recorded). Assert it in
173
+ one integration test and a join page that quietly stops posting the flag can't
174
+ go unnoticed.
175
+
176
+ **If the disclaimer is a compliance record, set
177
+ `application: { requireDisclaimerAck: true }`** and a submit with no ack is a
178
+ 400 instead of a row with no consent. It defaults to `false` so an upgrade
179
+ never starts rejecting traffic; enabling it fails *deterministically* on your
180
+ first test submit, not intermittently in production. Expect this to default to
181
+ `true` at 1.0.
171
182
  - **CRM projection points.** chapter projects the person on application submit
172
183
  (`projectApplicant`), not on booking or on webhook status change. If you mirror
173
184
  pipeline stage into the CRM, keep those routes.
@@ -177,6 +188,13 @@ These bite silently — a smoke test won't catch them:
177
188
 
178
189
  ### Install + scope notes
179
190
 
191
+ - **Your first admin is seeded in odla Studio, by hand — and the email must be
192
+ lowercased.** Neither `admins` nor `superAdmins` is ever written by a worker
193
+ route or a provisioning seed; that is deliberate, so nothing running in the app
194
+ (or injected into a page) can grant admin. The gate looks the email up
195
+ lowercased, so a row saved as `Ada@Example.com` matches nothing and the account
196
+ silently isn't an admin — with no error to tell you. Save it lowercase, then
197
+ confirm by signing in, not by looking at the row.
180
198
  - **`@odla-ai/auth-clerk` is not a chapter peer.** It is deliberately absent from
181
199
  this package's manifest, because only one entry imports it: the worker verifies
182
200
  JWTs with `jose` via `ctx.verifyUser`, and `@odla-ai/chapter/ui/member` is
package/dist/index.cjs CHANGED
@@ -45,6 +45,7 @@ __export(index_exports, {
45
45
  findApplicationRef: () => findApplicationRef,
46
46
  firstPaymentPatch: () => firstPaymentPatch,
47
47
  getVaultSecret: () => getVaultSecret,
48
+ hasDisclaimerAck: () => hasDisclaimerAck,
48
49
  introIdempotencyKey: () => introIdempotencyKey,
49
50
  isAdminRole: () => isAdminRole,
50
51
  isAlreadySent: () => isAlreadySent,
@@ -486,9 +487,13 @@ function resolveApplication(a) {
486
487
  optional,
487
488
  maxLen: a?.maxLen ?? {},
488
489
  defaultMaxLen: a?.defaultMaxLen ?? 2e3,
489
- bodyCap: a?.bodyCap ?? 32768
490
+ bodyCap: a?.bodyCap ?? 32768,
491
+ requireDisclaimerAck: a?.requireDisclaimerAck ?? false
490
492
  };
491
493
  }
494
+ function hasDisclaimerAck(fields) {
495
+ return fields.disclaimerAck === true || fields.disclaimerAck === "true";
496
+ }
492
497
  var IDENTITY_FIELDS = /* @__PURE__ */ new Set(["email", "firstName", "lastName"]);
493
498
  function applicantProfile(chapter, fields) {
494
499
  const profile = {};
@@ -511,6 +516,10 @@ async function submitApplication(db, chapter, fields, opts) {
511
516
  const cap = app.maxLen[f] ?? app.defaultMaxLen;
512
517
  if (typeof v === "string" && v.length > cap) return { ok: false, error: `${f} exceeds ${cap} characters` };
513
518
  }
519
+ const acked = hasDisclaimerAck(fields);
520
+ if (app.requireDisclaimerAck && !acked) {
521
+ return { ok: false, error: "disclaimerAck is required" };
522
+ }
514
523
  const id2 = opts.newId();
515
524
  const row = { id: id2, status: chapter.pipeline.initial, createdAt: opts.now };
516
525
  for (const f of [...app.required, ...app.optional]) {
@@ -518,12 +527,12 @@ async function submitApplication(db, chapter, fields, opts) {
518
527
  }
519
528
  if (fields.focus !== void 0) row.focus = fields.focus;
520
529
  if (opts.groupId) row.groupId = opts.groupId;
521
- if (fields.disclaimerAck === true || fields.disclaimerAck === "true") row.disclaimerAckAt = opts.now;
530
+ if (acked) row.disclaimerAckAt = opts.now;
522
531
  const { duplicate } = await db.transact(
523
532
  [{ t: "update", ns: "applications", id: id2, attrs: row }],
524
533
  opts.submissionId ? { mutationId: `join:${opts.submissionId}` } : void 0
525
534
  );
526
- return { ok: true, id: id2, duplicate, status: chapter.pipeline.initial };
535
+ return { ok: true, id: id2, duplicate, status: chapter.pipeline.initial, disclaimerAckAt: acked ? opts.now : null };
527
536
  }
528
537
  function joinConfig(group, paymentsReady2) {
529
538
  return {