@odla-ai/chapter 0.24.0 → 0.25.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.
@@ -299,10 +299,9 @@ interface ChapterApplication {
299
299
  * writing a row with no consent record. Default `true`. Set `false`
300
300
  * deliberately only when the site renders no consent control. */
301
301
  requireDisclaimerAck?: boolean;
302
- /** Allowlist of fields that reach the Clerk account's client-readable
303
- * `public_metadata.profile`. Default `[]`, so application details remain
304
- * db-only. Set a curated list (e.g. `["phone", "state", "focus"]`) for
305
- * fields the browser may read. */
302
+ /** Allowlist of application fields mirrored into the Clerk account's
303
+ * backend-only `private_metadata.profile`. Default `[]`. Use this for small
304
+ * account/admin signals; the application row and CRM remain canonical. */
306
305
  profileFields?: readonly string[];
307
306
  /** Extra application fields carried into the one-way CRM projection, on top of
308
307
  * the built-in identity/contact set. Each MUST be declared on your crm person
@@ -299,10 +299,9 @@ interface ChapterApplication {
299
299
  * writing a row with no consent record. Default `true`. Set `false`
300
300
  * deliberately only when the site renders no consent control. */
301
301
  requireDisclaimerAck?: boolean;
302
- /** Allowlist of fields that reach the Clerk account's client-readable
303
- * `public_metadata.profile`. Default `[]`, so application details remain
304
- * db-only. Set a curated list (e.g. `["phone", "state", "focus"]`) for
305
- * fields the browser may read. */
302
+ /** Allowlist of application fields mirrored into the Clerk account's
303
+ * backend-only `private_metadata.profile`. Default `[]`. Use this for small
304
+ * account/admin signals; the application row and CRM remain canonical. */
306
305
  profileFields?: readonly string[];
307
306
  /** Extra application fields carried into the one-way CRM projection, on top of
308
307
  * the built-in identity/contact set. Each MUST be declared on your crm person
@@ -519,23 +519,30 @@ function clerkUserRequest(input) {
519
519
  skip_password_requirement: true,
520
520
  ...input.firstName ? { first_name: input.firstName } : {},
521
521
  ...input.lastName ? { last_name: input.lastName } : {},
522
- ...input.publicMetadata ? { public_metadata: input.publicMetadata } : {}
522
+ ...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
523
+ ...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
523
524
  }
524
525
  };
525
526
  }
526
- async function refreshUserMetadata(secretKey, email, publicMetadata, fetchImpl) {
527
+ async function updateClerkUserMetadata(secretKey, userId, input, fetchImpl = fetch) {
528
+ const res = await fetchImpl(`https://api.clerk.com/v1/users/${encodeURIComponent(userId)}/metadata`, {
529
+ method: "PATCH",
530
+ headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
531
+ body: JSON.stringify({
532
+ ...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
533
+ ...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
534
+ })
535
+ });
536
+ return res.ok;
537
+ }
538
+ async function updateClerkUserMetadataByEmail(secretKey, email, input, fetchImpl = fetch) {
527
539
  const auth = { authorization: `Bearer ${secretKey}` };
528
540
  const found = await fetchImpl(`https://api.clerk.com/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, { headers: auth });
529
541
  if (!found.ok) return false;
530
542
  const users = await found.json().catch(() => null);
531
543
  const id = Array.isArray(users) && typeof users[0]?.id === "string" ? users[0].id : void 0;
532
544
  if (!id) return false;
533
- const patched = await fetchImpl(`https://api.clerk.com/v1/users/${id}/metadata`, {
534
- method: "PATCH",
535
- headers: { ...auth, "content-type": "application/json" },
536
- body: JSON.stringify({ public_metadata: publicMetadata })
537
- });
538
- return patched.ok;
545
+ return updateClerkUserMetadata(secretKey, id, input, fetchImpl);
539
546
  }
540
547
  async function createClerkUser(secretKey, input, fetchImpl = fetch) {
541
548
  const { path, body } = clerkUserRequest(input);
@@ -546,8 +553,15 @@ async function createClerkUser(secretKey, input, fetchImpl = fetch) {
546
553
  });
547
554
  if (res.ok) return { ok: true, status: res.status };
548
555
  const healed = heal(res.status);
549
- if (!healed.existed || !input.publicMetadata) return healed;
550
- const refreshed = await refreshUserMetadata(secretKey, input.email, input.publicMetadata, fetchImpl).catch(() => false);
556
+ const repair = input.repairMetadata ?? {
557
+ ...input.publicMetadata !== void 0 ? { publicMetadata: input.publicMetadata } : {},
558
+ ...input.privateMetadata !== void 0 ? { privateMetadata: input.privateMetadata } : {}
559
+ };
560
+ if (!healed.existed || repair.publicMetadata === void 0 && repair.privateMetadata === void 0) return healed;
561
+ const refreshed = await updateClerkUserMetadataByEmail(secretKey, input.email, {
562
+ ...repair.publicMetadata !== void 0 ? { publicMetadata: repair.publicMetadata } : {},
563
+ ...repair.privateMetadata !== void 0 ? { privateMetadata: repair.privateMetadata } : {}
564
+ }, fetchImpl).catch(() => false);
551
565
  return { ...healed, refreshed };
552
566
  }
553
567
 
@@ -655,6 +669,41 @@ function emailGroupFrom(row) {
655
669
  }
656
670
 
657
671
  // src/worker-provisioning.ts
672
+ var applicantPrivateMetadata = (chapter, applicationId, fields, removeMissingProfile = false) => {
673
+ const profile = applicantProfile(chapter, fields);
674
+ return {
675
+ applicationId,
676
+ ...profile ? { profile } : removeMissingProfile ? { profile: null } : {}
677
+ };
678
+ };
679
+ var publicProfileRemoval = { applicationId: null, profile: null };
680
+ async function markPrivateMetadataSynced(db, applicationId, clerkUserId) {
681
+ await db.transact([{
682
+ t: "update",
683
+ ns: "applications",
684
+ id: applicationId,
685
+ attrs: {
686
+ clerkPrivateMetadataSyncedAt: Date.now(),
687
+ ...clerkUserId ? { clerkUserId } : {}
688
+ }
689
+ }]);
690
+ }
691
+ async function syncApplicantPrivateMetadata(db, chapter, applicationId, fields, clerkUserId) {
692
+ if (chapter.account === "none") return false;
693
+ try {
694
+ const secret = await getVaultSecret(db, "clerk_secret_key");
695
+ if (!secret) return false;
696
+ const ok = await updateClerkUserMetadata(secret, clerkUserId, {
697
+ publicMetadata: publicProfileRemoval,
698
+ privateMetadata: applicantPrivateMetadata(chapter, applicationId, fields, true)
699
+ });
700
+ if (!ok) return false;
701
+ await markPrivateMetadataSynced(db, applicationId, clerkUserId);
702
+ return true;
703
+ } catch {
704
+ return false;
705
+ }
706
+ }
658
707
  async function provisionApplicant(db, chapter, applicationId, fields) {
659
708
  const email = typeof fields.email === "string" ? fields.email : "";
660
709
  if (!email) return;
@@ -682,17 +731,30 @@ async function provisionApplicant(db, chapter, applicationId, fields) {
682
731
  try {
683
732
  const secret = await getVaultSecret(db, "clerk_secret_key");
684
733
  if (!secret) return;
685
- const profile = applicantProfile(chapter, fields);
686
- const publicMetadata = { applicationId, ...profile ? { profile } : {} };
734
+ const privateMetadata = applicantPrivateMetadata(chapter, applicationId, fields);
687
735
  if (chapter.account === "create") {
688
- await createClerkUser(secret, {
736
+ const result = await createClerkUser(secret, {
689
737
  email,
690
738
  firstName: stringValue(fields.firstName),
691
739
  lastName: stringValue(fields.lastName),
692
- publicMetadata
740
+ privateMetadata,
741
+ repairMetadata: {
742
+ publicMetadata: publicProfileRemoval,
743
+ privateMetadata: applicantPrivateMetadata(chapter, applicationId, fields, true)
744
+ }
693
745
  });
746
+ if (result.ok && (!result.existed || result.refreshed)) {
747
+ await markPrivateMetadataSynced(db, applicationId);
748
+ }
694
749
  } else {
695
- await createClerkInvitation(secret, { email, publicMetadata });
750
+ const result = await createClerkInvitation(secret, { email });
751
+ if (result.existed) {
752
+ const refreshed = await updateClerkUserMetadataByEmail(secret, email, {
753
+ publicMetadata: publicProfileRemoval,
754
+ privateMetadata: applicantPrivateMetadata(chapter, applicationId, fields, true)
755
+ });
756
+ if (refreshed) await markPrivateMetadataSynced(db, applicationId);
757
+ }
696
758
  }
697
759
  } catch {
698
760
  }
@@ -741,7 +803,10 @@ async function memberSessionApplication(db, chapterId, email) {
741
803
  const groups = (await db.query({ groups: { $: { where: { id: chapterId }, limit: 1 } } })).groups;
742
804
  const group = Array.isArray(groups) ? groups[0] : void 0;
743
805
  const timezone = resolveScheduling(group?.schedulingJson).timezone;
744
- return memberApplication(app, meeting, timezone);
806
+ return {
807
+ application: memberApplication(app, meeting, timezone),
808
+ source: app
809
+ };
745
810
  }
746
811
  var handleHealth = async (_req, url) => url.pathname === "/api/health" ? json({ ok: true }) : null;
747
812
  var handleConfig = async (_req, url, env, ctx) => {
@@ -762,8 +827,26 @@ var handleMe = async (req, url, env, ctx) => {
762
827
  const superAdmin = await ctx.isSuperAdminEmail(db, u.email);
763
828
  const base = { authorized: isAdminRole(role, ctx.auth), role, superAdmin, email: u.email ?? null };
764
829
  if (ctx.chapter.mode !== "chapter" || !u.email) return json(base);
765
- const application = await memberSessionApplication(db, ctx.chapter.id, u.email);
766
- return json({ ...base, application });
830
+ const member = await memberSessionApplication(db, ctx.chapter.id, u.email);
831
+ let privateMetadataSynced = false;
832
+ if (member && ctx.chapter.account !== "none" && typeof member.source.clerkPrivateMetadataSyncedAt !== "number") {
833
+ privateMetadataSynced = await syncApplicantPrivateMetadata(
834
+ db,
835
+ ctx.chapter,
836
+ String(member.source.id),
837
+ member.source,
838
+ u.userId
839
+ );
840
+ }
841
+ if (member && !privateMetadataSynced && member.source.clerkUserId !== u.userId) {
842
+ await db.transact([{
843
+ t: "update",
844
+ ns: "applications",
845
+ id: String(member.source.id),
846
+ attrs: { clerkUserId: u.userId }
847
+ }]).catch(() => void 0);
848
+ }
849
+ return json({ ...base, application: member?.application ?? null });
767
850
  };
768
851
  var handleCrm = async (req, url, env, ctx) => {
769
852
  const crmBase = ctx.crmBase;
@@ -1618,9 +1701,21 @@ var PAGE = 100;
1618
1701
  function toRecord(u) {
1619
1702
  if (typeof u.id !== "string") return null;
1620
1703
  const pm = u.public_metadata ?? {};
1704
+ const privateMetadata = u.private_metadata ?? {};
1705
+ const rawProfile = privateMetadata.profile;
1706
+ const profile = rawProfile && typeof rawProfile === "object" && !Array.isArray(rawProfile) ? rawProfile : {};
1707
+ const applicationId = typeof privateMetadata.applicationId === "string" ? privateMetadata.applicationId : void 0;
1621
1708
  const role = typeof pm.role === "string" && pm.role ? pm.role : DEFAULT_ROLE;
1622
1709
  const email = u.email_addresses?.[0]?.email_address;
1623
- return { id: u.id, email: typeof email === "string" ? email : void 0, role, publicMetadata: pm };
1710
+ return {
1711
+ id: u.id,
1712
+ email: typeof email === "string" ? email : void 0,
1713
+ role,
1714
+ publicMetadata: pm,
1715
+ privateMetadata,
1716
+ profile,
1717
+ ...applicationId ? { applicationId } : {}
1718
+ };
1624
1719
  }
1625
1720
  async function clerkGet(path, secretKey, fetchImpl) {
1626
1721
  const res = await fetchImpl(`${CLERK_API}${path}`, { headers: { authorization: `Bearer ${secretKey}` } });
@@ -1691,17 +1786,20 @@ var handleAdminPeople = async (req, url, env, ctx) => {
1691
1786
  db.query({ $users: { $: { limit: 200 } } }),
1692
1787
  sk ? clerkListUsers(sk).catch(() => []) : Promise.resolve([])
1693
1788
  ]);
1694
- const roleByUserId = new Map(roleList.map((u) => [u.id, u.role]));
1789
+ const clerkByUserId = new Map(roleList.map((u) => [u.id, u]));
1695
1790
  const people = /* @__PURE__ */ new Map();
1696
1791
  for (const u of usersRes.$users ?? []) {
1697
1792
  if (u.deleted === true) continue;
1698
1793
  const email = typeof u.email === "string" ? u.email : "";
1699
1794
  if (!email) continue;
1795
+ const clerk = clerkByUserId.get(String(u.id));
1700
1796
  people.set(email.toLowerCase(), {
1701
1797
  email,
1702
1798
  name: typeof u.name === "string" ? u.name : "",
1703
1799
  userId: typeof u.id === "string" ? u.id : null,
1704
- role: roleByUserId.get(String(u.id)) ?? "provisional",
1800
+ role: clerk?.role ?? "provisional",
1801
+ profile: clerk ? clerk.profile : null,
1802
+ clerkApplicationId: clerk?.applicationId ?? null,
1705
1803
  application: null
1706
1804
  });
1707
1805
  }
@@ -1714,7 +1812,15 @@ var handleAdminPeople = async (req, url, env, ctx) => {
1714
1812
  if (!row.application) row.application = applicationSummary(a);
1715
1813
  if (!row.name) row.name = name;
1716
1814
  } else {
1717
- people.set(key, { email: String(a.email), name, userId: null, role: null, application: applicationSummary(a) });
1815
+ people.set(key, {
1816
+ email: String(a.email),
1817
+ name,
1818
+ userId: null,
1819
+ role: null,
1820
+ profile: null,
1821
+ clerkApplicationId: null,
1822
+ application: applicationSummary(a)
1823
+ });
1718
1824
  }
1719
1825
  }
