@odla-ai/chapter 0.45.0 → 0.47.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.
@@ -115,12 +115,16 @@ function createWorkerContext(options) {
115
115
  }
116
116
  async function isAdminEmail(db, email) {
117
117
  if (!email) return false;
118
- const { admins } = await db.query({ admins: { $: { where: { email: email.toLowerCase() }, limit: 1 } } });
118
+ const { admins } = await db.query({ admins: { $: { where: { email: email.trim().toLowerCase() }, limit: 1 } } });
119
119
  return Array.isArray(admins) && admins.length > 0;
120
120
  }
121
121
  async function isSuperAdminEmail(db, email) {
122
122
  if (!auth.superAdmins || !email) return false;
123
- const { superAdmins } = await db.query({ superAdmins: { $: { where: { email: email.toLowerCase() }, limit: 1 } } });
123
+ if (auth.superAdminSource === "odla") {
124
+ const { $users } = await db.query({ $users: { $: { where: { email: email.trim().toLowerCase(), role: "super_admin" }, limit: 1 } } });
125
+ return Array.isArray($users) && $users.length > 0;
126
+ }
127
+ const { superAdmins } = await db.query({ superAdmins: { $: { where: { email: email.trim().toLowerCase() }, limit: 1 } } });
124
128
  return Array.isArray(superAdmins) && superAdmins.length > 0;
125
129
  }
