@odla-ai/chapter 0.11.0 → 0.13.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/dist/index.d.cts CHANGED
@@ -181,6 +181,26 @@ interface ChapterApplication {
181
181
  * otherwise silent, permanent and unreconstructible. Failure is deterministic
182
182
  * and surfaces on the first test submit, not intermittently in production. */
183
183
  requireDisclaimerAck?: boolean;
184
+ /** Allowlist of fields that reach the Clerk account's client-readable
185
+ * `public_metadata.profile`. Default (unset) projects every non-identity
186
+ * configured field — convenient, but it also exposes free-text and
187
+ * third-party fields (`message`, `referral`). Set this to a curated list
188
+ * (e.g. `["phone", "state", "focus"]`) to keep confidential fields db-only.
189
+ * Expected to become required-in-spirit at 1.0. */
190
+ profileFields?: readonly string[];
191
+ /** Extra application fields carried into the one-way CRM projection, on top of
192
+ * the built-in identity/contact set. Each MUST be declared on your crm person
193
+ * type or the enrichment is dropped (the base person still projects). Default
194
+ * none. */
195
+ crmFields?: readonly string[];
196
+ /** Cap on the element count of array-valued fields (e.g. `focus`), so a client
197
+ * cannot post a 10k-element array into a row or into Clerk metadata.
198
+ * Non-primitive elements are dropped. Default 100. */
199
+ maxArrayLen?: number;
200
+ /** Validate that a field literally named `email` looks like an email address,
201
+ * returning a 400 rather than accepting input the downstream Clerk create will
202
+ * reject anyway. Default `true`; a valid application is never newly rejected. */
203
+ validateEmail?: boolean;
184
204
  }
185
205
  /** The fully-resolved application config carried on the {@link Chapter}. */