1720
1826
  const rows2 = [...people.values()].sort(
@@ -1766,6 +1872,88 @@ var handleAdminPeopleRole = async (req, url, env, ctx) => {
1766
1872
  return json({ ok: true });
1767
1873
  };
1768
1874
 
1875
+ // src/worker-routes-admin-clerk.ts
1876
+ var own = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
1877
+ function canonical(value) {
1878
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
1879
+ if (value && typeof value === "object") {
1880
+ return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`).join(",")}}`;
1881
+ }
1882
+ return JSON.stringify(value) ?? "undefined";
1883
+ }
1884
+ function positiveInt(raw, fallback, max) {
1885
+ const parsed = Number(raw);
1886
+ return Number.isSafeInteger(parsed) && parsed >= 0 ? Math.min(parsed, max) : fallback;
1887
+ }
1888
+ var handleAdminClerkPrivateProfileSync = async (req, url, env, ctx) => {
1889
+ if (req.method !== "POST" || url.pathname !== "/api/admin/clerk/private-profiles/sync") return null;
1890
+ const rawDb = ctx.makeDb(env);
1891
+ const actor = await ctx.verifyUser(req, env);
1892
+ if (!actor) return json({ error: "unauthorized" }, 401);
1893
+ if (!await ctx.isAdmin(rawDb, actor)) return json({ error: "forbidden" }, 403);
1894
+ if (ctx.chapter.mode !== "chapter" || ctx.chapter.account === "none") {
1895
+ return json({ error: "private profile sync requires a side-effecting chapter account model" }, 409);
1896
+ }
1897
+ const db = rawDb;
1898
+ const secret = await getVaultSecret(db, "clerk_secret_key");
1899
+ if (!secret) return json({ error: "clerk_secret_key missing from vault" }, 503);
1900
+ let all;
1901
+ try {
1902
+ all = await clerkListUsers(secret);
1903
+ } catch {
1904
+ return json({ error: "Clerk user listing failed" }, 502);
1905
+ }
1906
+ const offset = positiveInt(url.searchParams.get("offset"), 0, Number.MAX_SAFE_INTEGER);
1907
+ const limit = positiveInt(url.searchParams.get("limit"), 50, 100) || 50;
1908
+ const users = all.slice(offset, offset + limit);
1909
+ const result = { updated: 0, alreadyCurrent: 0, noApplication: 0, failed: 0 };
1910
+ for (const user of users) {
1911
+ if (!user.email) {
1912
+ result.noApplication += 1;
1913
+ continue;
1914
+ }
1915
+ const apps = (await db.query({
1916
+ applications: { $: { where: { email: user.email }, order: { createdAt: "desc" }, limit: 1 } }
1917
+ })).applications;
1918
+ const app = Array.isArray(apps) ? apps[0] : void 0;
1919
+ if (!app || typeof app.id !== "string") {
1920
+ result.noApplication += 1;
1921
+ continue;
1922
+ }
1923
+ const desired = applicantPrivateMetadata(ctx.chapter, app.id, app);
1924
+ const desiredProfile = desired.profile ?? {};
1925
+ const legacyPublic = own(user.publicMetadata, "applicationId") || own(user.publicMetadata, "profile");
1926
+ const current = user.applicationId === app.id && canonical(user.profile) === canonical(desiredProfile);
1927
+ try {
1928
+ let updated = false;
1929
+ if (!current || legacyPublic) {
1930
+ const ok = await updateClerkUserMetadata(secret, user.id, {
1931
+ publicMetadata: publicProfileRemoval,
1932
+ privateMetadata: applicantPrivateMetadata(ctx.chapter, app.id, app, true)
1933
+ });
1934
+ if (!ok) {
1935
+ result.failed += 1;
1936
+ continue;
1937
+ }
1938
+ updated = true;
1939
+ }
1940
+ await markPrivateMetadataSynced(db, app.id, user.id);
1941
+ if (updated) result.updated += 1;
1942
+ else result.alreadyCurrent += 1;
1943
+ } catch {
1944
+ result.failed += 1;
1945
+ }
1946
+ }
1947
+ const nextOffset = offset + users.length < all.length ? offset + users.length : null;
1948
+ return json({
1949
+ totalUsers: all.length,
1950
+ offset,
1951
+ processed: users.length,
1952
+ nextOffset,
1953
+ ...result
1954
+ });
1955
+ };
1956
+
1769
1957
  // src/series.ts
1770
1958
  function periodSeries(points, now, count, periodMs) {
1771
1959
  const start = now - count * periodMs;
@@ -2382,6 +2570,7 @@ var BUILTIN_ROUTES = [
2382
2570
  handleAdminPeopleAccess,
2383
2571
  handleAdminPeopleRole,
2384
2572
  handleAdminCrmSync,
2573
+ handleAdminClerkPrivateProfileSync,
2385
2574
  // Aggregation
2386
2575
  handleAdminDashboard,
2387
2576
  handleAdminBilling,