@odla-ai/chapter 0.24.0 → 0.25.1

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
@@ -327,10 +327,9 @@ interface ChapterApplication {
327
327
  * writing a row with no consent record. Default `true`. Set `false`
328
328
  * deliberately only when the site renders no consent control. */
329
329
  requireDisclaimerAck?: boolean;
330
- /** Allowlist of fields that reach the Clerk account's client-readable
331
- * `public_metadata.profile`. Default `[]`, so application details remain
332
- * db-only. Set a curated list (e.g. `["phone", "state", "focus"]`) for
333
- * fields the browser may read. */
330
+ /** Allowlist of application fields mirrored into the Clerk account's
331
+ * backend-only `private_metadata.profile`. Default `[]`. Use this for small
332
+ * account/admin signals; the application row and CRM remain canonical. */
334
333
  profileFields?: readonly string[];
335
334
  /** Extra application fields carried into the one-way CRM projection, on top of
336
335
  * the built-in identity/contact set. Each MUST be declared on your crm person
@@ -877,11 +876,10 @@ declare function clampArray(value: unknown, max: number): unknown;
877
876
  * the boolean an API client sends and the string a plain HTML form posts. */
878
877
  declare function hasDisclaimerAck(fields: Record<string, unknown>): boolean;
879
878
  /**
880
- * The applicant profile written to the Clerk account's client-readable
881
- * `public_metadata.profile`. Projects each configured non-identity field, plus
882
- * `focus` (clamped), but ONLY those in `application.profileFields`, so a site
883
- * keeps confidential fields (`message`, `referral`) db-only. Derived from
884
- * config, so a site's own field names project without this package knowing
879
+ * The applicant profile written to the Clerk account's backend-only
880
+ * `private_metadata.profile`. Projects each configured non-identity field, plus
881
+ * `focus` (clamped), but ONLY those in `application.profileFields`. Derived
882
+ * from config, so a site's own field names project without this package knowing
885
883
  * them. Pure; returns `undefined` when there is nothing to write.
886
884
  */
887
885
  declare function applicantProfile(chapter: Chapter, fields: Record<string, unknown>): Record<string, unknown> | undefined;
@@ -1097,7 +1095,7 @@ interface ClerkResult {
1097
1095
  ok: boolean;
1098
1096
  status: number;
1099
1097
  existed?: boolean;
1100
- /** Set when an `existed` heal also refreshed the account's public_metadata. */
1098
+ /** Set when an `existed` heal also refreshed the account's metadata. */
1101
1099
  refreshed?: boolean;
1102
1100
  }
1103
1101
  /** Inputs for a Clerk invitation. */
@@ -1105,8 +1103,9 @@ interface ClerkInviteInput {
1105
1103
  email: string;
1106
1104
  /** Where the accept-invitation link lands (usually the member area). */
1107
1105
  redirectUrl?: string;
1108
- /** Written to the invitation's `public_metadata`, so the accepted account
1109
- * carries your own profile fields. */
1106
+ /** Written to the invitation's `public_metadata`. Clerk application
1107
+ * invitations cannot carry private metadata, so never put applicant profile
1108
+ * fields here. Chapter completes that backend-only write after acceptance. */
1110
1109
  publicMetadata?: Record<string, unknown>;
1111
1110
  }
1112
1111
  /** Build the Clerk Backend API invitation request (path + JSON body). Pure, so
@@ -1124,9 +1123,14 @@ interface ClerkUserInput {
1124
1123
  email: string;
1125
1124
  firstName?: string;
1126
1125
  lastName?: string;
1127
- /** Written to the user's `public_metadata` — the site's own profile fields
1128
- * (role, tier, whatever the member area reads). */
1126
+ /** Backend-authored, browser-readable claims such as `role`. */
1129
1127
  publicMetadata?: Record<string, unknown>;
1128
+ /** Backend-only account signals and profile data. */
1129
+ privateMetadata?: Record<string, unknown>;
1130
+ /** Optional metadata merge used only when create returns 422 for an existing
1131
+ * account. This lets migrations remove legacy keys without putting tombstone
1132
+ * values on a newly-created user. Defaults to the create metadata. */
1133
+ repairMetadata?: ClerkMetadataInput;
1130
1134
  }