126
130
  async function roleFor(db, u) {
@@ -759,8 +763,8 @@ var APPLICATION_LABELS = {
759
763
  state: "State"
760
764
  };
761
765
  function humanize(id) {
762
- const text5 = id.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim();
763
- return text5 ? `${text5[0].toUpperCase()}${text5.slice(1)}` : id;
766
+ const text6 = id.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim();
767
+ return text6 ? `${text6[0].toUpperCase()}${text6.slice(1)}` : id;
764
768
  }
765
769
  function applicationDefs(chapter) {
766
770
  try {
@@ -1290,10 +1294,10 @@ function planDelivery(input) {
1290
1294
  const transport = !isProd && !redirect ? "log-only" : input.cloudflareReady ? "cloudflare" : "log-only";
1291
1295
  const to = redirect ? input.group.debugEmail : input.to;
1292
1296
  const subject = (redirect ? "[dev] " : "") + render(tpl.subject, vars);
1293
- const text5 = redirect ? `(dev redirect; original recipient: ${input.to})
1297
+ const text6 = redirect ? `(dev redirect; original recipient: ${input.to})
1294
1298
 
1295
1299
  ` + render(tpl.text, vars) : render(tpl.text, vars);
1296
- return { deliver: true, transport, to, subject, text: text5, redirected: redirect };
1300
+ return { deliver: true, transport, to, subject, text: text6, redirected: redirect };
1297
1301
  }
1298
1302
 
1299
1303
  // src/notify.ts
@@ -2181,10 +2185,10 @@ function boundedString(value, path, errors, max, required = false) {
2181
2185
  errors.push(`${path} must be a string`);
2182
2186
  return void 0;
2183
2187
  }
2184
- const text5 = value.trim();
2185
- if (required && !text5) errors.push(`${path} is required`);
2186
- if (text5.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
2187
- return text5;
2188
+ const text6 = value.trim();
2189
+ if (required && !text6) errors.push(`${path} is required`);
2190
+ if (text6.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
2191
+ return text6;
2188
2192
  }
2189
2193
  function validateMembershipPageDocument(input, options = {}) {
2190
2194
  const errors = [];
@@ -2233,8 +2237,8 @@ function validateMembershipPageDocument(input, options = {}) {
2233
2237
  else {
2234
2238
  if (item.benefits.length > MAX_BENEFITS) errors.push(`${path}.benefits supports at most ${MAX_BENEFITS} entries`);
2235
2239
  benefits = item.benefits.slice(0, MAX_BENEFITS).flatMap((benefit, benefitIndex) => {
2236
- const text5 = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
2237
- return text5 ? [text5] : [];
2240
+ const text6 = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
2241
+ return text6 ? [text6] : [];
2238
2242
  });
2239
2243
  }
2240
2244
  }
@@ -3946,17 +3950,17 @@ async function notifyAdminOfPayment(db, env, eventId, app) {
3946
3950
  try {
3947
3951
  const group = await groupFor(db, app);
3948
3952
  if (!group || typeof group.notificationEmail !== "string" || !group.notificationEmail) return;
3949
- const text5 = (value) => typeof value === "string" ? value : "";
3953
+ const text6 = (value) => typeof value === "string" ? value : "";
3950
3954
  await sendTemplated(deps(db, env), {
3951
3955
  group: emailGroupFrom(group),
3952
3956
  template: "adminNotification",
3953
3957
  to: group.notificationEmail,
3954
3958
  vars: {
3955
- firstName: text5(app.firstName),
3956
- lastName: text5(app.lastName),
3957
- email: text5(app.email),
3958
- phone: text5(app.phone),
3959
- state: text5(app.state)
3959
+ firstName: text6(app.firstName),
3960
+ lastName: text6(app.lastName),
3961
+ email: text6(app.email),
3962
+ phone: text6(app.phone),
3963
+ state: text6(app.state)
3960
3964
  },
3961
3965
  dedupeKey: `${eventId}:admin`,
3962
3966
  applicationId: String(app.id)
@@ -5720,11 +5724,11 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
5720
5724
  for (const key of EMAIL_TEMPLATE_NAMES) {
5721
5725
  const t = templates[key];
5722
5726
  const subject = typeof t?.subject === "string" ? t.subject.trim() : "";
5723
- const text5 = typeof t?.text === "string" ? t.text : "";
5724
- if (!subject || !text5.trim()) return json({ error: `template "${key}" needs a subject and a body` }, 400);
5727
+ const text6 = typeof t?.text === "string" ? t.text : "";
5728
+ if (!subject || !text6.trim()) return json({ error: `template "${key}" needs a subject and a body` }, 400);
5725
5729
  if (/[\r\n]/.test(subject) || subject.length > 200) return json({ error: `template "${key}" subject must be a single line under 200 characters` }, 400);
5726
- if (text5.length > 1e4) return json({ error: `template "${key}" body is too long` }, 400);
5727
- clean[key] = { subject, text: text5, enabled: t?.enabled !== false };
5730
+ if (text6.length > 1e4) return json({ error: `template "${key}" body is too long` }, 400);
5731
+ clean[key] = { subject, text: text6, enabled: t?.enabled !== false };
5728
5732
  }
5729
5733
  const emailish = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
5730
5734
  const notificationEmail = str2(body.notificationEmail).trim();
@@ -5941,7 +5945,8 @@ var handleAdminNetworkTargets = async (req, url, env, ctx) => {
5941
5945
  fields,
5942
5946
  sharedNotes,
5943
5947
  editableFields,
5944
- stageTransitions
5948
+ stageTransitions,
5949
+ commercialParity
5945
5950
  }) => ({
5946
5951
  id,
5947
5952
  name,
@@ -5949,7 +5954,8 @@ var handleAdminNetworkTargets = async (req, url, env, ctx) => {
5949
5954
  types: Object.keys(fields ?? DEFAULT_SHARE_FIELDS),
5950
5955
  sharedNotes,
5951
5956
  editableFields,
5952
- stageTransitions
5957
+ stageTransitions,
5958
+ commercialParity
5953
5959
  }))
5954
5960
  });
5955
5961
  };
@@ -7207,9 +7213,9 @@ var handleAdminNetworkPersonRegistry = async (req, url, env, ctx) => {
7207
7213
  if (req.method === "POST") {
7208
7214
  let body;
7209
7215
  try {
7210
- const text5 = await req.text();
7211
- if (new TextEncoder().encode(text5).byteLength > MAX_BODY2) return json({ error: "request is too large" }, 413);
7212
- const parsed = JSON.parse(text5);
7216
+ const text6 = await req.text();
7217
+ if (new TextEncoder().encode(text6).byteLength > MAX_BODY2) return json({ error: "request is too large" }, 413);
7218
+ const parsed = JSON.parse(text6);
7213
7219
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("invalid");
7214
7220
  body = parsed;
7215
7221
  } catch {
@@ -7804,6 +7810,298 @@ function reconcileStripeSnapshot(shadow, snapshots, sources) {
7804
7810
  };
7805
7811
  }
7806
7812
 
7813
+ // src/worker-routes-network-commercial-parity.ts
7814
+ var import_db12 = require("@odla-ai/db");
7815
+
7816
+ // src/network-commercial-parity.ts
7817
+ var DIGEST = /^[0-9a-f]{64}$/;
7818
+ var MAX_RECORDS = 500;
7819
+ var text4 = (value, max = 255) => typeof value === "string" && value.length > 0 && value.length <= max ? value : null;
7820
+ function parityMessage(partition, kind, value) {
7821
+ return [
7822
+ "chapter-commercial-parity",
7823
+ "v1",
7824
+ partition.appId,
7825
+ partition.chapterId,
7826
+ partition.environment,
7827
+ partition.dataEnvironment,
7828
+ partition.runtime,
7829
+ kind,
7830
+ value
7831
+ ].join("");
7832
+ }
7833
+ async function parityKey(secret) {
7834
+ const encoder4 = new TextEncoder();
7835
+ return crypto.subtle.importKey(
7836
+ "raw",
7837
+ encoder4.encode(secret),
7838
+ { name: "HMAC", hash: "SHA-256" },
7839
+ false,
7840
+ ["sign"]
7841
+ );
7842
+ }
7843
+ async function digestWithKey(key, partition, kind, value) {
7844
+ const encoder4 = new TextEncoder();
7845
+ const signed = await crypto.subtle.sign("HMAC", key, encoder4.encode(parityMessage(partition, kind, value)));
7846
+ return [...new Uint8Array(signed)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
7847
+ }
7848
+ async function buildChapterCommercialParitySnapshot(input) {
7849
+ const requestedMax = input.maxRecords ?? MAX_RECORDS;
7850
+ const maxRecords = Number.isSafeInteger(requestedMax) ? Math.max(0, Math.min(requestedMax, MAX_RECORDS)) : MAX_RECORDS;
7851
+ const eligible = input.rows.filter((row) => text4(row.id) && (text4(row.stripeCustomerId) || text4(row.stripeSubscriptionId)));
7852
+ const selected2 = eligible.slice(0, maxRecords);
7853
+ const key = await parityKey(input.secret);
7854
+ const records = await Promise.all(selected2.map(async (row) => {
7855
+ const applicationId = text4(row.id);
7856
+ const customerId = text4(row.stripeCustomerId);
7857
+ const subscriptionId = text4(row.stripeSubscriptionId);
7858
+ return {
7859
+ applicationDigest: await digestWithKey(key, input.partition, "application", applicationId),
7860
+ customerDigest: customerId ? await digestWithKey(key, input.partition, "customer", customerId) : null,
7861
+ subscriptionDigest: subscriptionId ? await digestWithKey(key, input.partition, "subscription", subscriptionId) : null,
7862
+ status: text4(row.status, 64),
7863
+ canceled: row.canceled === true
7864
+ };
7865
+ }));
7866
+ records.sort((left, right) => left.applicationDigest.localeCompare(right.applicationDigest));
7867
+ const truncated = input.scanTruncated || eligible.length > maxRecords;
7868
+ return {
7869
+ version: 1,
7870
+ site: { id: input.siteId },
7871
+ partition: input.partition,
7872
+ generatedAt: input.now ?? Date.now(),
7873
+ complete: !truncated,
7874
+ truncated,
7875
+ counts: { scanned: input.scanned, eligible: eligible.length, returned: records.length },
7876
+ records
7877
+ };
7878
+ }
7879
+ function recordKey(record4) {
7880
+ return `${record4.applicationDigest}:${record4.customerDigest ?? "-"}:${record4.subscriptionDigest ?? "-"}`;
7881
+ }
7882
+ function validSnapshot(snapshot, source) {
7883
+ const partition = snapshot?.partition;
7884
+ const records = snapshot?.records;
7885
+ if (snapshot?.version !== 1 || snapshot.site?.id !== source.chapterId || !partition || !Array.isArray(records) || records.length > MAX_RECORDS || partition.appId !== source.appId || partition.chapterId !== source.chapterId || partition.environment !== source.environment || partition.dataEnvironment !== source.dataEnvironment || partition.runtime !== source.runtime || typeof snapshot.complete !== "boolean" || typeof snapshot.truncated !== "boolean" || !snapshot.counts) return false;
7886
+ const seen = /* @__PURE__ */ new Set();
7887
+ for (const record4 of records) {
7888
+ if (!DIGEST.test(record4.applicationDigest) || record4.customerDigest !== null && !DIGEST.test(record4.customerDigest) || record4.subscriptionDigest !== null && !DIGEST.test(record4.subscriptionDigest) || record4.status !== null && text4(record4.status, 64) === null || typeof record4.canceled !== "boolean") return false;
7889
+ const key = recordKey(record4);
7890
+ if (seen.has(key)) return false;
7891
+ seen.add(key);
7892
+ }
7893
+ return Number.isSafeInteger(snapshot.counts.scanned) && Number.isSafeInteger(snapshot.counts.eligible) && Number.isSafeInteger(snapshot.counts.returned) && snapshot.counts.returned === records.length && snapshot.counts.scanned >= snapshot.counts.eligible && snapshot.counts.eligible >= snapshot.counts.returned;
7894
+ }
7895
+ async function reconcileCommercialParitySnapshot(input) {
7896
+ if (!validSnapshot(input.snapshot, input.source)) throw new Error("invalid chapter commercial parity snapshot");
7897
+ const sourceKey = networkCommercialSourceKey(input.source);
7898
+ const key = await parityKey(input.secret);
7899
+ const shadow = await Promise.all(input.projection.subscriptions.filter((row) => row.sourceKey === sourceKey && text4(row.applicationId) && text4(row.objectId)).map(async (row) => {
7900
+ const customerId = text4(row.customerId);
7901
+ const subscriptionId = text4(row.subscriptionId) ?? text4(row.objectId);
7902
+ return {
7903
+ applicationDigest: await digestWithKey(key, input.snapshot.partition, "application", row.applicationId),
7904
+ customerDigest: customerId ? await digestWithKey(key, input.snapshot.partition, "customer", customerId) : null,
7905
+ subscriptionDigest: subscriptionId ? await digestWithKey(key, input.snapshot.partition, "subscription", subscriptionId) : null,
7906
+ status: null,
7907
+ canceled: false,
7908
+ stripeStatus: text4(row.status, 64),
7909
+ stripeCanceled: row.status === "canceled"
7910
+ };
7911
+ }));
7912
+ const chapterByKey = new Map(input.snapshot.records.map((record4) => [recordKey(record4), record4]));
7913
+ const shadowByKey = new Map(shadow.map((record4) => [recordKey(record4), record4]));
7914
+ const exceptions = [];
7915
+ const records = [];
7916
+ for (const [key2, chapter] of chapterByKey) {
7917
+ const stripe = shadowByKey.get(key2);
7918
+ if (!stripe) {
7919
+ exceptions.push({ kind: "chapter_only", applicationDigest: chapter.applicationDigest, detail: { chapterStatus: chapter.status } });
7920
+ continue;
7921
+ }
7922
+ const compared = { ...chapter, stripeStatus: stripe.stripeStatus, stripeCanceled: stripe.stripeCanceled };
7923
+ records.push(compared);
7924
+ if (chapter.canceled !== stripe.stripeCanceled) {
7925
+ exceptions.push({
7926
+ kind: "cancellation_mismatch",
7927
+ applicationDigest: chapter.applicationDigest,
7928
+ detail: { chapterCanceled: chapter.canceled, stripeCanceled: stripe.stripeCanceled }
7929
+ });
7930
+ }
7931
+ }
7932
+ for (const [key2, stripe] of shadowByKey) {
7933
+ if (!chapterByKey.has(key2)) {
7934
+ exceptions.push({ kind: "shadow_only", applicationDigest: stripe.applicationDigest, detail: { stripeStatus: stripe.stripeStatus } });
7935
+ }
7936
+ }
7937
+ if (!input.snapshot.complete || input.snapshot.truncated) {
7938
+ exceptions.push({
7939
+ kind: "chapter_truncated",
7940
+ detail: { scanned: input.snapshot.counts.scanned, eligible: input.snapshot.counts.eligible, returned: input.snapshot.counts.returned }
7941
+ });
7942
+ }
7943
+ if (chapterByKey.size !== shadowByKey.size) {
7944
+ exceptions.push({ kind: "count_mismatch", detail: { chapter: chapterByKey.size, shadow: shadowByKey.size } });
7945
+ }
7946
+ records.sort((left, right) => left.applicationDigest.localeCompare(right.applicationDigest));
7947
+ return {
7948
+ ok: input.snapshot.complete && exceptions.length === 0,
7949
+ complete: input.snapshot.complete,
7950
+ counts: { chapter: chapterByKey.size, shadow: shadowByKey.size, matched: records.length },
7951
+ records,
7952
+ exceptions
7953
+ };
7954
+ }
7955
+
7956
+ // src/network-commercial-parity-response.ts
7957
+ var MAX_RESPONSE_BYTES = 512e3;
7958
+ async function readBoundedCommercialParityResponse(response) {
7959
+ const declared = Number(response.headers.get("content-length") ?? 0);
7960
+ if (declared > MAX_RESPONSE_BYTES) return null;
7961
+ if (!response.body) return "";
7962
+ const reader = response.body.getReader();
7963
+ const chunks = [];
7964
+ let total = 0;
7965
+ for (; ; ) {
7966
+ const next = await reader.read();
7967
+ if (next.done) break;
7968
+ total += next.value.byteLength;
7969
+ if (total > MAX_RESPONSE_BYTES) {
7970
+ await reader.cancel();
7971
+ return null;
7972
+ }
7973
+ chunks.push(next.value);
7974
+ }
7975
+ const body = new Uint8Array(total);
7976
+ let offset = 0;
7977
+ for (const chunk of chunks) {
7978
+ body.set(chunk, offset);
7979
+ offset += chunk.byteLength;
7980
+ }
7981
+ return new TextDecoder().decode(body);
7982
+ }
7983
+
7984
+ // src/worker-routes-network-commercial-parity.ts
7985
+ var PARITY_PATH = "/api/network/commercial-parity";
7986
+ var PAGE2 = 500;
7987
+ var MAX_SCAN = 5e3;
7988
+ var MAX_RECORDS2 = 500;
7989
+ async function localCommercialRows(db) {
7990
+ const rows6 = [];
7991
+ for (let offset = 0; offset < MAX_SCAN; offset += PAGE2) {
7992
+ const result = await db.query({ applications: { $: { limit: PAGE2, offset, order: { createdAt: "asc" } } } });
7993
+ const page = Array.isArray(result.applications) ? result.applications : [];
7994
+ rows6.push(...page.slice(0, MAX_SCAN - rows6.length));
7995
+ if (page.length < PAGE2) return { rows: rows6, truncated: false };
7996
+ }
7997
+ return { rows: rows6, truncated: true };
7998
+ }
7999
+ var handleNetworkCommercialParity = async (req, url, env, ctx) => {
8000
+ if (req.method !== "GET" || url.pathname !== PARITY_PATH) return null;
8001
+ if (ctx.chapter.mode !== "chapter") return json({ error: "not found" }, 404);
8002
+ const readers = ctx.chapter.network.readers.filter((reader) => reader.commercialParity);
8003
+ if (readers.length === 0) return json({ error: "unauthorized" }, 401);
8004
+ const db = ctx.makeDb(env);
8005
+ const secret = await getVaultSecret(db, "network_share_secret");
8006
+ if (!secret) return json({ error: "unauthorized" }, 401);
8007
+ const verified = await (0, import_db12.verifyFederatedRequest)(req, { secret, senders: readers.map((reader) => reader.id) });
8008
+ if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
8009
+ const partition = {
8010
+ appId: env.ODLA_APP_ID,
8011
+ chapterId: ctx.chapter.id,
8012
+ environment: env.ODLA_ENV,
8013
+ dataEnvironment: env.ODLA_ENV,
8014
+ runtime: env.ODLA_RUNTIME?.trim() || "default"
8015
+ };
8016
+ try {
8017
+ const local3 = await localCommercialRows(db);
8018
+ return json(await buildChapterCommercialParitySnapshot({
8019
+ rows: local3.rows,
8020
+ secret,
8021
+ partition,
8022
+ siteId: ctx.chapter.id,
8023
+ scanned: local3.rows.length,
8024
+ scanTruncated: local3.truncated,
8025
+ maxRecords: MAX_RECORDS2
8026
+ }));
8027
+ } catch {
8028
+ return json({ error: "commercial parity unavailable" }, 503);
8029
+ }
8030
+ };
8031
+ async function reconcileTarget(db, env, ctx, target, projection) {
8032
+ const base = { targetId: target.id, name: target.name, url: target.url };
8033
+ const secret = await getVaultSecret(db, target.secretName);
8034
+ if (!secret) return { ...base, available: false, ok: false, error: { code: "edge_secret_missing" } };
8035
+ const destination = new URL(PARITY_PATH, target.url);
8036
+ try {
8037
+ const transport = resolveNetworkTargetTransport(env, target);
8038
+ const headers = await (0, import_db12.signFederatedRequest)({ secret, sender: ctx.chapter.id, method: "GET", url: destination });
8039
+ const response = await transport.fetch(destination, { headers, signal: AbortSignal.timeout(1e4) });
8040
+ const responseBody = await readBoundedCommercialParityResponse(response);
8041
+ const snapshot = responseBody !== null ? (() => {
8042
+ try {
8043
+ return JSON.parse(responseBody);
8044
+ } catch {
8045
+ return null;
8046
+ }
8047
+ })() : null;
8048
+ if (!response.ok || !snapshot) {
8049
+ return {
8050
+ ...base,
8051
+ available: false,
8052
+ ok: false,
8053
+ transport: transport.kind,
8054
+ error: { code: "chapter_response_failed", status: response.status }
8055
+ };
8056
+ }
8057
+ const sources = ctx.chapter.networkCommercialReadModel?.sources ?? [];
8058
+ const matchingSources = sources.filter((candidate) => candidate.appId === snapshot.partition?.appId && candidate.chapterId === snapshot.partition?.chapterId && candidate.environment === snapshot.partition?.environment && candidate.dataEnvironment === snapshot.partition?.dataEnvironment && candidate.runtime === snapshot.partition?.runtime);
8059
+ if (matchingSources.length !== 1) {
8060
+ return {
8061
+ ...base,
8062
+ available: true,
8063
+ ok: false,
8064
+ transport: transport.kind,
8065
+ error: { code: matchingSources.length === 0 ? "source_not_configured" : "source_ambiguous" }
8066
+ };
8067
+ }
8068
+ return {
8069
+ ...base,
8070
+ available: true,
8071
+ transport: transport.kind,
8072
+ generatedAt: snapshot.generatedAt,
8073
+ ...await reconcileCommercialParitySnapshot({
8074
+ projection,
8075
+ source: matchingSources[0],
8076
+ snapshot,
8077
+ secret
8078
+ })
8079
+ };
8080
+ } catch (error) {
8081
+ return {
8082
+ ...base,
8083
+ available: false,
8084
+ ok: false,
8085
+ error: {
8086
+ code: error instanceof NetworkTargetTransportError ? "binding_unavailable" : error instanceof Error && error.message === "invalid chapter commercial parity snapshot" ? "invalid_chapter_snapshot" : "chapter_request_failed"
8087
+ }
8088
+ };
8089
+ }
8090
+ }
8091
+ async function reconcileChapterCommercialTargets(db, env, ctx, projection) {
8092
+ const targets = ctx.chapter.network.targets.filter((target) => target.commercialParity);
8093
+ const results = await Promise.all(targets.map((target) => reconcileTarget(db, env, ctx, target, projection)));
8094
+ return {
8095
+ reconciled: true,
8096
+ provider: "chapters",
8097
+ writable: false,
8098
+ configured: targets.length,
8099
+ available: results.filter((result) => result.available).length,
8100
+ ok: results.every((result) => result.ok),
8101
+ targets: results
8102
+ };
8103
+ }
8104
+
7807
8105
  // src/worker-routes-network-commercial.ts
7808
8106
  var ADMIN_PATH2 = "/api/admin/network-commercial-read-model";
7809
8107
  var WEBHOOK_PATH = "/api/network-commercial/stripe-webhook";
@@ -7907,6 +8205,10 @@ var handleAdminNetworkCommercialReadModel = async (req, url, env, ctx) => {
7907
8205
  } catch {
7908
8206
  return json({ error: "invalid JSON body" }, 400);
7909
8207
  }
8208
+ if (parsed.command === "reconcile-chapters") {
8209
+ const projection2 = await loadNetworkCommercialProjection(db, ctx.chapter.networkCommercialReadModel.sources);
8210
+ return json(await reconcileChapterCommercialTargets(db, env, ctx, projection2));
8211
+ }
7910
8212
  if (parsed.command === "reconcile-stripe") {
7911
8213
  const secretKey = await getVaultSecret(db, "stripe_secret_key");
7912
8214
  if (!secretKey) return json({ error: "Stripe reconciliation is not configured" }, 503);
@@ -7929,7 +8231,7 @@ var handleAdminNetworkCommercialReadModel = async (req, url, env, ctx) => {
7929
8231
  }
7930
8232
  }
7931
8233
  if (parsed.command !== "rebuild" || typeof parsed.mutationId !== "string" || !MUTATION_ID5.test(parsed.mutationId)) {
7932
- return json({ error: "rebuild or reconcile-stripe command is required; rebuild also requires a valid mutationId" }, 400);
8234
+ return json({ error: "rebuild, reconcile-stripe, or reconcile-chapters command is required; rebuild also requires a valid mutationId" }, 400);
7933
8235
  }
7934
8236
  const projection = await rebuildNetworkCommercialProjection(db, ctx.chapter.networkCommercialReadModel.sources, parsed.mutationId);
7935
8237
  return json({ rebuilt: true, ...safeProjection(projection) });
@@ -8018,7 +8320,7 @@ var handleFormation = async (req, url, env, ctx) => {
8018
8320
  };
8019
8321
 
8020
8322
  // src/worker-routes-builder-library.ts
8021
- var import_db12 = require("@odla-ai/db");
8323
+ var import_db13 = require("@odla-ai/db");
8022
8324
 
8023
8325
  // src/builder-library-validation.ts
8024
8326
  var ID4 = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -8041,7 +8343,7 @@ var record3 = (value) => value !== null && typeof value === "object" && !Array.i
8041
8343
  function unknownKeys3(value, allowed2, path, errors) {
8042
8344
  for (const key of Object.keys(value)) if (!allowed2.has(key)) errors.push(`${path}.${key} is not supported`);
8043
8345
  }
8044
- function text4(value, path, errors, max = MAX_TEXT2, required = true) {
8346
+ function text5(value, path, errors, max = MAX_TEXT2, required = true) {
8045
8347
  if (value === void 0 && !required) return void 0;
8046
8348
  if (typeof value !== "string" || required && !value.trim()) {
8047
8349
  errors.push(`${path} must be a non-empty string`);
@@ -8067,7 +8369,7 @@ function scopeValue(value, path, errors) {
8067
8369
  return { kind: raw2.kind };
8068
8370
  }
8069
8371
  if (raw2.kind === "chapter") {
8070
- const chapterId = text4(raw2.chapterId, `${path}.chapterId`, errors, 63) ?? "";
8372
+ const chapterId = text5(raw2.chapterId, `${path}.chapterId`, errors, 63) ?? "";
8071
8373
  if (chapterId && !APP_ID.test(chapterId)) errors.push(`${path}.chapterId must be a lowercase app slug`);
8072
8374
  return { kind: "chapter", chapterId };
8073
8375
  }
@@ -8081,22 +8383,22 @@ function resourceValue(value, path, errors) {
8081
8383
  return null;
8082
8384
  }
8083
8385
  unknownKeys3(raw2, RESOURCE_KEYS, path, errors);
8084
- const id = text4(raw2.id, `${path}.id`, errors, 80) ?? "";
8386
+ const id = text5(raw2.id, `${path}.id`, errors, 80) ?? "";
8085
8387
  if (id && !ID4.test(id)) errors.push(`${path}.id must be lowercase kebab-case`);
8086
- const kind = text4(raw2.kind, `${path}.kind`, errors) ?? "";
8388
+ const kind = text5(raw2.kind, `${path}.kind`, errors) ?? "";
8087
8389
  if (!KINDS.has(kind)) errors.push(`${path}.kind is unsupported`);
8088
- const title = text4(raw2.title, `${path}.title`, errors, 240) ?? "";
8089
- const summary = text4(raw2.summary, `${path}.summary`, errors, 1e3, false);
8090
- const status = text4(raw2.status, `${path}.status`, errors) ?? "";
8390
+ const title = text5(raw2.title, `${path}.title`, errors, 240) ?? "";
8391
+ const summary = text5(raw2.summary, `${path}.summary`, errors, 1e3, false);
8392
+ const status = text5(raw2.status, `${path}.status`, errors) ?? "";
8091
8393
  if (!STATUSES.has(status)) errors.push(`${path}.status must be draft, published, or archived`);
8092
8394
  const revision = integer3(raw2.revision, `${path}.revision`, errors);
8093
8395
  const scope = scopeValue(raw2.scope, `${path}.scope`, errors) ?? { kind: "global" };
8094
- const tags = Array.isArray(raw2.tags) ? raw2.tags.map((tag, index2) => text4(tag, `${path}.tags[${index2}]`, errors, 80) ?? "") : [];
8396
+ const tags = Array.isArray(raw2.tags) ? raw2.tags.map((tag, index2) => text5(tag, `${path}.tags[${index2}]`, errors, 80) ?? "") : [];
8095
8397
  if (!Array.isArray(raw2.tags)) errors.push(`${path}.tags must be an array`);
8096
8398
  if (tags.length > MAX_TAGS) errors.push(`${path}.tags may contain at most ${MAX_TAGS} items`);
8097
8399
  if (new Set(tags).size !== tags.length) errors.push(`${path}.tags must be unique`);
8098
- const bodyMarkdown = text4(raw2.bodyMarkdown, `${path}.bodyMarkdown`, errors, MAX_MARKDOWN3, false);
8099
- const url = text4(raw2.url, `${path}.url`, errors, 2048, false);
8400
+ const bodyMarkdown = text5(raw2.bodyMarkdown, `${path}.bodyMarkdown`, errors, MAX_MARKDOWN3, false);
8401
+ const url = text5(raw2.url, `${path}.url`, errors, 2048, false);
8100
8402
  if (url) {
8101
8403
  try {
8102
8404
  if (new URL(url).protocol !== "https:") errors.push(`${path}.url must use https`);
@@ -8108,9 +8410,9 @@ function resourceValue(value, path, errors) {
8108
8410
  if (raw2.brandAsset !== void 0 && !asset) errors.push(`${path}.brandAsset must be an object`);
8109
8411
  if (asset) unknownKeys3(asset, ASSET_KEYS, `${path}.brandAsset`, errors);
8110
8412
  const brandAsset = asset ? {
8111
- bookId: text4(asset.bookId, `${path}.brandAsset.bookId`, errors, 160) ?? "",
8112
- assetId: text4(asset.assetId, `${path}.brandAsset.assetId`, errors, 160) ?? "",
8113
- ...asset.filename === void 0 ? {} : { filename: text4(asset.filename, `${path}.brandAsset.filename`, errors, 240) ?? "" }
8413
+ bookId: text5(asset.bookId, `${path}.brandAsset.bookId`, errors, 160) ?? "",
8414
+ assetId: text5(asset.assetId, `${path}.brandAsset.assetId`, errors, 160) ?? "",
8415
+ ...asset.filename === void 0 ? {} : { filename: text5(asset.filename, `${path}.brandAsset.filename`, errors, 240) ?? "" }
8114
8416
  } : void 0;
8115
8417
  if ((kind === "document" || kind === "note") && !bodyMarkdown) errors.push(`${path}.bodyMarkdown is required for ${kind}`);
8116
8418
  if ((kind === "link" || kind === "inspiration") && !url) errors.push(`${path}.url is required for ${kind}`);
@@ -8125,17 +8427,17 @@ function resourceValue(value, path, errors) {
8125
8427
  if (provenanceRaw?.promotedFrom !== void 0 && !promotedRaw) errors.push(`${path}.provenance.promotedFrom must be an object`);
8126
8428
  if (promotedRaw) unknownKeys3(promotedRaw, PROMOTION_KEYS, `${path}.provenance.promotedFrom`, errors);
8127
8429
  const promotedFrom = promotedRaw ? {
8128
- chapterId: text4(promotedRaw.chapterId, `${path}.provenance.promotedFrom.chapterId`, errors, 63) ?? "",
8129
- resourceId: text4(promotedRaw.resourceId, `${path}.provenance.promotedFrom.resourceId`, errors, 80) ?? "",
8430
+ chapterId: text5(promotedRaw.chapterId, `${path}.provenance.promotedFrom.chapterId`, errors, 63) ?? "",
8431
+ resourceId: text5(promotedRaw.resourceId, `${path}.provenance.promotedFrom.resourceId`, errors, 80) ?? "",
8130
8432
  resourceRevision: integer3(promotedRaw.resourceRevision, `${path}.provenance.promotedFrom.resourceRevision`, errors),
8131
8433
  documentRevision: integer3(promotedRaw.documentRevision, `${path}.provenance.promotedFrom.documentRevision`, errors),
8132
- promotedBy: text4(promotedRaw.promotedBy, `${path}.provenance.promotedFrom.promotedBy`, errors, 320) ?? "",
8434
+ promotedBy: text5(promotedRaw.promotedBy, `${path}.provenance.promotedFrom.promotedBy`, errors, 320) ?? "",
8133
8435
  promotedAt: integer3(promotedRaw.promotedAt, `${path}.provenance.promotedFrom.promotedAt`, errors, 0)
8134
8436
  } : void 0;
8135
8437
  const provenance = {
8136
- createdBy: text4(provenanceRaw?.createdBy, `${path}.provenance.createdBy`, errors, 320) ?? "",
8438
+ createdBy: text5(provenanceRaw?.createdBy, `${path}.provenance.createdBy`, errors, 320) ?? "",
8137
8439
  createdAt: integer3(provenanceRaw?.createdAt, `${path}.provenance.createdAt`, errors, 0),
8138
- updatedBy: text4(provenanceRaw?.updatedBy, `${path}.provenance.updatedBy`, errors, 320) ?? "",
8440
+ updatedBy: text5(provenanceRaw?.updatedBy, `${path}.provenance.updatedBy`, errors, 320) ?? "",
8139
8441
  updatedAt: integer3(provenanceRaw?.updatedAt, `${path}.provenance.updatedAt`, errors, 0),
8140
8442
  ...promotedFrom ? { promotedFrom } : {}
8141
8443
  };
@@ -8154,7 +8456,7 @@ function validateBuilderLibraryDocument(value, options = {}) {
8154
8456
  if (sourceRaw) unknownKeys3(sourceRaw, SOURCE_KEYS2, "document.source", errors);
8155
8457
  const sourceType = sourceRaw?.type;
8156
8458
  if (sourceType !== "chapter" && sourceType !== "built_not_found") errors.push("document.source.type must be chapter or built_not_found");
8157
- const sourceAppId = text4(sourceRaw?.appId, "document.source.appId", errors, 63) ?? "";
8459
+ const sourceAppId = text5(sourceRaw?.appId, "document.source.appId", errors, 63) ?? "";
8158
8460
  if (sourceAppId && !APP_ID.test(sourceAppId)) errors.push("document.source.appId must be a lowercase app slug");
8159
8461
  if (options.sourceType && sourceType !== options.sourceType) errors.push(`document.source.type must be ${options.sourceType}`);
8160
8462
  if (options.sourceAppId && sourceAppId !== options.sourceAppId) errors.push(`document.source.appId must be ${options.sourceAppId}`);
@@ -8162,8 +8464,8 @@ function validateBuilderLibraryDocument(value, options = {}) {
8162
8464
  if (raw2.target !== void 0 && !targetRaw) errors.push("document.target must be an object");
8163
8465
  if (targetRaw) unknownKeys3(targetRaw, TARGET_KEYS2, "document.target", errors);
8164
8466
  const target = targetRaw ? {
8165
- appId: text4(targetRaw.appId, "document.target.appId", errors, 63) ?? "",
8166
- environment: text4(targetRaw.environment, "document.target.environment", errors, 80) ?? ""
8467
+ appId: text5(targetRaw.appId, "document.target.appId", errors, 63) ?? "",
8468
+ environment: text5(targetRaw.environment, "document.target.environment", errors, 80) ?? ""
8167
8469
  } : void 0;
8168
8470
  if (options.targetAppId && target?.appId !== options.targetAppId) errors.push(`document.target.appId must be ${options.targetAppId}`);
8169
8471
  if (!Array.isArray(raw2.resources)) errors.push("document.resources must be an array");
@@ -8301,7 +8603,7 @@ async function verifySource(req, db, ctx) {
8301
8603
  if (ctx.chapter.mode === "chapter") {
8302
8604
  const secret = await getVaultSecret(db, "network_share_secret");
8303
8605
  if (!secret) return null;
8304
- const verified = await (0, import_db12.verifyFederatedRequest)(req, {
8606
+ const verified = await (0, import_db13.verifyFederatedRequest)(req, {
8305
8607
  secret,
8306
8608
  senders: sources.map((source2) => source2.sourceId)
8307
8609
  });
@@ -8314,7 +8616,7 @@ async function verifySource(req, db, ctx) {
8314
8616
  if (!target) continue;
8315
8617
  const secret = await getVaultSecret(db, target.secretName);
8316
8618
  if (!secret) continue;
8317
- const verified = await (0, import_db12.verifyFederatedRequest)(req.clone(), { secret, senders: [source.sourceId] });
8619
+ const verified = await (0, import_db13.verifyFederatedRequest)(req.clone(), { secret, senders: [source.sourceId] });
8318
8620
  if (verified.ok) return { source, body: verified.body };
8319
8621
  }
8320
8622
  return null;
@@ -8504,11 +8806,11 @@ var handleAdminBuilderLibrary = async (req, url, env, ctx) => {
8504
8806
  };
8505
8807
 
8506
8808
  // src/signup-control-delivery.ts
8507
- var import_db13 = require("@odla-ai/db");
8809
+ var import_db14 = require("@odla-ai/db");
8508
8810
  async function deliverSignupControl(options) {
8509
8811
  const destination = new URL(SIGNUP_CONTROL_PATH, options.targetUrl);
8510
8812
  const body = JSON.stringify(options.envelope);
8511
- const signed = await (0, import_db13.signFederatedRequest)({
8813
+ const signed = await (0, import_db14.signFederatedRequest)({
8512
8814
  secret: options.secret,
8513
8815
  sender: options.sender,
8514
8816
  method: "POST",
@@ -8529,11 +8831,11 @@ async function deliverSignupControl(options) {
8529
8831
  }
8530
8832
 
8531
8833
  // src/member-content-delivery.ts
8532
- var import_db14 = require("@odla-ai/db");
8834
+ var import_db15 = require("@odla-ai/db");
8533
8835
  async function deliverMemberContent(options) {
8534
8836
  const destination = new URL(MEMBER_CONTENT_CONTROL_PATH, options.targetUrl);
8535
8837
  const body = JSON.stringify(options.envelope);
8536
- const signed = await (0, import_db14.signFederatedRequest)({
8838
+ const signed = await (0, import_db15.signFederatedRequest)({
8537
8839
  secret: options.secret,
8538
8840
  sender: options.sender,
8539
8841
  method: "POST",
@@ -8554,11 +8856,11 @@ async function deliverMemberContent(options) {
8554
8856
  }
8555
8857
 
8556
8858
  // src/builder-library-delivery.ts
8557
- var import_db15 = require("@odla-ai/db");
8859
+ var import_db16 = require("@odla-ai/db");
8558
8860
  async function deliverBuilderLibrary(options) {
8559
8861
  const destination = new URL(BUILDER_LIBRARY_CONTROL_PATH, options.targetUrl);
8560
8862
  const body = JSON.stringify(options.envelope);
8561
- const signed = await (0, import_db15.signFederatedRequest)({ secret: options.secret, sender: options.sender, method: "POST", url: destination, body });
8863
+ const signed = await (0, import_db16.signFederatedRequest)({ secret: options.secret, sender: options.sender, method: "POST", url: destination, body });
8562
8864
  const response = await (options.fetch ?? globalThis.fetch)(destination, { method: "POST", headers: { ...signed, "content-type": "application/json" }, body });
8563
8865
  const result = await response.json();
8564
8866
  if (!response.ok || !("ok" in result) || result.ok !== true) {
@@ -8572,7 +8874,7 @@ async function deliverBuilderLibrary(options) {
8572
8874
  var SIGNUP_CONTROL_THEME_MARKER = "<!--odla:signup-control-theme-->";
8573
8875
  var SIGNUP_CONTROL_THEME_STYLE_ID = "odla-signup-control-theme";
8574
8876
  var TOKEN_NAME = /^--ui-[a-z0-9][a-z0-9-]{0,94}$/;
8575
- var DIGEST = /^sha256:[0-9a-f]{64}$/;
8877
+ var DIGEST2 = /^sha256:[0-9a-f]{64}$/;
8576
8878
  var UNSAFE_VALUE = /[\u0000-\u001f\u007f\\<>{};]|\/\*|\*\/|(?:^|[^a-z])(?:url|expression)\s*\(|@import/i;
8577
8879
  var MAX_TOKENS = 256;
8578
8880
  var MAX_VALUE_LENGTH = 1e3;
@@ -8616,7 +8918,7 @@ function stateFor(revision, options) {
8616
8918
  metadata: { source: "bootstrap", revision: null, digest: null, cacheKey: "signup-control:bootstrap" }
8617
8919
  };
8618
8920
  }
8619
- if (!Number.isSafeInteger(revision.revision) || revision.revision < 1 || !DIGEST.test(revision.digest)) {
8921
+ if (!Number.isSafeInteger(revision.revision) || revision.revision < 1 || !DIGEST2.test(revision.digest)) {
8620
8922
  throw new TypeError("signup-control theme revision metadata is invalid");
8621
8923
  }
8622
8924
  return {
@@ -8713,6 +9015,7 @@ var BUILTIN_ROUTES = [
8713
9015
  handleNetworkSnapshot,
8714
9016
  handleNetworkRecords,
8715
9017
  handleNetworkSharedNotes,
9018
+ handleNetworkCommercialParity,
8716
9019
  handleNetworkCommercialStripeWebhook,
8717
9020
  handleFormation,
8718
9021
  handleMember,