@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.
@@ -154,6 +154,32 @@ interface ChapterApplication {
154
154
  defaultMaxLen?: number;
155
155
  /** Max JSON request body in bytes. Default 32768. */
156
156
  bodyCap?: number;
157
+ /** Reject a submit that carries no truthy `disclaimerAck` (400), instead of
158
+ * writing a row with no consent record. Default `false` for back-compat —
159
+ * but turn it on if the disclaimer is a compliance record: a missing ack is
160
+ * otherwise silent, permanent and unreconstructible. Failure is deterministic
161
+ * and surfaces on the first test submit, not intermittently in production. */
162
+ requireDisclaimerAck?: boolean;
163
+ /** Allowlist of fields that reach the Clerk account's client-readable
164
+ * `public_metadata.profile`. Default (unset) projects every non-identity
165
+ * configured field — convenient, but it also exposes free-text and
166
+ * third-party fields (`message`, `referral`). Set this to a curated list
167
+ * (e.g. `["phone", "state", "focus"]`) to keep confidential fields db-only.
168
+ * Expected to become required-in-spirit at 1.0. */
169
+ profileFields?: readonly string[];
170
+ /** Extra application fields carried into the one-way CRM projection, on top of
171
+ * the built-in identity/contact set. Each MUST be declared on your crm person
172
+ * type or the enrichment is dropped (the base person still projects). Default
173
+ * none. */
174
+ crmFields?: readonly string[];
175
+ /** Cap on the element count of array-valued fields (e.g. `focus`), so a client
176
+ * cannot post a 10k-element array into a row or into Clerk metadata.
177
+ * Non-primitive elements are dropped. Default 100. */
178
+ maxArrayLen?: number;
179
+ /** Validate that a field literally named `email` looks like an email address,
180
+ * returning a 400 rather than accepting input the downstream Clerk create will
181
+ * reject anyway. Default `true`; a valid application is never newly rejected. */
182
+ validateEmail?: boolean;
157
183
  }
158
184
  /** The fully-resolved application config carried on the {@link Chapter}. */