1131
1135
  /** Build the Clerk Backend API user-create request. The account is created
1132
1136
  * passwordless (the member signs in via the site's Clerk flow), so join step 3
@@ -1135,21 +1139,41 @@ declare function clerkUserRequest(input: ClerkUserInput): {
1135
1139
  path: string;
1136
1140
  body: Record<string, unknown>;
1137
1141
  };
1142
+ /** One merge-PATCH to the metadata fields Clerk exposes on its Backend API. */
1143
+ interface ClerkMetadataInput {
1144
+ publicMetadata?: Record<string, unknown>;
1145
+ privateMetadata?: Record<string, unknown>;
1146
+ }
1147
+ /** Merge metadata into an existing Clerk user. Nested `null` values remove old
1148
+ * keys, which Chapter uses to migrate its former public profile into private
1149
+ * metadata without disturbing the public `role` claim. */
1150
+ declare function updateClerkUserMetadata(secretKey: string, userId: string, input: ClerkMetadataInput, fetchImpl?: typeof fetch): Promise<boolean>;
1151
+ /** Look a Clerk account up by email and merge its metadata.
1152
+ *
1153
+ * Returns `false` when no matching user can be resolved or Clerk rejects the
1154
+ * lookup or update. Chapter uses this for create-healing and invitation
1155
+ * adoption so a repeated application repairs the private account snapshot.
1156
+ */
1157
+ declare function updateClerkUserMetadataByEmail(secretKey: string, email: string, input: ClerkMetadataInput, fetchImpl?: typeof fetch): Promise<boolean>;
1138
1158
  /** Create the applicant's Clerk account server-side. A repeat for an email that
1139
1159
  * already has an account heals to `{ ok: true, existed: true }` — the account
1140
- * exists, which is the state apply-time provisioning wanted — and, when
1141
- * `publicMetadata` was supplied, refreshes it on the existing account so a
1142
- * re-application repairs a previously missed create (`refreshed: true`). */
1160
+ * exists, which is the state apply-time provisioning wanted — and refreshes
1161
+ * supplied public/private metadata on the existing account so a re-application
1162
+ * repairs a previously missed create (`refreshed: true`). */
1143
1163
  declare function createClerkUser(secretKey: string, input: ClerkUserInput, fetchImpl?: typeof fetch): Promise<ClerkResult>;
1144
1164
 
1145
1165
  /** A Clerk user as chapter's role layer sees it. `role` defaults to the lowest
1146
- * rung when `public_metadata` carries none; `publicMetadata` is returned raw so a
1147
- * site with a custom ladder can re-derive it. */
1166
+ * rung when `public_metadata` carries none. Raw public/private metadata is
1167
+ * returned only from this backend helper; `profile` and `applicationId` are the
1168
+ * normalized Chapter-owned private signals. */
1148
1169
  interface ClerkUserRecord {
1149
1170
  id: string;
1150
1171
  email?: string;
1151
1172
  role: string;
1152
1173
  publicMetadata: Record<string, unknown>;
1174
+ privateMetadata: Record<string, unknown>;
1175
+ profile: Record<string, unknown>;
1176
+ applicationId?: string;
1153
1177
  }
1154
1178
  /** Look a Clerk user up by email. `null` when no such user — or when the lookup
1155
1179
  * fails (a role gate treats an unresolvable user as absent, matching the site's
@@ -1164,8 +1188,9 @@ declare function clerkGetUser(secretKey: string, id: string, fetchImpl?: typeof
1164
1188
  * until a short page. A page fetch that fails THROWS rather than returning a
1165
1189
  * partial list, so the caller never mistakes a truncated roster for the whole. */
1166
1190
  declare function clerkListUsers(secretKey: string, fetchImpl?: typeof fetch): Promise<ClerkUserRecord[]>;
1167
- /** Write a user's role: a MERGE-PATCH of only `{ role }` on `public_metadata`, so
1168
- * it leaves a separately-written `profile` untouched. Returns whether it stuck. */
1191
+ /** Write a user's role: a MERGE-PATCH of only `{ role }` on `public_metadata`.
1192
+ * The backend-only profile lives in `private_metadata`, so the two signals
1193
+ * cannot overwrite each other. Returns whether the role write stuck. */
1169
1194
  declare function clerkSetRole(secretKey: string, id: string, role: string, fetchImpl?: typeof fetch): Promise<boolean>;
1170
1195
 
1171
1196
  /** Which auth mode a secret is required for: `"client"` (publishable key only) or
@@ -1231,8 +1256,9 @@ interface IntegrationDescriptor {
1231
1256
  * the one public `setting` (the `pk_*` publishable key, served to the SPA), the
1232
1257
  * `secrets` auth mode `"full"` needs in the tenant vault, BOTH sync directions —
1233
1258
  * the inbound `$users` webhook mirror (`provider->odla`) and the outbound
1234
- * role/profile write via `clerk_secret_key` (`odla->provider`, chapter's
1235
- * `clerk.ts`) — and the `provision` split. Data only; it performs none of it.
1259
+ * public role/private profile write via `clerk_secret_key` (`odla->provider`,
1260
+ * chapter's `clerk.ts`) — and the `provision` split. Data only; it performs
1261
+ * none of it.
1236
1262
  */
1237
1263
  declare const clerkIntegration: IntegrationDescriptor;
1238
1264
 
