@odla-ai/chapter 0.11.0 → 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.
@@ -160,6 +160,26 @@ interface ChapterApplication {
160
160
  * otherwise silent, permanent and unreconstructible. Failure is deterministic
161
161
  * and surfaces on the first test submit, not intermittently in production. */
162
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;
163
183
  }
164
184
  /** The fully-resolved application config carried on the {@link Chapter}. */
165
185
  interface ResolvedApplication {
@@ -169,6 +189,11 @@ interface ResolvedApplication {
169
189
  defaultMaxLen: number;
170
190
  bodyCap: number;
171
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;
172
197
  }
173
198
  /** The `defineChapter()` config a site fills in. */
174
199
  interface ChapterConfig {
@@ -160,6 +160,26 @@ interface ChapterApplication {
160
160
  * otherwise silent, permanent and unreconstructible. Failure is deterministic
161
161
  * and surfaces on the first test submit, not intermittently in production. */
162
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;
163
183
  }
164
184
  /** The fully-resolved application config carried on the {@link Chapter}. */
165
185
  interface ResolvedApplication {
@@ -169,6 +189,11 @@ interface ResolvedApplication {
169
189
  defaultMaxLen: number;
170
190
  bodyCap: number;
171
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;
172
197
  }
173
198
  /** The `defineChapter()` config a site fills in. */
174
199
  interface ChapterConfig {
@@ -157,18 +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
+ }
160
168
  function hasDisclaimerAck(fields) {
161
169
  return fields.disclaimerAck === true || fields.disclaimerAck === "true";
162
170
  }
163
171
  var IDENTITY_FIELDS = /* @__PURE__ */ new Set(["email", "firstName", "lastName"]);
164
172
  function applicantProfile(chapter, fields) {
173
+ const app = chapter.application;
174
+ const allowed = (f) => app.profileFields === null || app.profileFields.includes(f);
165
175
  const profile = {};
166
- for (const f of [...chapter.application.required, ...chapter.application.optional]) {
167
- if (IDENTITY_FIELDS.has(f)) continue;
176
+ for (const f of [...app.required, ...app.optional]) {
177
+ if (IDENTITY_FIELDS.has(f) || !allowed(f)) continue;
168
178
  const v = fields[f];
169
179
  if (typeof v === "string" && v.trim() !== "") profile[f] = v.trim();
170
180
  }
171
- if (fields.focus !== void 0) profile.focus = fields.focus;
181
+ if (fields.focus !== void 0 && allowed("focus")) profile.focus = clampArray(fields.focus, app.maxArrayLen);
172
182
  return Object.keys(profile).length > 0 ? profile : void 0;
173
183
  }
174
184
  async function submitApplication(db, chapter, fields, opts) {
@@ -182,6 +192,9 @@ async function submitApplication(db, chapter, fields, opts) {
182
192
  const cap = app.maxLen[f] ?? app.defaultMaxLen;
183
193
  if (typeof v === "string" && v.length > cap) return { ok: false, error: `${f} exceeds ${cap} characters` };
184
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
+ }
185
198
  const acked = hasDisclaimerAck(fields);
186
199
  if (app.requireDisclaimerAck && !acked) {
187
200
  return { ok: false, error: "disclaimerAck is required" };
@@ -191,7 +204,7 @@ async function submitApplication(db, chapter, fields, opts) {
191
204
  for (const f of [...app.required, ...app.optional]) {
192
205
  if (typeof fields[f] === "string") row[f] = fields[f].trim();
193
206
  }
194
- if (fields.focus !== void 0) row.focus = fields.focus;
207
+ if (fields.focus !== void 0) row.focus = clampArray(fields.focus, app.maxArrayLen);
195
208
  if (opts.groupId) row.groupId = opts.groupId;
196
209
  if (acked) row.disclaimerAckAt = opts.now;
197
210
  const { duplicate } = await db.transact(
@@ -244,7 +257,7 @@ async function projectSharedRecord(deps, person) {
244
257
  return upsertPerson(deps, { email: person.email, input: sharedPersonInput(person), mutationId: `share:${person.hubRecordId}` });
245
258
  }
246
259
  async function projectApplicant(deps, applicant) {
247
- const input = sharedPersonInput({
260
+ const base = sharedPersonInput({
248
261
  email: applicant.email,
249
262
  firstName: applicant.firstName,
250
263
  lastName: applicant.lastName,
@@ -252,7 +265,14 @@ async function projectApplicant(deps, applicant) {
252
265
  linkedin: applicant.linkedin,
253
266
  hubRecordId: applicant.applicationId
254
267
  });
255
- 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
+ }
256
276
  }
257
277
 
258
278
  // src/scheduling.ts
@@ -496,10 +516,14 @@ async function provisionApplicant(db, chapter, applicationId, fields) {
496
516
  const email = typeof fields.email === "string" ? fields.email : "";
497
517
  if (!email) return;
498
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
+ }
499
523
  try {
500
524
  await projectApplicant(
501
525
  { crm: chapter.crm, db, now: () => Date.now(), newId: () => crypto.randomUUID() },
502
- { 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 }
503
527
  );
504
528
  } catch {
505
529
  }