159
185
  interface ResolvedApplication {
@@ -162,6 +188,12 @@ interface ResolvedApplication {
162
188
  maxLen: Record<string, number>;
163
189
  defaultMaxLen: number;
164
190
  bodyCap: number;
191
+ requireDisclaimerAck: boolean;
192
+ /** Resolved Clerk-metadata allowlist; `null` means "all non-identity fields". */
193
+ profileFields: readonly string[] | null;
194
+ crmFields: readonly string[];
195
+ maxArrayLen: number;
196
+ validateEmail: boolean;
165
197
  }
166
198
  /** The `defineChapter()` config a site fills in. */
167
199
  interface ChapterConfig {
@@ -154,6 +154,32 @@ interface ChapterApplication {
154
154
  defaultMaxLen?: number;
155
155
  /** Max JSON request body in bytes. Default 32768. */
156
156
  bodyCap?: number;
157
+ /** Reject a submit that carries no truthy `disclaimerAck` (400), instead of
158
+ * writing a row with no consent record. Default `false` for back-compat —
159
+ * but turn it on if the disclaimer is a compliance record: a missing ack is
160
+ * otherwise silent, permanent and unreconstructible. Failure is deterministic
161
+ * and surfaces on the first test submit, not intermittently in production. */
162
+ requireDisclaimerAck?: boolean;
163
+ /** Allowlist of fields that reach the Clerk account's client-readable
164
+ * `public_metadata.profile`. Default (unset) projects every non-identity
165
+ * configured field — convenient, but it also exposes free-text and
166
+ * third-party fields (`message`, `referral`). Set this to a curated list
167
+ * (e.g. `["phone", "state", "focus"]`) to keep confidential fields db-only.
168
+ * Expected to become required-in-spirit at 1.0. */
169
+ profileFields?: readonly string[];
170
+ /** Extra application fields carried into the one-way CRM projection, on top of
171
+ * the built-in identity/contact set. Each MUST be declared on your crm person
172
+ * type or the enrichment is dropped (the base person still projects). Default
173
+ * none. */
174
+ crmFields?: readonly string[];
175
+ /** Cap on the element count of array-valued fields (e.g. `focus`), so a client
176
+ * cannot post a 10k-element array into a row or into Clerk metadata.
177
+ * Non-primitive elements are dropped. Default 100. */
178
+ maxArrayLen?: number;
179
+ /** Validate that a field literally named `email` looks like an email address,
180
+ * returning a 400 rather than accepting input the downstream Clerk create will
181
+ * reject anyway. Default `true`; a valid application is never newly rejected. */
182
+ validateEmail?: boolean;
157
183
  }
158
184
  /** The fully-resolved application config carried on the {@link Chapter}. */
159
185
  interface ResolvedApplication {
@@ -162,6 +188,12 @@ interface ResolvedApplication {
162
188
  maxLen: Record<string, number>;
163
189
  defaultMaxLen: number;
164
190
  bodyCap: number;
191
+ requireDisclaimerAck: boolean;
192
+ /** Resolved Clerk-metadata allowlist; `null` means "all non-identity fields". */
193
+ profileFields: readonly string[] | null;
194
+ crmFields: readonly string[];
195
+ maxArrayLen: number;
196
+ validateEmail: boolean;
165
197
  }
166
198
  /** The `defineChapter()` config a site fills in. */
167
199
  interface ChapterConfig {
@@ -157,15 +157,28 @@ async function createClerkUser(secretKey, input, fetchImpl = fetch) {
157
157
  }
158
158
 
159
159
  // src/member.ts
160
+ var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
161
+ function isValidEmail(value) {
162
+ return typeof value === "string" && EMAIL_RE.test(value);
163
+ }
164
+ function clampArray(value, max) {
165
+ if (!Array.isArray(value)) return value;
166
+ return value.filter((x) => typeof x === "string" || typeof x === "number" || typeof x === "boolean").slice(0, max);
167
+ }
168
+ function hasDisclaimerAck(fields) {
169
+ return fields.disclaimerAck === true || fields.disclaimerAck === "true";
170
+ }
160
171
  var IDENTITY_FIELDS = /* @__PURE__ */ new Set(["email", "firstName", "lastName"]);
161
172
  function applicantProfile(chapter, fields) {
173
+ const app = chapter.application;
174
+ const allowed = (f) => app.profileFields === null || app.profileFields.includes(f);
162
175
  const profile = {};
163
- for (const f of [...chapter.application.required, ...chapter.application.optional]) {
164
- if (IDENTITY_FIELDS.has(f)) continue;
176
+ for (const f of [...app.required, ...app.optional]) {
177
+ if (IDENTITY_FIELDS.has(f) || !allowed(f)) continue;
165
178
  const v = fields[f];
166
179
  if (typeof v === "string" && v.trim() !== "") profile[f] = v.trim();
167
180
  }
168
- if (fields.focus !== void 0) profile.focus = fields.focus;
181
+ if (fields.focus !== void 0 && allowed("focus")) profile.focus = clampArray(fields.focus, app.maxArrayLen);
169
182
  return Object.keys(profile).length > 0 ? profile : void 0;
170
183
  }
171
184
  async function submitApplication(db, chapter, fields, opts) {
@@ -179,19 +192,26 @@ async function submitApplication(db, chapter, fields, opts) {
179
192
  const cap = app.maxLen[f] ?? app.defaultMaxLen;
180
193
  if (typeof v === "string" && v.length > cap) return { ok: false, error: `${f} exceeds ${cap} characters` };
181
194
  }
195
+ if (app.validateEmail && typeof fields.email === "string" && !isValidEmail(fields.email)) {
196
+ return { ok: false, error: "email must be a valid email address" };
197
+ }
198
+ const acked = hasDisclaimerAck(fields);
199
+ if (app.requireDisclaimerAck && !acked) {
200
+ return { ok: false, error: "disclaimerAck is required" };
201
+ }
182
202
  const id = opts.newId();
183
203
  const row = { id, status: chapter.pipeline.initial, createdAt: opts.now };
184
204
  for (const f of [...app.required, ...app.optional]) {
185
205
  if (typeof fields[f] === "string") row[f] = fields[f].trim();
186
206
  }
187
- if (fields.focus !== void 0) row.focus = fields.focus;
207
+ if (fields.focus !== void 0) row.focus = clampArray(fields.focus, app.maxArrayLen);
188
208
  if (opts.groupId) row.groupId = opts.groupId;
189
- if (fields.disclaimerAck === true || fields.disclaimerAck === "true") row.disclaimerAckAt = opts.now;
209
+ if (acked) row.disclaimerAckAt = opts.now;
190
210
  const { duplicate } = await db.transact(
191
211
  [{ t: "update", ns: "applications", id, attrs: row }],
192
212
  opts.submissionId ? { mutationId: `join:${opts.submissionId}` } : void 0
193
213
  );
194
- return { ok: true, id, duplicate, status: chapter.pipeline.initial };
214
+ return { ok: true, id, duplicate, status: chapter.pipeline.initial, disclaimerAckAt: acked ? opts.now : null };
195
215
  }
196
216
  function joinConfig(group, paymentsReady) {
197
217
  return {
@@ -237,7 +257,7 @@ async function projectSharedRecord(deps, person) {
237
257
  return upsertPerson(deps, { email: person.email, input: sharedPersonInput(person), mutationId: `share:${person.hubRecordId}` });
238
258
  }
239
259
  async function projectApplicant(deps, applicant) {
240
- const input = sharedPersonInput({
260
+ const base = sharedPersonInput({
241
261
  email: applicant.email,
242
262
  firstName: applicant.firstName,
243
263
  lastName: applicant.lastName,
@@ -245,7 +265,14 @@ async function projectApplicant(deps, applicant) {
245
265
  linkedin: applicant.linkedin,
246
266
  hubRecordId: applicant.applicationId
247
267
  });
248
- return upsertPerson(deps, { email: applicant.email, input, mutationId: `apply:${applicant.applicationId}` });
268
+ const mutationId = `apply:${applicant.applicationId}`;
269
+ const extra = applicant.extra ?? {};
270
+ if (Object.keys(extra).length === 0) return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
271
+ try {
272
+ return await upsertPerson(deps, { email: applicant.email, input: { ...base, ...extra }, mutationId });
273
+ } catch {
274
+ return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
275
+ }
249
276
  }
250
277
 
251
278
  // src/scheduling.ts
@@ -489,10 +516,14 @@ async function provisionApplicant(db, chapter, applicationId, fields) {
489
516
  const email = typeof fields.email === "string" ? fields.email : "";
490
517
  if (!email) return;
491
518
  const s = (v) => typeof v === "string" ? v : void 0;
519
+ const extra = {};
520
+ for (const f of chapter.application.crmFields) {
521
+ if (fields[f] !== void 0) extra[f] = fields[f];
522
+ }
492
523
  try {
493
524
  await projectApplicant(
494
525
  { crm: chapter.crm, db, now: () => Date.now(), newId: () => crypto.randomUUID() },
495
- { applicationId, email, firstName: s(fields.firstName), lastName: s(fields.lastName), phone: s(fields.phone), linkedin: s(fields.linkedin) }
526
+ { applicationId, email, firstName: s(fields.firstName), lastName: s(fields.lastName), phone: s(fields.phone), linkedin: s(fields.linkedin), extra }
496
527
  );
497
528
  } catch {
498
529
  }
@@ -647,7 +678,14 @@ var handleMember = async (req, url, env, ctx) => {
647
678
  }
648
679
  await provisionApplicant(db, chapter, result.id, parsed);
649
680
  }
650
- return json({ id: result.id, duplicate: result.duplicate, status: result.status });
681
+ return json({
682
+ id: result.id,
683
+ duplicate: result.duplicate,
684
+ status: result.status,
685
+ // Echoed so a site can see (and assert in an integration test) whether its
686
+ // join page actually posted the ack. Absent consent is otherwise invisible.
687
+ disclaimerAckAt: result.disclaimerAckAt
688
+ });
651
689
  }
652
690
  return null;
653
691
  };