@@ -1491,4 +1517,4 @@ type ApplicationBookingPatch = {
1491
1517
  * already there (never backward). */
1492
1518
  declare function applicationBookingUpdate(currentStatus: string, startAt: number, htmlLink?: string | null): ApplicationBookingPatch;
1493
1519
 
1494
- 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 ChapterCopy, type ChapterCopyInput, type ChapterDb, type ChapterEmails, type ChapterIntegrationDescriptor, type ChapterIntegrationOptions, type ChapterMode, type ChapterNetwork, type ChapterNetworkTarget, type ChapterOperations, type ChapterPipeline, type ChapterPolicy, type ChapterPrices, type ChapterRunbookHints, type ChapterScheduling, type ChapterSends, type ClerkInviteInput, type ClerkResult, type ClerkUserInput, type ClerkUserRecord, type CompiledChapterBrandTokens, DEFAULT_CHAPTER_COPY, DEFAULT_SHARE_FIELDS, type DashboardMetricData, type DashboardMetricSeries, type DbOp, type DbRules, type DbSchema, type DeliveryDecision, type EmailGroup, type EmailTemplate, type EmailTemplateRow, type Entity, type ExistingMeeting, type GuardResult, type IntegrationDescriptor, type IntegrationProvision, type IntegrationSecret, type IntegrationSetting, type IntegrationSync, 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 ResolvedNetwork, type ResolvedNetworkTarget, type ResolvedOperations, type ResolvedPipeline, type ResolvedScheduling, type ResolvedSends, type RoleChangeContext, type Rule, SCHEDULING_DEFAULTS, type SchedulingErrors, type SecretMode, type SecretStore, type SessionUser, type SharedBusiness, type SharedPerson, type SharedRecord, type StripeEvent, type StripeResult, type SubmitResult, type WebhookEvent, applicantProfile, applicationBookingUpdate, applicationSummary, backfillCrm, bookingDecision, brandTokens, bucketSeries, buildGroupSeed, canApprove, canBook, canChangeRole, canTransition, canceledPatch, chapterBrandFromTokens, chapterDb, clampArray, clerkGetUser, clerkGetUserByEmail, clerkIntegration, clerkInviteRequest, clerkListUsers, clerkSetRole, clerkUserRequest, createChapterIntegration, createClerkInvitation, createClerkUser, dashboardMetricData, defaultCrm, defineChapter, emailGroupFrom, endForSlot, findApplicationRef, firstPaymentPatch, formatChapterCopy, getVaultSecret, hasDisclaimerAck, introIdempotencyKey, isAdminRole, isAlreadySent, isReconcilable, isSlotAvailable, isValidEmail, joinConfig, meetingCreateRow, meetingRescheduleUpdate, memberApplication, memberSession, networkSourceTag, normalizeSharedRecord, normalizeWebhookEvent, paymentsReady, personInputFromApp, planDelivery, projectApplicant, projectSharedRecord, reconcileMeetings, refundedPatch, render, renderSummary, renderTemplateBody, renewalPatch, resolveApplication, resolveAuth, resolveChapterCopy, resolvePipeline, resolveScheduling, roleFromClaim, sendTemplated, sharedPersonInput, sharedRecordFromCrm, slotWindow, stageIndex, stripeCall, stripeForm, subAnnualCents, submitApplication, subscriptionIdempotencyKey, syncApplicationToCrm, validateScheduling, verifyStripeSignature, webhookMutationId };
1520
+ 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 ChapterCopy, type ChapterCopyInput, type ChapterDb, type ChapterEmails, type ChapterIntegrationDescriptor, type ChapterIntegrationOptions, type ChapterMode, type ChapterNetwork, type ChapterNetworkTarget, type ChapterOperations, type ChapterPipeline, type ChapterPolicy, type ChapterPrices, type ChapterRunbookHints, type ChapterScheduling, type ChapterSends, type ClerkInviteInput, type ClerkMetadataInput, type ClerkResult, type ClerkUserInput, type ClerkUserRecord, type CompiledChapterBrandTokens, DEFAULT_CHAPTER_COPY, DEFAULT_SHARE_FIELDS, type DashboardMetricData, type DashboardMetricSeries, type DbOp, type DbRules, type DbSchema, type DeliveryDecision, type EmailGroup, type EmailTemplate, type EmailTemplateRow, type Entity, type ExistingMeeting, type GuardResult, type IntegrationDescriptor, type IntegrationProvision, type IntegrationSecret, type IntegrationSetting, type IntegrationSync, 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 ResolvedNetwork, type ResolvedNetworkTarget, type ResolvedOperations, type ResolvedPipeline, type ResolvedScheduling, type ResolvedSends, type RoleChangeContext, type Rule, SCHEDULING_DEFAULTS, type SchedulingErrors, type SecretMode, type SecretStore, type SessionUser, type SharedBusiness, type SharedPerson, type SharedRecord, type StripeEvent, type StripeResult, type SubmitResult, type WebhookEvent, applicantProfile, applicationBookingUpdate, applicationSummary, backfillCrm, bookingDecision, brandTokens, bucketSeries, buildGroupSeed, canApprove, canBook, canChangeRole, canTransition, canceledPatch, chapterBrandFromTokens, chapterDb, clampArray, clerkGetUser, clerkGetUserByEmail, clerkIntegration, clerkInviteRequest, clerkListUsers, clerkSetRole, clerkUserRequest, createChapterIntegration, createClerkInvitation, createClerkUser, dashboardMetricData, defaultCrm, defineChapter, emailGroupFrom, endForSlot, findApplicationRef, firstPaymentPatch, formatChapterCopy, getVaultSecret, hasDisclaimerAck, introIdempotencyKey, isAdminRole, isAlreadySent, isReconcilable, isSlotAvailable, isValidEmail, joinConfig, meetingCreateRow, meetingRescheduleUpdate, memberApplication, memberSession, networkSourceTag, normalizeSharedRecord, normalizeWebhookEvent, paymentsReady, personInputFromApp, planDelivery, projectApplicant, projectSharedRecord, reconcileMeetings, refundedPatch, render, renderSummary, renderTemplateBody, renewalPatch, resolveApplication, resolveAuth, resolveChapterCopy, resolvePipeline, resolveScheduling, roleFromClaim, sendTemplated, sharedPersonInput, sharedRecordFromCrm, slotWindow, stageIndex, stripeCall, stripeForm, subAnnualCents, submitApplication, subscriptionIdempotencyKey, syncApplicationToCrm, updateClerkUserMetadata, updateClerkUserMetadataByEmail, validateScheduling, verifyStripeSignature, webhookMutationId };
package/dist/index.d.ts CHANGED
@@ -327,10 +327,9 @@ interface ChapterApplication {
327
327
  * writing a row with no consent record. Default `true`. Set `false`
328
328
  * deliberately only when the site renders no consent control. */
329
329
  requireDisclaimerAck?: boolean;
330
- /** Allowlist of fields that reach the Clerk account's client-readable
331
- * `public_metadata.profile`. Default `[]`, so application details remain
332
- * db-only. Set a curated list (e.g. `["phone", "state", "focus"]`) for
333
- * fields the browser may read. */
330
+ /** Allowlist of application fields mirrored into the Clerk account's
331
+ * backend-only `private_metadata.profile`. Default `[]`. Use this for small
332
+ * account/admin signals; the application row and CRM remain canonical. */
334
333
  profileFields?: readonly string[];
335
334
  /** Extra application fields carried into the one-way CRM projection, on top of
336
335
  * the built-in identity/contact set. Each MUST be declared on your crm person
@@ -877,11 +876,10 @@ declare function clampArray(value: unknown, max: number): unknown;
877
876
  * the boolean an API client sends and the string a plain HTML form posts. */
878
877
  declare function hasDisclaimerAck(fields: Record<string, unknown>): boolean;
879
878
  /**
880
- * The applicant profile written to the Clerk account's client-readable
881
- * `public_metadata.profile`. Projects each configured non-identity field, plus
882
- * `focus` (clamped), but ONLY those in `application.profileFields`, so a site
883
- * keeps confidential fields (`message`, `referral`) db-only. Derived from
884
- * config, so a site's own field names project without this package knowing
879
+ * The applicant profile written to the Clerk account's backend-only
880
+ * `private_metadata.profile`. Projects each configured non-identity field, plus
881
+ * `focus` (clamped), but ONLY those in `application.profileFields`. Derived
882
+ * from config, so a site's own field names project without this package knowing
885
883
  * them. Pure; returns `undefined` when there is nothing to write.
886
884
  */
887
885
  declare function applicantProfile(chapter: Chapter, fields: Record<string, unknown>): Record<string, unknown> | undefined;
@@ -1097,7 +1095,7 @@ interface ClerkResult {
1097
1095
  ok: boolean;
1098
1096
  status: number;
1099
1097
  existed?: boolean;
1100
- /** Set when an `existed` heal also refreshed the account's public_metadata. */
1098
+ /** Set when an `existed` heal also refreshed the account's metadata. */
1101
1099
  refreshed?: boolean;
1102
1100
  }
1103
1101
  /** Inputs for a Clerk invitation. */
@@ -1105,8 +1103,9 @@ interface ClerkInviteInput {
1105
1103
  email: string;
1106
1104
  /** Where the accept-invitation link lands (usually the member area). */
1107
1105
  redirectUrl?: string;
1108
- /** Written to the invitation's `public_metadata`, so the accepted account
1109
- * carries your own profile fields. */
1106
+ /** Written to the invitation's `public_metadata`. Clerk application
1107
+ * invitations cannot carry private metadata, so never put applicant profile
1108
+ * fields here. Chapter completes that backend-only write after acceptance. */
1110
1109
  publicMetadata?: Record<string, unknown>;
1111
1110
  }
1112
1111
  /** Build the Clerk Backend API invitation request (path + JSON body). Pure, so
@@ -1124,9 +1123,14 @@ interface ClerkUserInput {
1124
1123
  email: string;
1125
1124
  firstName?: string;
1126
1125
  lastName?: string;
1127
- /** Written to the user's `public_metadata` — the site's own profile fields
1128
- * (role, tier, whatever the member area reads). */
1126
+ /** Backend-authored, browser-readable claims such as `role`. */
1129
1127
  publicMetadata?: Record<string, unknown>;
1128
+ /** Backend-only account signals and profile data. */
1129
+ privateMetadata?: Record<string, unknown>;
1130
+ /** Optional metadata merge used only when create returns 422 for an existing
1131
+ * account. This lets migrations remove legacy keys without putting tombstone
1132
+ * values on a newly-created user. Defaults to the create metadata. */
1133
+ repairMetadata?: ClerkMetadataInput;
1130
1134
  }