186
206
  interface ResolvedApplication {
@@ -190,6 +210,11 @@ interface ResolvedApplication {
190
210
  defaultMaxLen: number;
191
211
  bodyCap: number;
192
212
  requireDisclaimerAck: boolean;
213
+ /** Resolved Clerk-metadata allowlist; `null` means "all non-identity fields". */
214
+ profileFields: readonly string[] | null;
215
+ crmFields: readonly string[];
216
+ maxArrayLen: number;
217
+ validateEmail: boolean;
193
218
  }
194
219
  /** The `defineChapter()` config a site fills in. */
195
220
  interface ChapterConfig {
@@ -632,15 +657,22 @@ declare function canceledPatch(): {
632
657
 
633
658
  /** Apply defaults + validate the application config. Throws at import on bad shape. */
634
659
  declare function resolveApplication(a: ChapterApplication | undefined): ResolvedApplication;
660
+ /** Whether a string looks like an email address (see {@link EMAIL_RE}). Exported
661
+ * so a site building its own submit path applies the same rule chapter does. */
662
+ declare function isValidEmail(value: unknown): boolean;
663
+ /** Bound an array-valued field: drop non-primitive elements and cap the length,
664
+ * so a client cannot post an unbounded array. Non-arrays pass through unchanged. */
665
+ declare function clampArray(value: unknown, max: number): unknown;
635
666
  /** Whether a submit body carries a genuine disclaimer acknowledgement. Accepts
636
667
  * the boolean an API client sends and the string a plain HTML form posts. */
637
668
  declare function hasDisclaimerAck(fields: Record<string, unknown>): boolean;
638
669
  /**
639
- * The applicant profile written to the Clerk account's `public_metadata.profile`:
640
- * every configured application field Clerk does not already carry natively, plus
641
- * `focus`. Derived from `application.required`/`optional`, so a site's own field
642
- * names project without this package knowing them. Pure; returns `undefined` when
643
- * there is nothing to write.
670
+ * The applicant profile written to the Clerk account's client-readable
671
+ * `public_metadata.profile`. Projects each configured non-identity field, plus
672
+ * `focus` (clamped) but ONLY those in `application.profileFields` when that
673
+ * allowlist is set, so a site keeps confidential fields (`message`, `referral`)
674
+ * db-only. Derived from config, so a site's own field names project without this
675
+ * package knowing them. Pure; returns `undefined` when there is nothing to write.
644
676
  */
645
677
  declare function applicantProfile(chapter: Chapter, fields: Record<string, unknown>): Record<string, unknown> | undefined;
646
678
  /** A validated submission, or a 400-worthy validation error the route returns.
@@ -722,7 +754,9 @@ interface ProjectionDeps {
722
754
  declare function projectSharedRecord(deps: ProjectionDeps, person: SharedPerson): Promise<{
723
755
  recordId: string;
724
756
  }>;
725
- /** An arriving applicant, as far as the CRM projection cares. */
757
+ /** An arriving applicant, as far as the CRM projection cares. `extra` carries the
758
+ * site-configured `crmFields` (values from the application), merged on top of the
759
+ * built-in identity/contact set. */
726
760
  interface Applicant {
727
761
  applicationId: string;
728
762
  email: string;
@@ -730,12 +764,18 @@ interface Applicant {
730
764
  lastName?: string;
731
765
  phone?: string;
732
766
  linkedin?: string;
767
+ extra?: Record<string, unknown>;
733
768
  }
734
769
  /**
735
770
  * Project an arriving applicant into this chapter's `crm_record`, so a new
736
771
  * application shows up in the CRM immediately, unified by email with any prior
737
772
  * record. Idempotent per application (`apply:${applicationId}`). Best-effort at
738
773
  * the call site — a projection failure never fails the application.
774
+ *
775
+ * `extra` fields (a site's `crmFields`) are merged on top of the base person. If
776
+ * an extra field is not on the crm person type, crm validation throws — so the
777
+ * projection retries with the base person alone, ensuring a misconfigured
778
+ * enrichment never silently drops the applicant from the CRM entirely.
739
779
  */
740
780
  declare function projectApplicant(deps: ProjectionDeps, applicant: Applicant): Promise<{
741
781
  recordId: string;
@@ -793,6 +833,32 @@ declare function clerkUserRequest(input: ClerkUserInput): {
793
833
  * re-application repairs a previously missed create (`refreshed: true`). */
794
834
  declare function createClerkUser(secretKey: string, input: ClerkUserInput, fetchImpl?: typeof fetch): Promise<ClerkResult>;
795
835
 
836
+ /** A Clerk user as chapter's role layer sees it. `role` defaults to the lowest
837
+ * rung when `public_metadata` carries none; `publicMetadata` is returned raw so a
838
+ * site with a custom ladder can re-derive it. */
839
+ interface ClerkUserRecord {
840
+ id: string;
841
+ email?: string;
842
+ role: string;
843
+ publicMetadata: Record<string, unknown>;
844
+ }
845
+ /** Look a Clerk user up by email. `null` when no such user — or when the lookup
846
+ * fails (a role gate treats an unresolvable user as absent, matching the site's
847
+ * own fallback). Role defaults to provisional when unset. */
848
+ declare function clerkGetUserByEmail(secretKey: string, email: string, fetchImpl?: typeof fetch): Promise<ClerkUserRecord | null>;
849
+ /** Fetch a Clerk user by id, for role-change gating. `null` when missing or on a
850
+ * failed lookup. Role defaults to provisional when unset. */
851
+ declare function clerkGetUser(secretKey: string, id: string, fetchImpl?: typeof fetch): Promise<ClerkUserRecord | null>;
852
+ /** List ALL Clerk users with their roles, auto-paginating. A membership community
853
+ * outgrows one page, and a fixed `limit=100` would silently drop members from the
854
+ * admin roster with no error — so this pages (offset in steps of {@link PAGE})
855
+ * until a short page. A page fetch that fails THROWS rather than returning a
856
+ * partial list, so the caller never mistakes a truncated roster for the whole. */
857
+ declare function clerkListUsers(secretKey: string, fetchImpl?: typeof fetch): Promise<ClerkUserRecord[]>;
858
+ /** Write a user's role: a MERGE-PATCH of only `{ role }` on `public_metadata`, so
859
+ * it leaves a separately-written `profile` untouched. Returns whether it stuck. */
860
+ declare function clerkSetRole(secretKey: string, id: string, role: string, fetchImpl?: typeof fetch): Promise<boolean>;
861
+
796
862
  /** An application row, as far as the session cares about it. */
797
863
  interface ApplicationRecord {
798
864
  id: string;
@@ -1031,4 +1097,4 @@ type ApplicationBookingPatch = {
1031
1097
  * already there (never backward). */
1032
1098
  declare function applicationBookingUpdate(currentStatus: string, startAt: number, htmlLink?: string | null): ApplicationBookingPatch;
1033
1099
 
1034
- export { type AccountModel, type AdminNotificationTrigger, type Applicant, type ApplicationBookingPatch, type ApplicationRecord, type ApplicationSummary, type Attr, type AttrType, type Chapter, type ChapterApplication, type ChapterAuth, type ChapterBrand, type ChapterConfig, type ChapterDb, type ChapterEmails, type ChapterIntegrationDescriptor, type ChapterIntegrationOptions, type ChapterMode, type ChapterPipeline, type ChapterPolicy, type ChapterPrices, type ChapterScheduling, type ChapterSends, type ClerkInviteInput, type ClerkResult, type ClerkUserInput, type DbOp, type DbRules, type DbSchema, type DeliveryDecision, type EmailGroup, type EmailTemplate, type EmailTemplateRow, type Entity, type ExistingMeeting, type GuardResult, type JoinConfigGroup, type LiveEvent, type MailSender, type MeetingForReconcile, type MeetingRecord, type MeetingReschedulePatch, type MemberApplication, type MemberSession, type NewMeetingRow, type NotifyDeps, type NotifyInput, type NotifyResult, type PaymentsGroup, type ProjectionDeps, type ReconcileDecision, type ResolvedApplication, type ResolvedAuth, type ResolvedPipeline, type ResolvedScheduling, type ResolvedSends, type RoleChangeContext, type Rule, SCHEDULING_DEFAULTS, type SchedulingErrors, type SecretStore, type SessionUser, type SharedPerson, type StripeEvent, type SubmitResult, type WebhookEvent, applicantProfile, applicationBookingUpdate, applicationSummary, bookingDecision, brandTokens, buildGroupSeed, canApprove, canBook, canChangeRole, canTransition, canceledPatch, chapterDb, clerkInviteRequest, clerkUserRequest, createChapterIntegration, createClerkInvitation, createClerkUser, defaultCrm, defineChapter, emailGroupFrom, endForSlot, findApplicationRef, firstPaymentPatch, getVaultSecret, hasDisclaimerAck, introIdempotencyKey, isAdminRole, isAlreadySent, isReconcilable, isSlotAvailable, joinConfig, meetingCreateRow, meetingRescheduleUpdate, memberApplication, memberSession, normalizeWebhookEvent, paymentsReady, planDelivery, projectApplicant, projectSharedRecord, reconcileMeetings, refundedPatch, render, renderSummary, renderTemplateBody, renewalPatch, resolveApplication, resolveAuth, resolvePipeline, resolveScheduling, roleFromClaim, sendTemplated, sharedPersonInput, slotWindow, stageIndex, stripeForm, submitApplication, subscriptionIdempotencyKey, validateScheduling, verifyStripeSignature, webhookMutationId };
1100
+ export { type AccountModel, type AdminNotificationTrigger, type Applicant, type ApplicationBookingPatch, type ApplicationRecord, type ApplicationSummary, type Attr, type AttrType, type Chapter, type ChapterApplication, type ChapterAuth, type ChapterBrand, type ChapterConfig, type ChapterDb, type ChapterEmails, type ChapterIntegrationDescriptor, type ChapterIntegrationOptions, type ChapterMode, type ChapterPipeline, type ChapterPolicy, type ChapterPrices, type ChapterScheduling, type ChapterSends, type ClerkInviteInput, type ClerkResult, type ClerkUserInput, type ClerkUserRecord, type DbOp, type DbRules, type DbSchema, type DeliveryDecision, type EmailGroup, type EmailTemplate, type EmailTemplateRow, type Entity, type ExistingMeeting, type GuardResult, type JoinConfigGroup, type LiveEvent, type MailSender, type MeetingForReconcile, type MeetingRecord, type MeetingReschedulePatch, type MemberApplication, type MemberSession, type NewMeetingRow, type NotifyDeps, type NotifyInput, type NotifyResult, type PaymentsGroup, type ProjectionDeps, type ReconcileDecision, type ResolvedApplication, type ResolvedAuth, type ResolvedPipeline, type ResolvedScheduling, type ResolvedSends, type RoleChangeContext, type Rule, SCHEDULING_DEFAULTS, type SchedulingErrors, type SecretStore, type SessionUser, type SharedPerson, type StripeEvent, type SubmitResult, type WebhookEvent, applicantProfile, applicationBookingUpdate, applicationSummary, bookingDecision, brandTokens, buildGroupSeed, canApprove, canBook, canChangeRole, canTransition, canceledPatch, chapterDb, clampArray, clerkGetUser, clerkGetUserByEmail, clerkInviteRequest, clerkListUsers, clerkSetRole, clerkUserRequest, createChapterIntegration, createClerkInvitation, createClerkUser, defaultCrm, defineChapter, emailGroupFrom, endForSlot, findApplicationRef, firstPaymentPatch, getVaultSecret, hasDisclaimerAck, introIdempotencyKey, isAdminRole, isAlreadySent, isReconcilable, isSlotAvailable, isValidEmail, joinConfig, meetingCreateRow, meetingRescheduleUpdate, memberApplication, memberSession, normalizeWebhookEvent, paymentsReady, planDelivery, projectApplicant, projectSharedRecord, reconcileMeetings, refundedPatch, render, renderSummary, renderTemplateBody, renewalPatch, resolveApplication, resolveAuth, resolvePipeline, resolveScheduling, roleFromClaim, sendTemplated, sharedPersonInput, slotWindow, stageIndex, stripeForm, submitApplication, subscriptionIdempotencyKey, validateScheduling, verifyStripeSignature, webhookMutationId };
package/dist/index.d.ts CHANGED
@@ -181,6 +181,26 @@ interface ChapterApplication {
181
181
  * otherwise silent, permanent and unreconstructible. Failure is deterministic
182
182
  * and surfaces on the first test submit, not intermittently in production. */
183
183
  requireDisclaimerAck?: boolean;
184
+ /** Allowlist of fields that reach the Clerk account's client-readable
185
+ * `public_metadata.profile`. Default (unset) projects every non-identity
186
+ * configured field — convenient, but it also exposes free-text and
187
+ * third-party fields (`message`, `referral`). Set this to a curated list
188
+ * (e.g. `["phone", "state", "focus"]`) to keep confidential fields db-only.
189
+ * Expected to become required-in-spirit at 1.0. */
190
+ profileFields?: readonly string[];
191
+ /** Extra application fields carried into the one-way CRM projection, on top of
192
+ * the built-in identity/contact set. Each MUST be declared on your crm person
193
+ * type or the enrichment is dropped (the base person still projects). Default
194
+ * none. */
195
+ crmFields?: readonly string[];
196
+ /** Cap on the element count of array-valued fields (e.g. `focus`), so a client
197
+ * cannot post a 10k-element array into a row or into Clerk metadata.
198
+ * Non-primitive elements are dropped. Default 100. */
199
+ maxArrayLen?: number;
200
+ /** Validate that a field literally named `email` looks like an email address,
201
+ * returning a 400 rather than accepting input the downstream Clerk create will
202
+ * reject anyway. Default `true`; a valid application is never newly rejected. */
203
+ validateEmail?: boolean;
184
204
  }
185
205
  /** The fully-resolved application config carried on the {@link Chapter}. */
186
206
  interface ResolvedApplication {
@@ -190,6 +210,11 @@ interface ResolvedApplication {
190
210
  defaultMaxLen: number;
191
211
  bodyCap: number;
192
212
  requireDisclaimerAck: boolean;
213
+ /** Resolved Clerk-metadata allowlist; `null` means "all non-identity fields". */
214
+ profileFields: readonly string[] | null;
215
+ crmFields: readonly string[];
216
+ maxArrayLen: number;
217
+ validateEmail: boolean;
193
218
  }
194
219
  /** The `defineChapter()` config a site fills in. */
195
220
  interface ChapterConfig {
@@ -632,15 +657,22 @@ declare function canceledPatch(): {
632
657
 
633
658
  /** Apply defaults + validate the application config. Throws at import on bad shape. */
634
659
  declare function resolveApplication(a: ChapterApplication | undefined): ResolvedApplication;
660
+ /** Whether a string looks like an email address (see {@link EMAIL_RE}). Exported
661
+ * so a site building its own submit path applies the same rule chapter does. */
662
+ declare function isValidEmail(value: unknown): boolean;
663
+ /** Bound an array-valued field: drop non-primitive elements and cap the length,
664
+ * so a client cannot post an unbounded array. Non-arrays pass through unchanged. */
665
+ declare function clampArray(value: unknown, max: number): unknown;
635
666
  /** Whether a submit body carries a genuine disclaimer acknowledgement. Accepts
636
667
  * the boolean an API client sends and the string a plain HTML form posts. */
637
668
  declare function hasDisclaimerAck(fields: Record<string, unknown>): boolean;
638
669
  /**
639
- * The applicant profile written to the Clerk account's `public_metadata.profile`:
640
- * every configured application field Clerk does not already carry natively, plus
641
- * `focus`. Derived from `application.required`/`optional`, so a site's own field
642
- * names project without this package knowing them. Pure; returns `undefined` when
643
- * there is nothing to write.
670
+ * The applicant profile written to the Clerk account's client-readable
671
+ * `public_metadata.profile`. Projects each configured non-identity field, plus
672
+ * `focus` (clamped) but ONLY those in `application.profileFields` when that
673
+ * allowlist is set, so a site keeps confidential fields (`message`, `referral`)
674
+ * db-only. Derived from config, so a site's own field names project without this
675
+ * package knowing them. Pure; returns `undefined` when there is nothing to write.
644
676
  */
645
677
  declare function applicantProfile(chapter: Chapter, fields: Record<string, unknown>): Record<string, unknown> | undefined;
646
678
  /** A validated submission, or a 400-worthy validation error the route returns.
@@ -722,7 +754,9 @@ interface ProjectionDeps {
722
754
  declare function projectSharedRecord(deps: ProjectionDeps, person: SharedPerson): Promise<{
723
755
  recordId: string;
724
756
  }>;
725
- /** An arriving applicant, as far as the CRM projection cares. */
757
+ /** An arriving applicant, as far as the CRM projection cares. `extra` carries the
758
+ * site-configured `crmFields` (values from the application), merged on top of the
759
+ * built-in identity/contact set. */
726
760
  interface Applicant {
727
761
  applicationId: string;
728
762
  email: string;
@@ -730,12 +764,18 @@ interface Applicant {
730
764
  lastName?: string;
731
765
  phone?: string;
732
766
  linkedin?: string;
767
+ extra?: Record<string, unknown>;
733
768
  }
734
769
  /**
735
770
  * Project an arriving applicant into this chapter's `crm_record`, so a new
736
771
  * application shows up in the CRM immediately, unified by email with any prior
737
772
  * record. Idempotent per application (`apply:${applicationId}`). Best-effort at
738
773
  * the call site — a projection failure never fails the application.
774
+ *
775
+ * `extra` fields (a site's `crmFields`) are merged on top of the base person. If
776
+ * an extra field is not on the crm person type, crm validation throws — so the
777
+ * projection retries with the base person alone, ensuring a misconfigured
778
+ * enrichment never silently drops the applicant from the CRM entirely.
739
779
  */
740
780
  declare function projectApplicant(deps: ProjectionDeps, applicant: Applicant): Promise<{
741
781
  recordId: string;
@@ -793,6 +833,32 @@ declare function clerkUserRequest(input: ClerkUserInput): {
793
833
  * re-application repairs a previously missed create (`refreshed: true`). */
794
834
  declare function createClerkUser(secretKey: string, input: ClerkUserInput, fetchImpl?: typeof fetch): Promise<ClerkResult>;
795
835
 
836
+ /** A Clerk user as chapter's role layer sees it. `role` defaults to the lowest
837
+ * rung when `public_metadata` carries none; `publicMetadata` is returned raw so a
838
+ * site with a custom ladder can re-derive it. */
839
+ interface ClerkUserRecord {
840
+ id: string;
841
+ email?: string;
842
+ role: string;
843
+ publicMetadata: Record<string, unknown>;
844
+ }
845
+ /** Look a Clerk user up by email. `null` when no such user — or when the lookup
846
+ * fails (a role gate treats an unresolvable user as absent, matching the site's
847
+ * own fallback). Role defaults to provisional when unset. */
848
+ declare function clerkGetUserByEmail(secretKey: string, email: string, fetchImpl?: typeof fetch): Promise<ClerkUserRecord | null>;
849
+ /** Fetch a Clerk user by id, for role-change gating. `null` when missing or on a
850
+ * failed lookup. Role defaults to provisional when unset. */
851
+ declare function clerkGetUser(secretKey: string, id: string, fetchImpl?: typeof fetch): Promise<ClerkUserRecord | null>;
852
+ /** List ALL Clerk users with their roles, auto-paginating. A membership community
853
+ * outgrows one page, and a fixed `limit=100` would silently drop members from the
854
+ * admin roster with no error — so this pages (offset in steps of {@link PAGE})
855
+ * until a short page. A page fetch that fails THROWS rather than returning a
856
+ * partial list, so the caller never mistakes a truncated roster for the whole. */
857
+ declare function clerkListUsers(secretKey: string, fetchImpl?: typeof fetch): Promise<ClerkUserRecord[]>;
858
+ /** Write a user's role: a MERGE-PATCH of only `{ role }` on `public_metadata`, so
859
+ * it leaves a separately-written `profile` untouched. Returns whether it stuck. */
860
+ declare function clerkSetRole(secretKey: string, id: string, role: string, fetchImpl?: typeof fetch): Promise<boolean>;
861
+
796
862
  /** An application row, as far as the session cares about it. */
797
863
  interface ApplicationRecord {
798
864
  id: string;
@@ -1031,4 +1097,4 @@ type ApplicationBookingPatch = {
1031
1097
  * already there (never backward). */
1032
1098
  declare function applicationBookingUpdate(currentStatus: string, startAt: number, htmlLink?: string | null): ApplicationBookingPatch;
1033
1099
 
1034
- export { type AccountModel, type AdminNotificationTrigger, type Applicant, type ApplicationBookingPatch, type ApplicationRecord, type ApplicationSummary, type Attr, type AttrType, type Chapter, type ChapterApplication, type ChapterAuth, type ChapterBrand, type ChapterConfig, type ChapterDb, type ChapterEmails, type ChapterIntegrationDescriptor, type ChapterIntegrationOptions, type ChapterMode, type ChapterPipeline, type ChapterPolicy, type ChapterPrices, type ChapterScheduling, type ChapterSends, type ClerkInviteInput, type ClerkResult, type ClerkUserInput, type DbOp, type DbRules, type DbSchema, type DeliveryDecision, type EmailGroup, type EmailTemplate, type EmailTemplateRow, type Entity, type ExistingMeeting, type GuardResult, type JoinConfigGroup, type LiveEvent, type MailSender, type MeetingForReconcile, type MeetingRecord, type MeetingReschedulePatch, type MemberApplication, type MemberSession, type NewMeetingRow, type NotifyDeps, type NotifyInput, type NotifyResult, type PaymentsGroup, type ProjectionDeps, type ReconcileDecision, type ResolvedApplication, type ResolvedAuth, type ResolvedPipeline, type ResolvedScheduling, type ResolvedSends, type RoleChangeContext, type Rule, SCHEDULING_DEFAULTS, type SchedulingErrors, type SecretStore, type SessionUser, type SharedPerson, type StripeEvent, type SubmitResult, type WebhookEvent, applicantProfile, applicationBookingUpdate, applicationSummary, bookingDecision, brandTokens, buildGroupSeed, canApprove, canBook, canChangeRole, canTransition, canceledPatch, chapterDb, clerkInviteRequest, clerkUserRequest, createChapterIntegration, createClerkInvitation, createClerkUser, defaultCrm, defineChapter, emailGroupFrom, endForSlot, findApplicationRef, firstPaymentPatch, getVaultSecret, hasDisclaimerAck, introIdempotencyKey, isAdminRole, isAlreadySent, isReconcilable, isSlotAvailable, joinConfig, meetingCreateRow, meetingRescheduleUpdate, memberApplication, memberSession, normalizeWebhookEvent, paymentsReady, planDelivery, projectApplicant, projectSharedRecord, reconcileMeetings, refundedPatch, render, renderSummary, renderTemplateBody, renewalPatch, resolveApplication, resolveAuth, resolvePipeline, resolveScheduling, roleFromClaim, sendTemplated, sharedPersonInput, slotWindow, stageIndex, stripeForm, submitApplication, subscriptionIdempotencyKey, validateScheduling, verifyStripeSignature, webhookMutationId };
1100
+ export { type AccountModel, type AdminNotificationTrigger, type Applicant, type ApplicationBookingPatch, type ApplicationRecord, type ApplicationSummary, type Attr, type AttrType, type Chapter, type ChapterApplication, type ChapterAuth, type ChapterBrand, type ChapterConfig, type ChapterDb, type ChapterEmails, type ChapterIntegrationDescriptor, type ChapterIntegrationOptions, type ChapterMode, type ChapterPipeline, type ChapterPolicy, type ChapterPrices, type ChapterScheduling, type ChapterSends, type ClerkInviteInput, type ClerkResult, type ClerkUserInput, type ClerkUserRecord, type DbOp, type DbRules, type DbSchema, type DeliveryDecision, type EmailGroup, type EmailTemplate, type EmailTemplateRow, type Entity, type ExistingMeeting, type GuardResult, type JoinConfigGroup, type LiveEvent, type MailSender, type MeetingForReconcile, type MeetingRecord, type MeetingReschedulePatch, type MemberApplication, type MemberSession, type NewMeetingRow, type NotifyDeps, type NotifyInput, type NotifyResult, type PaymentsGroup, type ProjectionDeps, type ReconcileDecision, type ResolvedApplication, type ResolvedAuth, type ResolvedPipeline, type ResolvedScheduling, type ResolvedSends, type RoleChangeContext, type Rule, SCHEDULING_DEFAULTS, type SchedulingErrors, type SecretStore, type SessionUser, type SharedPerson, type StripeEvent, type SubmitResult, type WebhookEvent, applicantProfile, applicationBookingUpdate, applicationSummary, bookingDecision, brandTokens, buildGroupSeed, canApprove, canBook, canChangeRole, canTransition, canceledPatch, chapterDb, clampArray, clerkGetUser, clerkGetUserByEmail, clerkInviteRequest, clerkListUsers, clerkSetRole, clerkUserRequest, createChapterIntegration, createClerkInvitation, createClerkUser, defaultCrm, defineChapter, emailGroupFrom, endForSlot, findApplicationRef, firstPaymentPatch, getVaultSecret, hasDisclaimerAck, introIdempotencyKey, isAdminRole, isAlreadySent, isReconcilable, isSlotAvailable, isValidEmail, joinConfig, meetingCreateRow, meetingRescheduleUpdate, memberApplication, memberSession, normalizeWebhookEvent, paymentsReady, planDelivery, projectApplicant, projectSharedRecord, reconcileMeetings, refundedPatch, render, renderSummary, renderTemplateBody, renewalPatch, resolveApplication, resolveAuth, resolvePipeline, resolveScheduling, roleFromClaim, sendTemplated, sharedPersonInput, slotWindow, stageIndex, stripeForm, submitApplication, subscriptionIdempotencyKey, validateScheduling, verifyStripeSignature, webhookMutationId };
package/dist/index.js CHANGED
@@ -395,27 +395,47 @@ function resolveApplication(a) {
395
395
  throw new Error(`defineChapter.application.${name}: must be an array of field-name strings`);
396
396
  }
397
397
  }
398
+ if (a?.profileFields !== void 0 && (!Array.isArray(a.profileFields) || !a.profileFields.every((f) => typeof f === "string" && f !== ""))) {
399
+ throw new Error("defineChapter.application.profileFields: must be an array of field-name strings");
400
+ }
401
+ if (a?.crmFields !== void 0 && (!Array.isArray(a.crmFields) || !a.crmFields.every((f) => typeof f === "string" && f !== ""))) {
402
+ throw new Error("defineChapter.application.crmFields: must be an array of field-name strings");
403
+ }
398
404
  return {
399
405
  required,
400
406
  optional,
401
407
  maxLen: a?.maxLen ?? {},
402
408
  defaultMaxLen: a?.defaultMaxLen ?? 2e3,
403
409
  bodyCap: a?.bodyCap ?? 32768,
404
- requireDisclaimerAck: a?.requireDisclaimerAck ?? false
410
+ requireDisclaimerAck: a?.requireDisclaimerAck ?? false,
411
+ profileFields: a?.profileFields ?? null,
412
+ crmFields: a?.crmFields ?? [],
413
+ maxArrayLen: a?.maxArrayLen ?? 100,
414
+ validateEmail: a?.validateEmail ?? true
405
415
  };
406
416
  }
417
+ var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
418
+ function isValidEmail(value) {
419
+ return typeof value === "string" && EMAIL_RE.test(value);
420
+ }
421
+ function clampArray(value, max) {
422
+ if (!Array.isArray(value)) return value;
423
+ return value.filter((x) => typeof x === "string" || typeof x === "number" || typeof x === "boolean").slice(0, max);
424
+ }
407
425
  function hasDisclaimerAck(fields) {
408
426
  return fields.disclaimerAck === true || fields.disclaimerAck === "true";
409
427
  }
410
428
  var IDENTITY_FIELDS = /* @__PURE__ */ new Set(["email", "firstName", "lastName"]);
411
429
  function applicantProfile(chapter, fields) {
430
+ const app = chapter.application;
431
+ const allowed = (f) => app.profileFields === null || app.profileFields.includes(f);
412
432
  const profile = {};
413
- for (const f of [...chapter.application.required, ...chapter.application.optional]) {
414
- if (IDENTITY_FIELDS.has(f)) continue;
433
+ for (const f of [...app.required, ...app.optional]) {
434
+ if (IDENTITY_FIELDS.has(f) || !allowed(f)) continue;
415
435
  const v = fields[f];
416
436
  if (typeof v === "string" && v.trim() !== "") profile[f] = v.trim();
417
437
  }
418
- if (fields.focus !== void 0) profile.focus = fields.focus;
438
+ if (fields.focus !== void 0 && allowed("focus")) profile.focus = clampArray(fields.focus, app.maxArrayLen);
419
439
  return Object.keys(profile).length > 0 ? profile : void 0;
420
440
  }
421
441
  async function submitApplication(db, chapter, fields, opts) {
@@ -429,6 +449,9 @@ async function submitApplication(db, chapter, fields, opts) {
429
449
  const cap = app.maxLen[f] ?? app.defaultMaxLen;
430
450
  if (typeof v === "string" && v.length > cap) return { ok: false, error: `${f} exceeds ${cap} characters` };
431
451
  }
452
+ if (app.validateEmail && typeof fields.email === "string" && !isValidEmail(fields.email)) {
453
+ return { ok: false, error: "email must be a valid email address" };
454
+ }
432
455
  const acked = hasDisclaimerAck(fields);
433
456
  if (app.requireDisclaimerAck && !acked) {
434
457
  return { ok: false, error: "disclaimerAck is required" };
@@ -438,7 +461,7 @@ async function submitApplication(db, chapter, fields, opts) {
438
461
  for (const f of [...app.required, ...app.optional]) {
439
462
  if (typeof fields[f] === "string") row[f] = fields[f].trim();
440
463
  }
441
- if (fields.focus !== void 0) row.focus = fields.focus;
464
+ if (fields.focus !== void 0) row.focus = clampArray(fields.focus, app.maxArrayLen);
442
465
  if (opts.groupId) row.groupId = opts.groupId;
443
466
  if (acked) row.disclaimerAckAt = opts.now;
444
467
  const { duplicate } = await db.transact(
@@ -780,7 +803,7 @@ async function projectSharedRecord(deps, person) {
780
803
  return upsertPerson(deps, { email: person.email, input: sharedPersonInput(person), mutationId: `share:${person.hubRecordId}` });
781
804
  }
782
805
  async function projectApplicant(deps, applicant) {
783
- const input = sharedPersonInput({
806
+ const base = sharedPersonInput({
784
807
  email: applicant.email,
785
808
  firstName: applicant.firstName,
786
809
  lastName: applicant.lastName,
@@ -788,7 +811,14 @@ async function projectApplicant(deps, applicant) {
788
811
  linkedin: applicant.linkedin,
789
812
  hubRecordId: applicant.applicationId
790
813
  });
791
- return upsertPerson(deps, { email: applicant.email, input, mutationId: `apply:${applicant.applicationId}` });
814
+ const mutationId = `apply:${applicant.applicationId}`;
815
+ const extra = applicant.extra ?? {};
816
+ if (Object.keys(extra).length === 0) return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
817
+ try {
818
+ return await upsertPerson(deps, { email: applicant.email, input: { ...base, ...extra }, mutationId });
819
+ } catch {
820
+ return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
821
+ }
792
822
  }
793
823
 
794
824
  // src/clerk.ts
@@ -853,6 +883,53 @@ async function createClerkUser(secretKey, input, fetchImpl = fetch) {
853
883
  return { ...healed, refreshed };
854
884
  }
855
885
 
886
+ // src/clerk-roles.ts
887
+ var CLERK_API = "https://api.clerk.com";
888
+ var DEFAULT_ROLE = "provisional";
889
+ var PAGE = 100;
890
+ function toRecord(u) {
891
+ if (typeof u.id !== "string") return null;
892
+ const pm = u.public_metadata ?? {};
893
+ const role = typeof pm.role === "string" && pm.role ? pm.role : DEFAULT_ROLE;
894
+ const email = u.email_addresses?.[0]?.email_address;
895
+ return { id: u.id, email: typeof email === "string" ? email : void 0, role, publicMetadata: pm };
896
+ }
897
+ async function clerkGet(path, secretKey, fetchImpl) {
898
+ const res = await fetchImpl(`${CLERK_API}${path}`, { headers: { authorization: `Bearer ${secretKey}` } });
899
+ if (!res.ok) throw new Error(`clerk GET ${path} \u2192 ${res.status}`);
900
+ return res.json();
901
+ }
902
+ async function clerkGetUserByEmail(secretKey, email, fetchImpl = fetch) {
903
+ const data = await clerkGet(`/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, secretKey, fetchImpl).catch(() => null);
904
+ const user = Array.isArray(data) ? data[0] : void 0;
905
+ return user ? toRecord(user) : null;
906
+ }
907
+ async function clerkGetUser(secretKey, id2, fetchImpl = fetch) {
908
+ const data = await clerkGet(`/v1/users/${encodeURIComponent(id2)}`, secretKey, fetchImpl).catch(() => null);
909
+ return data ? toRecord(data) : null;
910
+ }
911
+ async function clerkListUsers(secretKey, fetchImpl = fetch) {
912
+ const out = [];
913
+ for (let offset = 0; ; offset += PAGE) {
914
+ const data = await clerkGet(`/v1/users?limit=${PAGE}&offset=${offset}`, secretKey, fetchImpl);
915
+ const page = Array.isArray(data) ? data : [];
916
+ for (const u of page) {
917
+ const record = toRecord(u);
918
+ if (record) out.push(record);
919
+ }
920
+ if (page.length < PAGE) break;
921
+ }
922
+ return out;
923
+ }
924
+ async function clerkSetRole(secretKey, id2, role, fetchImpl = fetch) {
925
+ const res = await fetchImpl(`${CLERK_API}/v1/users/${encodeURIComponent(id2)}/metadata`, {
926
+ method: "PATCH",
927
+ headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
928
+ body: JSON.stringify({ public_metadata: { role } })
929
+ });
930
+ return res.ok;
931
+ }
932
+
856
933
  // src/session.ts
857
934
  function applicationSummary(app) {
858
935
  return {
@@ -1069,7 +1146,12 @@ export {
1069
1146
  canTransition,
1070
1147
  canceledPatch,
1071
1148
  chapterDb,
1149
+ clampArray,
1150
+ clerkGetUser,
1151
+ clerkGetUserByEmail,
1072
1152
  clerkInviteRequest,
1153
+ clerkListUsers,
1154
+ clerkSetRole,
1073
1155
  clerkUserRequest,
1074
1156
  createChapterIntegration,
1075
1157
  createClerkInvitation,
@@ -1087,6 +1169,7 @@ export {
1087
1169
  isAlreadySent,
1088
1170
  isReconcilable,
1089
1171
  isSlotAvailable,
1172
+ isValidEmail,
1090
1173
  joinConfig,
1091
1174
  meetingCreateRow,
1092
1175
  meetingRescheduleUpdate,