1131
1135
  /** Build the Clerk Backend API user-create request. The account is created
1132
1136
  * passwordless (the member signs in via the site's Clerk flow), so join step 3
@@ -1135,21 +1139,41 @@ declare function clerkUserRequest(input: ClerkUserInput): {
1135
1139
  path: string;
1136
1140
  body: Record<string, unknown>;
1137
1141
  };
1142
+ /** One merge-PATCH to the metadata fields Clerk exposes on its Backend API. */
1143
+ interface ClerkMetadataInput {
1144
+ publicMetadata?: Record<string, unknown>;
1145
+ privateMetadata?: Record<string, unknown>;
1146
+ }
1147
+ /** Merge metadata into an existing Clerk user. Nested `null` values remove old
1148
+ * keys, which Chapter uses to migrate its former public profile into private
1149
+ * metadata without disturbing the public `role` claim. */
1150
+ declare function updateClerkUserMetadata(secretKey: string, userId: string, input: ClerkMetadataInput, fetchImpl?: typeof fetch): Promise<boolean>;
1151
+ /** Look a Clerk account up by email and merge its metadata.
1152
+ *
1153
+ * Returns `false` when no matching user can be resolved or Clerk rejects the
1154
+ * lookup or update. Chapter uses this for create-healing and invitation
1155
+ * adoption so a repeated application repairs the private account snapshot.
1156
+ */
1157
+ declare function updateClerkUserMetadataByEmail(secretKey: string, email: string, input: ClerkMetadataInput, fetchImpl?: typeof fetch): Promise<boolean>;
1138
1158
  /** Create the applicant's Clerk account server-side. A repeat for an email that
1139
1159
  * already has an account heals to `{ ok: true, existed: true }` — the account
1140
- * exists, which is the state apply-time provisioning wanted — and, when
1141
- * `publicMetadata` was supplied, refreshes it on the existing account so a
1142
- * re-application repairs a previously missed create (`refreshed: true`). */
1160
+ * exists, which is the state apply-time provisioning wanted — and refreshes
1161
+ * supplied public/private metadata on the existing account so a re-application
1162
+ * repairs a previously missed create (`refreshed: true`). */
1143
1163
  declare function createClerkUser(secretKey: string, input: ClerkUserInput, fetchImpl?: typeof fetch): Promise<ClerkResult>;
1144
1164
 
1145
1165
  /** A Clerk user as chapter's role layer sees it. `role` defaults to the lowest
1146
- * rung when `public_metadata` carries none; `publicMetadata` is returned raw so a
1147
- * site with a custom ladder can re-derive it. */
1166
+ * rung when `public_metadata` carries none. Raw public/private metadata is
1167
+ * returned only from this backend helper; `profile` and `applicationId` are the
1168
+ * normalized Chapter-owned private signals. */
1148
1169
  interface ClerkUserRecord {
1149
1170
  id: string;
1150
1171
  email?: string;
1151
1172
  role: string;
1152
1173
  publicMetadata: Record<string, unknown>;
1174
+ privateMetadata: Record<string, unknown>;
1175
+ profile: Record<string, unknown>;
1176
+ applicationId?: string;
1153
1177
  }
1154
1178
  /** Look a Clerk user up by email. `null` when no such user — or when the lookup
1155
1179
  * fails (a role gate treats an unresolvable user as absent, matching the site's
@@ -1164,8 +1188,9 @@ declare function clerkGetUser(secretKey: string, id: string, fetchImpl?: typeof
1164
1188
  * until a short page. A page fetch that fails THROWS rather than returning a
1165
1189
  * partial list, so the caller never mistakes a truncated roster for the whole. */
1166
1190
  declare function clerkListUsers(secretKey: string, fetchImpl?: typeof fetch): Promise<ClerkUserRecord[]>;
1167
- /** Write a user's role: a MERGE-PATCH of only `{ role }` on `public_metadata`, so
1168
- * it leaves a separately-written `profile` untouched. Returns whether it stuck. */
1191
+ /** Write a user's role: a MERGE-PATCH of only `{ role }` on `public_metadata`.
1192
+ * The backend-only profile lives in `private_metadata`, so the two signals
1193
+ * cannot overwrite each other. Returns whether the role write stuck. */
1169
1194
  declare function clerkSetRole(secretKey: string, id: string, role: string, fetchImpl?: typeof fetch): Promise<boolean>;
1170
1195
 
1171
1196
  /** Which auth mode a secret is required for: `"client"` (publishable key only) or
@@ -1231,8 +1256,9 @@ interface IntegrationDescriptor {
1231
1256
  * the one public `setting` (the `pk_*` publishable key, served to the SPA), the
1232
1257
  * `secrets` auth mode `"full"` needs in the tenant vault, BOTH sync directions —
1233
1258
  * the inbound `$users` webhook mirror (`provider->odla`) and the outbound
1234
- * role/profile write via `clerk_secret_key` (`odla->provider`, chapter's
1235
- * `clerk.ts`) — and the `provision` split. Data only; it performs none of it.
1259
+ * public role/private profile write via `clerk_secret_key` (`odla->provider`,
1260
+ * chapter's `clerk.ts`) — and the `provision` split. Data only; it performs
1261
+ * none of it.
1236
1262
  */
1237
1263
  declare const clerkIntegration: IntegrationDescriptor;
1238
1264
 
@@ -1491,4 +1517,4 @@ type ApplicationBookingPatch = {
1491
1517
  * already there (never backward). */
1492
1518
  declare function applicationBookingUpdate(currentStatus: string, startAt: number, htmlLink?: string | null): ApplicationBookingPatch;
1493
1519
 
1494
- 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 ChapterCopy, type ChapterCopyInput, type ChapterDb, type ChapterEmails, type ChapterIntegrationDescriptor, type ChapterIntegrationOptions, type ChapterMode, type ChapterNetwork, type ChapterNetworkTarget, type ChapterOperations, type ChapterPipeline, type ChapterPolicy, type ChapterPrices, type ChapterRunbookHints, type ChapterScheduling, type ChapterSends, type ClerkInviteInput, type ClerkResult, type ClerkUserInput, type ClerkUserRecord, type CompiledChapterBrandTokens, DEFAULT_CHAPTER_COPY, DEFAULT_SHARE_FIELDS, type DashboardMetricData, type DashboardMetricSeries, type DbOp, type DbRules, type DbSchema, type DeliveryDecision, type EmailGroup, type EmailTemplate, type EmailTemplateRow, type Entity, type ExistingMeeting, type GuardResult, type IntegrationDescriptor, type IntegrationProvision, type IntegrationSecret, type IntegrationSetting, type IntegrationSync, 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 ResolvedNetwork, type ResolvedNetworkTarget, type ResolvedOperations, type ResolvedPipeline, type ResolvedScheduling, type ResolvedSends, type RoleChangeContext, type Rule, SCHEDULING_DEFAULTS, type SchedulingErrors, type SecretMode, type SecretStore, type SessionUser, type SharedBusiness, type SharedPerson, type SharedRecord, type StripeEvent, type StripeResult, type SubmitResult, type WebhookEvent, applicantProfile, applicationBookingUpdate, applicationSummary, backfillCrm, bookingDecision, brandTokens, bucketSeries, buildGroupSeed, canApprove, canBook, canChangeRole, canTransition, canceledPatch, chapterBrandFromTokens, chapterDb, clampArray, clerkGetUser, clerkGetUserByEmail, clerkIntegration, clerkInviteRequest, clerkListUsers, clerkSetRole, clerkUserRequest, createChapterIntegration, createClerkInvitation, createClerkUser, dashboardMetricData, defaultCrm, defineChapter, emailGroupFrom, endForSlot, findApplicationRef, firstPaymentPatch, formatChapterCopy, getVaultSecret, hasDisclaimerAck, introIdempotencyKey, isAdminRole, isAlreadySent, isReconcilable, isSlotAvailable, isValidEmail, joinConfig, meetingCreateRow, meetingRescheduleUpdate, memberApplication, memberSession, networkSourceTag, normalizeSharedRecord, normalizeWebhookEvent, paymentsReady, personInputFromApp, planDelivery, projectApplicant, projectSharedRecord, reconcileMeetings, refundedPatch, render, renderSummary, renderTemplateBody, renewalPatch, resolveApplication, resolveAuth, resolveChapterCopy, resolvePipeline, resolveScheduling, roleFromClaim, sendTemplated, sharedPersonInput, sharedRecordFromCrm, slotWindow, stageIndex, stripeCall, stripeForm, subAnnualCents, submitApplication, subscriptionIdempotencyKey, syncApplicationToCrm, validateScheduling, verifyStripeSignature, webhookMutationId };
1520
+ 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 ChapterCopy, type ChapterCopyInput, type ChapterDb, type ChapterEmails, type ChapterIntegrationDescriptor, type ChapterIntegrationOptions, type ChapterMode, type ChapterNetwork, type ChapterNetworkTarget, type ChapterOperations, type ChapterPipeline, type ChapterPolicy, type ChapterPrices, type ChapterRunbookHints, type ChapterScheduling, type ChapterSends, type ClerkInviteInput, type ClerkMetadataInput, type ClerkResult, type ClerkUserInput, type ClerkUserRecord, type CompiledChapterBrandTokens, DEFAULT_CHAPTER_COPY, DEFAULT_SHARE_FIELDS, type DashboardMetricData, type DashboardMetricSeries, type DbOp, type DbRules, type DbSchema, type DeliveryDecision, type EmailGroup, type EmailTemplate, type EmailTemplateRow, type Entity, type ExistingMeeting, type GuardResult, type IntegrationDescriptor, type IntegrationProvision, type IntegrationSecret, type IntegrationSetting, type IntegrationSync, 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 ResolvedNetwork, type ResolvedNetworkTarget, type ResolvedOperations, type ResolvedPipeline, type ResolvedScheduling, type ResolvedSends, type RoleChangeContext, type Rule, SCHEDULING_DEFAULTS, type SchedulingErrors, type SecretMode, type SecretStore, type SessionUser, type SharedBusiness, type SharedPerson, type SharedRecord, type StripeEvent, type StripeResult, type SubmitResult, type WebhookEvent, applicantProfile, applicationBookingUpdate, applicationSummary, backfillCrm, bookingDecision, brandTokens, bucketSeries, buildGroupSeed, canApprove, canBook, canChangeRole, canTransition, canceledPatch, chapterBrandFromTokens, chapterDb, clampArray, clerkGetUser, clerkGetUserByEmail, clerkIntegration, clerkInviteRequest, clerkListUsers, clerkSetRole, clerkUserRequest, createChapterIntegration, createClerkInvitation, createClerkUser, dashboardMetricData, defaultCrm, defineChapter, emailGroupFrom, endForSlot, findApplicationRef, firstPaymentPatch, formatChapterCopy, getVaultSecret, hasDisclaimerAck, introIdempotencyKey, isAdminRole, isAlreadySent, isReconcilable, isSlotAvailable, isValidEmail, joinConfig, meetingCreateRow, meetingRescheduleUpdate, memberApplication, memberSession, networkSourceTag, normalizeSharedRecord, normalizeWebhookEvent, paymentsReady, personInputFromApp, planDelivery, projectApplicant, projectSharedRecord, reconcileMeetings, refundedPatch, render, renderSummary, renderTemplateBody, renewalPatch, resolveApplication, resolveAuth, resolveChapterCopy, resolvePipeline, resolveScheduling, roleFromClaim, sendTemplated, sharedPersonInput, sharedRecordFromCrm, slotWindow, stageIndex, stripeCall, stripeForm, subAnnualCents, submitApplication, subscriptionIdempotencyKey, syncApplicationToCrm, updateClerkUserMetadata, updateClerkUserMetadataByEmail, validateScheduling, verifyStripeSignature, webhookMutationId };
package/dist/index.js CHANGED
@@ -44,6 +44,10 @@ var applications = {
44
44
  meetingAt: attr("number", { indexed: true, optional: true }),
45
45
  meetingLink: attr("string", { optional: true }),
46
46
  clerkUserId: attr("string", { indexed: true, optional: true }),
47
+ // One-time/effect-repair marker. Absent means /api/me should copy the
48
+ // allowlisted application snapshot into Clerk private_metadata and remove
49
+ // the former public_metadata copy.
50
+ clerkPrivateMetadataSyncedAt: attr("number", { optional: true }),
47
51
  phone: attr("string", { optional: true }),
48
52
  state: attr("string", { optional: true }),
49
53
  groupId: attr("string", { indexed: true, optional: true }),
@@ -1502,23 +1506,30 @@ function clerkUserRequest(input) {
1502
1506
  skip_password_requirement: true,
1503
1507
  ...input.firstName ? { first_name: input.firstName } : {},
1504
1508
  ...input.lastName ? { last_name: input.lastName } : {},
1505
- ...input.publicMetadata ? { public_metadata: input.publicMetadata } : {}
1509
+ ...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
1510
+ ...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
1506
1511
  }
1507
1512
  };
1508
1513
  }
1509
- async function refreshUserMetadata(secretKey, email, publicMetadata, fetchImpl) {
1514
+ async function updateClerkUserMetadata(secretKey, userId, input, fetchImpl = fetch) {
1515
+ const res = await fetchImpl(`https://api.clerk.com/v1/users/${encodeURIComponent(userId)}/metadata`, {
1516
+ method: "PATCH",
1517
+ headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
1518
+ body: JSON.stringify({
1519
+ ...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
1520
+ ...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
1521
+ })
1522
+ });
1523
+ return res.ok;
1524
+ }
1525
+ async function updateClerkUserMetadataByEmail(secretKey, email, input, fetchImpl = fetch) {
1510
1526
  const auth = { authorization: `Bearer ${secretKey}` };
1511
1527
  const found = await fetchImpl(`https://api.clerk.com/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, { headers: auth });
1512
1528
  if (!found.ok) return false;
1513
1529
  const users = await found.json().catch(() => null);
1514
1530
  const id2 = Array.isArray(users) && typeof users[0]?.id === "string" ? users[0].id : void 0;
1515
1531
  if (!id2) return false;
1516
- const patched = await fetchImpl(`https://api.clerk.com/v1/users/${id2}/metadata`, {
1517
- method: "PATCH",
1518
- headers: { ...auth, "content-type": "application/json" },
1519
- body: JSON.stringify({ public_metadata: publicMetadata })
1520
- });
1521
- return patched.ok;
1532
+ return updateClerkUserMetadata(secretKey, id2, input, fetchImpl);
1522
1533
  }
1523
1534
  async function createClerkUser(secretKey, input, fetchImpl = fetch) {
1524
1535
  const { path, body } = clerkUserRequest(input);
@@ -1529,8 +1540,15 @@ async function createClerkUser(secretKey, input, fetchImpl = fetch) {
1529
1540
  });
1530
1541
  if (res.ok) return { ok: true, status: res.status };
1531
1542
  const healed = heal(res.status);
1532
- if (!healed.existed || !input.publicMetadata) return healed;
1533
- const refreshed = await refreshUserMetadata(secretKey, input.email, input.publicMetadata, fetchImpl).catch(() => false);
1543
+ const repair = input.repairMetadata ?? {
1544
+ ...input.publicMetadata !== void 0 ? { publicMetadata: input.publicMetadata } : {},
1545
+ ...input.privateMetadata !== void 0 ? { privateMetadata: input.privateMetadata } : {}
1546
+ };
1547
+ if (!healed.existed || repair.publicMetadata === void 0 && repair.privateMetadata === void 0) return healed;
1548
+ const refreshed = await updateClerkUserMetadataByEmail(secretKey, input.email, {
1549
+ ...repair.publicMetadata !== void 0 ? { publicMetadata: repair.publicMetadata } : {},
1550
+ ...repair.privateMetadata !== void 0 ? { privateMetadata: repair.privateMetadata } : {}
1551
+ }, fetchImpl).catch(() => false);
1534
1552
  return { ...healed, refreshed };
1535
1553
  }
1536
1554
 
@@ -1541,9 +1559,21 @@ var PAGE = 100;
1541
1559
  function toRecord(u) {
1542
1560
  if (typeof u.id !== "string") return null;
1543
1561
  const pm = u.public_metadata ?? {};
1562
+ const privateMetadata = u.private_metadata ?? {};
1563
+ const rawProfile = privateMetadata.profile;
1564
+ const profile = rawProfile && typeof rawProfile === "object" && !Array.isArray(rawProfile) ? rawProfile : {};
1565
+ const applicationId = typeof privateMetadata.applicationId === "string" ? privateMetadata.applicationId : void 0;
1544
1566
  const role = typeof pm.role === "string" && pm.role ? pm.role : DEFAULT_ROLE;
1545
1567
  const email = u.email_addresses?.[0]?.email_address;
1546
- return { id: u.id, email: typeof email === "string" ? email : void 0, role, publicMetadata: pm };
1568
+ return {
1569
+ id: u.id,
1570
+ email: typeof email === "string" ? email : void 0,
1571
+ role,
1572
+ publicMetadata: pm,
1573
+ privateMetadata,
1574
+ profile,
1575
+ ...applicationId ? { applicationId } : {}
1576
+ };
1547
1577
  }
1548
1578
  async function clerkGet(path, secretKey, fetchImpl) {
1549
1579
  const res = await fetchImpl(`${CLERK_API}${path}`, { headers: { authorization: `Bearer ${secretKey}` } });
@@ -1606,7 +1636,7 @@ var clerkIntegration = {
1606
1636
  },
1607
1637
  {
1608
1638
  key: "clerk_secret_key",
1609
- description: "Clerk backend key (sk_*) \u2014 powers the odla->Clerk writes (account create, role + profile via public_metadata) and lets odla-db resolve user email/name via the Clerk API.",
1639
+ description: "Clerk backend key (sk_*) \u2014 powers account creation, the public role claim, and backend-only private profile signals; it also lets odla-db resolve user email/name via the Clerk API.",
1610
1640
  pattern: "sk_",
1611
1641
  mode: "full",
1612
1642
  vault: true
@@ -1626,8 +1656,13 @@ var clerkIntegration = {
1626
1656
  engine: "@odla-ai/chapter clerk.ts (Clerk Backend API via vault clerk_secret_key)",
1627
1657
  direction: "odla->provider",
1628
1658
  entity: "clerk user",
1629
- // Separate merge-PATCHes so a role write never clobbers a profile write.
1630
- fields: ["public_metadata.role", "public_metadata.profile"],
1659
+ // Separate metadata fields so a public role write cannot expose or
1660
+ // clobber backend-only account/profile signals.
1661
+ fields: [
1662
+ "public_metadata.role",
1663
+ "private_metadata.applicationId",
1664
+ "private_metadata.profile"
1665
+ ],
1631
1666
  onDelete: "n/a (writes only)"
1632
1667
  }
1633
1668
  ],
@@ -2004,6 +2039,8 @@ export {
2004
2039
  submitApplication,
2005
2040
  subscriptionIdempotencyKey,
2006
2041
  syncApplicationToCrm,
2042
+ updateClerkUserMetadata,
2043
+ updateClerkUserMetadataByEmail,
2007
2044
  validateScheduling,
2008
2045
  verifyStripeSignature,
2009
2046
  webhookMutationId