@odla-ai/chapter 0.36.0 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +106 -0
- package/dist/{chunk-I45VYBUV.js → chunk-BIYWPPH6.js} +233 -140
- package/dist/chunk-BIYWPPH6.js.map +1 -0
- package/dist/{chunk-LWM32REV.js → chunk-SI3JHSEX.js} +211 -64
- package/dist/chunk-SI3JHSEX.js.map +1 -0
- package/dist/{copy-context-0k0-yWHL.d.ts → copy-context-Pbs3keLx.d.ts} +174 -28
- package/dist/index.cjs +483 -97
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +241 -29
- package/dist/index.d.ts +241 -29
- package/dist/index.js +483 -97
- package/dist/index.js.map +1 -1
- package/dist/ui/admin/index.d.ts +27 -3
- package/dist/ui/admin/index.js +5 -1
- package/dist/ui/index.d.ts +3 -3
- package/dist/ui/index.js +8 -2
- package/dist/ui/member/index.d.ts +28 -3
- package/dist/ui/member/index.js +3 -1
- package/dist/worker/index.cjs +736 -126
- package/dist/worker/index.cjs.map +1 -1
- package/dist/worker/index.d.cts +151 -28
- package/dist/worker/index.d.ts +151 -28
- package/dist/worker/index.js +729 -119
- package/dist/worker/index.js.map +1 -1
- package/package.json +1 -1
- package/runbooks/greenfield.md +27 -0
- package/dist/chunk-I45VYBUV.js.map +0 -1
- package/dist/chunk-LWM32REV.js.map +0 -1
package/dist/worker/index.js
CHANGED
|
@@ -179,9 +179,9 @@ function canonicalValue(value) {
|
|
|
179
179
|
}
|
|
180
180
|
if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
|
|
181
181
|
if (typeof value !== "object") throw new TypeError("signup control contains an unsupported value");
|
|
182
|
-
const
|
|
183
|
-
const entries = Object.keys(
|
|
184
|
-
const item =
|
|
182
|
+
const record3 = value;
|
|
183
|
+
const entries = Object.keys(record3).sort().flatMap((key) => {
|
|
184
|
+
const item = record3[key];
|
|
185
185
|
return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue(item)}`];
|
|
186
186
|
});
|
|
187
187
|
return `{${entries.join(",")}}`;
|
|
@@ -431,14 +431,14 @@ function hasDisclaimerAck(fields) {
|
|
|
431
431
|
var IDENTITY_FIELDS = /* @__PURE__ */ new Set(["email", "firstName", "lastName"]);
|
|
432
432
|
function applicantProfile(chapter, fields) {
|
|
433
433
|
const app = chapter.application;
|
|
434
|
-
const
|
|
434
|
+
const allowed2 = (f) => app.profileFields.includes(f);
|
|
435
435
|
const profile = {};
|
|
436
436
|
for (const f of [...app.required, ...app.optional]) {
|
|
437
|
-
if (IDENTITY_FIELDS.has(f) || !
|
|
437
|
+
if (IDENTITY_FIELDS.has(f) || !allowed2(f)) continue;
|
|
438
438
|
const v = fields[f];
|
|
439
439
|
if (typeof v === "string" && v.trim() !== "") profile[f] = v.trim();
|
|
440
440
|
}
|
|
441
|
-
if (fields.focus !== void 0 &&
|
|
441
|
+
if (fields.focus !== void 0 && allowed2("focus")) profile.focus = clampArray(fields.focus, app.maxArrayLen);
|
|
442
442
|
return Object.keys(profile).length > 0 ? profile : void 0;
|
|
443
443
|
}
|
|
444
444
|
async function submitApplication(db, chapter, fields, opts) {
|
|
@@ -731,8 +731,8 @@ function validateGiftOfferCatalog(input, tierIds) {
|
|
|
731
731
|
errors.push(`${path} must be an object`);
|
|
732
732
|
continue;
|
|
733
733
|
}
|
|
734
|
-
const
|
|
735
|
-
for (const key of Object.keys(item)) if (!
|
|
734
|
+
const allowed2 = /* @__PURE__ */ new Set(["id", "target", "billingMode", "stripePriceId", "priceCents", "currency", "redemptionLifetimeHours", "activeFrom", "activeUntil", "title", "description", "ctaLabel"]);
|
|
735
|
+
for (const key of Object.keys(item)) if (!allowed2.has(key)) errors.push(`${path}.${key} is not supported`);
|
|
736
736
|
const id = text(item.id, `${path}.id`, errors, 80);
|
|
737
737
|
if (id && !ID2.test(id)) errors.push(`${path}.id must be a lowercase kebab-case id`);
|
|
738
738
|
if (ids.has(id)) errors.push(`${path}.id duplicates "${id}"`);
|
|
@@ -829,8 +829,8 @@ async function clerkListUsers(secretKey, fetchImpl = fetch) {
|
|
|
829
829
|
const data = await clerkGet(`/v1/users?limit=${PAGE}&offset=${offset}`, secretKey, fetchImpl);
|
|
830
830
|
const page = Array.isArray(data) ? data : [];
|
|
831
831
|
for (const u of page) {
|
|
832
|
-
const
|
|
833
|
-
if (
|
|
832
|
+
const record3 = toRecord(u);
|
|
833
|
+
if (record3) out.push(record3);
|
|
834
834
|
}
|
|
835
835
|
if (page.length < PAGE) break;
|
|
836
836
|
}
|
|
@@ -892,34 +892,34 @@ function networkSourceTag(type, hubRecordId, sourceId) {
|
|
|
892
892
|
if (readable && raw.length <= 64) return raw;
|
|
893
893
|
return `network:${typeKey.slice(0, 16)}:${shortHash(`${sourceId ?? ""}\0${type}\0${hubRecordId}`)}`;
|
|
894
894
|
}
|
|
895
|
-
function normalizeSharedRecord(
|
|
896
|
-
if ("input" in
|
|
895
|
+
function normalizeSharedRecord(record3, fallbackSourceId = "legacy-source") {
|
|
896
|
+
if ("input" in record3 && record3.version === 2) {
|
|
897
897
|
return {
|
|
898
898
|
version: 2,
|
|
899
|
-
sourceId:
|
|
900
|
-
sourceRecordId:
|
|
901
|
-
type:
|
|
902
|
-
input:
|
|
899
|
+
sourceId: record3.source.siteId,
|
|
900
|
+
sourceRecordId: record3.source.recordId,
|
|
901
|
+
type: record3.type,
|
|
902
|
+
input: record3.input
|
|
903
903
|
};
|
|
904
904
|
}
|
|
905
|
-
if ("input" in
|
|
905
|
+
if ("input" in record3) {
|
|
906
906
|
return {
|
|
907
907
|
version: 1,
|
|
908
908
|
sourceId: fallbackSourceId,
|
|
909
|
-
sourceRecordId:
|
|
910
|
-
type:
|
|
911
|
-
input:
|
|
909
|
+
sourceRecordId: record3.hubRecordId,
|
|
910
|
+
type: record3.type,
|
|
911
|
+
input: record3.input
|
|
912
912
|
};
|
|
913
913
|
}
|
|
914
|
-
if ("type" in
|
|
915
|
-
const input = { name:
|
|
914
|
+
if ("type" in record3 && record3.type === "company") {
|
|
915
|
+
const input = { name: record3.name };
|
|
916
916
|
for (const key of ["domain", "industry", "location", "linkedin", "notes"]) {
|
|
917
|
-
if (
|
|
917
|
+
if (record3[key]) input[key] = record3[key];
|
|
918
918
|
}
|
|
919
919
|
return {
|
|
920
920
|
version: 1,
|
|
921
921
|
sourceId: fallbackSourceId,
|
|
922
|
-
sourceRecordId:
|
|
922
|
+
sourceRecordId: record3.hubRecordId,
|
|
923
923
|
type: "company",
|
|
924
924
|
input
|
|
925
925
|
};
|
|
@@ -927,28 +927,28 @@ function normalizeSharedRecord(record2, fallbackSourceId = "legacy-source") {
|
|
|
927
927
|
return {
|
|
928
928
|
version: 1,
|
|
929
929
|
sourceId: fallbackSourceId,
|
|
930
|
-
sourceRecordId:
|
|
930
|
+
sourceRecordId: record3.hubRecordId,
|
|
931
931
|
type: "person",
|
|
932
|
-
input: sharedPersonInput(
|
|
932
|
+
input: sharedPersonInput(record3)
|
|
933
933
|
};
|
|
934
934
|
}
|
|
935
|
-
function sharedRecordFromCrm(crm,
|
|
936
|
-
if (target.fields && !target.fields[
|
|
937
|
-
throw new Error(`${target.name} does not accept "${
|
|
935
|
+
function sharedRecordFromCrm(crm, record3, target, sourceId) {
|
|
936
|
+
if (target.fields && !target.fields[record3.type]) {
|
|
937
|
+
throw new Error(`${target.name} does not accept "${record3.type}" records`);
|
|
938
938
|
}
|
|
939
|
-
const def = crm.type(
|
|
940
|
-
const fields = target.fields?.[
|
|
939
|
+
const def = crm.type(record3.type);
|
|
940
|
+
const fields = target.fields?.[record3.type] ?? DEFAULT_SHARE_FIELDS[record3.type];
|
|
941
941
|
if (!fields) {
|
|
942
|
-
throw new Error(`${target.name} requires an explicit field allowlist for "${
|
|
942
|
+
throw new Error(`${target.name} requires an explicit field allowlist for "${record3.type}" records`);
|
|
943
943
|
}
|
|
944
944
|
const nameField = def.nameField ?? "name";
|
|
945
945
|
const input = {};
|
|
946
946
|
for (const field of /* @__PURE__ */ new Set([nameField, ...fields])) {
|
|
947
|
-
const value =
|
|
947
|
+
const value = record3.fields?.[field];
|
|
948
948
|
if (value !== void 0) input[field] = value;
|
|
949
949
|
}
|
|
950
|
-
if (input[nameField] === void 0) input[nameField] =
|
|
951
|
-
return sourceId ? { version: 2, source: { siteId: sourceId, recordId:
|
|
950
|
+
if (input[nameField] === void 0) input[nameField] = record3.name;
|
|
951
|
+
return sourceId ? { version: 2, source: { siteId: sourceId, recordId: record3.id }, type: record3.type, input } : { version: 1, type: record3.type, hubRecordId: record3.id, input };
|
|
952
952
|
}
|
|
953
953
|
async function upsertPerson(deps, opts) {
|
|
954
954
|
const email = opts.email.toLowerCase();
|
|
@@ -962,11 +962,11 @@ async function upsertPerson(deps, opts) {
|
|
|
962
962
|
const created = await createRecord(crmDeps3, { type: "person", input: opts.input, mutationId: opts.mutationId });
|
|
963
963
|
return { recordId: created.id };
|
|
964
964
|
}
|
|
965
|
-
async function findSharedRecord(deps,
|
|
965
|
+
async function findSharedRecord(deps, record3, tag) {
|
|
966
966
|
const structured = await getRecordByOrigin(
|
|
967
967
|
{ crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId },
|
|
968
|
-
|
|
969
|
-
|
|
968
|
+
record3.sourceId,
|
|
969
|
+
record3.sourceRecordId
|
|
970
970
|
);
|
|
971
971
|
if (structured) return structured;
|
|
972
972
|
const mapped = await deps.db.query({ crm_tag: { $: { where: { tag }, limit: 1 } } });
|
|
@@ -975,53 +975,53 @@ async function findSharedRecord(deps, record2, tag) {
|
|
|
975
975
|
const found = await deps.db.query({ crm_record: { $: { where: { id: mappedId }, limit: 1 } } });
|
|
976
976
|
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
977
977
|
}
|
|
978
|
-
const def = deps.crm.type(
|
|
978
|
+
const def = deps.crm.type(record3.type);
|
|
979
979
|
const emailField = def.emailField;
|
|
980
|
-
if (emailField && typeof
|
|
981
|
-
const primaryEmail =
|
|
980
|
+
if (emailField && typeof record3.input[emailField] === "string") {
|
|
981
|
+
const primaryEmail = record3.input[emailField].toLowerCase();
|
|
982
982
|
const found = await deps.db.query({
|
|
983
|
-
crm_record: { $: { where: { type:
|
|
983
|
+
crm_record: { $: { where: { type: record3.type, primaryEmail }, limit: 1 } }
|
|
984
984
|
});
|
|
985
985
|
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
986
986
|
}
|
|
987
|
-
const domain =
|
|
987
|
+
const domain = record3.input.domain;
|
|
988
988
|
const domainSlot = def.fields.domain?.slot;
|
|
989
989
|
if (typeof domain === "string" && domainSlot) {
|
|
990
990
|
const found = await deps.db.query({
|
|
991
|
-
crm_record: { $: { where: { type:
|
|
991
|
+
crm_record: { $: { where: { type: record3.type, [domainSlot]: domain }, limit: 1 } }
|
|
992
992
|
});
|
|
993
993
|
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
994
994
|
}
|
|
995
995
|
const nameField = def.nameField ?? "name";
|
|
996
|
-
const name =
|
|
997
|
-
if (
|
|
996
|
+
const name = record3.input[nameField];
|
|
997
|
+
if (record3.type === "company" && typeof name === "string" && name.trim()) {
|
|
998
998
|
const found = await deps.db.query({
|
|
999
|
-
crm_record: { $: { where: { type:
|
|
999
|
+
crm_record: { $: { where: { type: record3.type, name: name.trim() }, limit: 1 } }
|
|
1000
1000
|
});
|
|
1001
1001
|
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
1002
1002
|
}
|
|
1003
1003
|
return void 0;
|
|
1004
1004
|
}
|
|
1005
1005
|
async function projectSharedRecord(deps, shared, options = {}) {
|
|
1006
|
-
const
|
|
1007
|
-
if (!
|
|
1006
|
+
const record3 = normalizeSharedRecord(shared, options.sourceId);
|
|
1007
|
+
if (!record3.type.trim() || !record3.sourceId.trim() || !record3.sourceRecordId.trim()) {
|
|
1008
1008
|
throw new Error("type, source site id, and source record id must be non-empty");
|
|
1009
1009
|
}
|
|
1010
|
-
if (options.sourceId && options.sourceId !==
|
|
1010
|
+
if (options.sourceId && options.sourceId !== record3.sourceId) {
|
|
1011
1011
|
throw new Error("signed sender does not match payload source");
|
|
1012
1012
|
}
|
|
1013
|
-
const tag = networkSourceTag(
|
|
1013
|
+
const tag = networkSourceTag(record3.type, record3.sourceRecordId, record3.version === 2 ? record3.sourceId : void 0);
|
|
1014
1014
|
const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
|
|
1015
|
-
const existing = await findSharedRecord(deps,
|
|
1015
|
+
const existing = await findSharedRecord(deps, record3, tag);
|
|
1016
1016
|
let recordId;
|
|
1017
1017
|
if (existing && typeof existing.id === "string") {
|
|
1018
|
-
await updateRecord(crmDeps3, { id: existing.id, input:
|
|
1018
|
+
await updateRecord(crmDeps3, { id: existing.id, input: record3.input });
|
|
1019
1019
|
recordId = existing.id;
|
|
1020
1020
|
} else {
|
|
1021
|
-
recordId = `network_${shortHash(`${
|
|
1021
|
+
recordId = `network_${shortHash(`${record3.sourceId}\0${record3.type}\0${record3.sourceRecordId}`)}`;
|
|
1022
1022
|
await createRecord({ ...crmDeps3, newId: () => recordId }, {
|
|
1023
|
-
type:
|
|
1024
|
-
input:
|
|
1023
|
+
type: record3.type,
|
|
1024
|
+
input: record3.input,
|
|
1025
1025
|
mutationId: `share-create:${tag}`
|
|
1026
1026
|
});
|
|
1027
1027
|
}
|
|
@@ -1031,10 +1031,10 @@ async function projectSharedRecord(deps, shared, options = {}) {
|
|
|
1031
1031
|
);
|
|
1032
1032
|
await upsertRecordOrigin(crmDeps3, {
|
|
1033
1033
|
recordId,
|
|
1034
|
-
sourceId:
|
|
1035
|
-
sourceRecordId:
|
|
1034
|
+
sourceId: record3.sourceId,
|
|
1035
|
+
sourceRecordId: record3.sourceRecordId,
|
|
1036
1036
|
...options.sourceUrl ? { sourceUrl: options.sourceUrl } : {},
|
|
1037
|
-
payloadVersion:
|
|
1037
|
+
payloadVersion: record3.version
|
|
1038
1038
|
});
|
|
1039
1039
|
return { recordId };
|
|
1040
1040
|
}
|
|
@@ -1483,7 +1483,8 @@ function applicationSummary(app) {
|
|
|
1483
1483
|
meetingLink: app.meetingLink ?? null,
|
|
1484
1484
|
paid: Boolean(app.stripeSubscriptionId) && app.status !== "refunded",
|
|
1485
1485
|
renewalAt: app.renewalAt ?? null,
|
|
1486
|
-
canceled: app.canceled === true
|
|
1486
|
+
canceled: app.canceled === true,
|
|
1487
|
+
tierId: app.tierId ?? null
|
|
1487
1488
|
};
|
|
1488
1489
|
}
|
|
1489
1490
|
function memberApplication(app, meeting, defaultTimezone) {
|
|
@@ -1957,8 +1958,8 @@ var handleNetworkShared = async (req, url, env, ctx) => {
|
|
|
1957
1958
|
payload,
|
|
1958
1959
|
sourceId ? { sourceId } : {}
|
|
1959
1960
|
);
|
|
1960
|
-
const
|
|
1961
|
-
return json({ recordId, type:
|
|
1961
|
+
const record3 = normalizeSharedRecord(payload, sourceId);
|
|
1962
|
+
return json({ recordId, type: record3.type });
|
|
1962
1963
|
} catch (error) {
|
|
1963
1964
|
return json({
|
|
1964
1965
|
error: error instanceof Error ? error.message : "invalid shared record"
|
|
@@ -1998,9 +1999,9 @@ var MAX_BENEFITS = 24;
|
|
|
1998
1999
|
var MAX_MARKDOWN = 65536;
|
|
1999
2000
|
var MAX_LABEL = 160;
|
|
2000
2001
|
var objectRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
2001
|
-
function rejectUnknownKeys(value,
|
|
2002
|
+
function rejectUnknownKeys(value, allowed2, path, errors) {
|
|
2002
2003
|
for (const key of Object.keys(value)) {
|
|
2003
|
-
if (!
|
|
2004
|
+
if (!allowed2.has(key)) errors.push(`${path}.${key} is not supported`);
|
|
2004
2005
|
}
|
|
2005
2006
|
}
|
|
2006
2007
|
function boundedString(value, path, errors, max, required = false) {
|
|
@@ -2156,8 +2157,8 @@ var ROOT_KEYS2 = /* @__PURE__ */ new Set([
|
|
|
2156
2157
|
]);
|
|
2157
2158
|
var TIER_KEYS2 = /* @__PURE__ */ new Set(["id", "name", "available", "sortOrder", "priceCents", "stripePriceId", "blurb"]);
|
|
2158
2159
|
var recordOf = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
2159
|
-
function unknownKeys(value,
|
|
2160
|
-
for (const key of Object.keys(value)) if (!
|
|
2160
|
+
function unknownKeys(value, allowed2, path, errors) {
|
|
2161
|
+
for (const key of Object.keys(value)) if (!allowed2.has(key)) errors.push(`${path}.${key} is not supported`);
|
|
2161
2162
|
}
|
|
2162
2163
|
function text2(value, path, errors, max = 65536) {
|
|
2163
2164
|
if (typeof value !== "string" || !value.trim()) {
|
|
@@ -2242,10 +2243,10 @@ function policyOf(value, errors) {
|
|
|
2242
2243
|
errors.push("document.policy must be an object");
|
|
2243
2244
|
return void 0;
|
|
2244
2245
|
}
|
|
2245
|
-
const
|
|
2246
|
-
unknownKeys(input,
|
|
2246
|
+
const allowed2 = /* @__PURE__ */ new Set(["disclaimerText", "refundPolicyText", "trustCopy", "commitmentText", "normsText"]);
|
|
2247
|
+
unknownKeys(input, allowed2, "document.policy", errors);
|
|
2247
2248
|
const result = {};
|
|
2248
|
-
for (const key of
|
|
2249
|
+
for (const key of allowed2) {
|
|
2249
2250
|
if (input[key] === void 0 && key !== "disclaimerText" && key !== "refundPolicyText") continue;
|
|
2250
2251
|
result[key] = text2(input[key], `document.policy.${key}`, errors);
|
|
2251
2252
|
}
|
|
@@ -2384,9 +2385,589 @@ var handleSignupControl = async (req, url, env, ctx) => {
|
|
|
2384
2385
|
});
|
|
2385
2386
|
};
|
|
2386
2387
|
|
|
2387
|
-
// src/worker-routes-
|
|
2388
|
+
// src/worker-routes-member-content.ts
|
|
2388
2389
|
import { verifyFederatedRequest as verifyFederatedRequest3 } from "@odla-ai/db";
|
|
2390
|
+
|
|
2391
|
+
// src/member-content-canonical.ts
|
|
2392
|
+
var encoder2 = new TextEncoder();
|
|
2393
|
+
function canonicalValue2(value) {
|
|
2394
|
+
if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
|
|
2395
|
+
return JSON.stringify(value);
|
|
2396
|
+
}
|
|
2397
|
+
if (Array.isArray(value)) return `[${value.map(canonicalValue2).join(",")}]`;
|
|
2398
|
+
if (typeof value !== "object") throw new TypeError("member content contains an unsupported value");
|
|
2399
|
+
const record3 = value;
|
|
2400
|
+
const entries = Object.keys(record3).sort().flatMap((key) => {
|
|
2401
|
+
const item = record3[key];
|
|
2402
|
+
return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue2(item)}`];
|
|
2403
|
+
});
|
|
2404
|
+
return `{${entries.join(",")}}`;
|
|
2405
|
+
}
|
|
2406
|
+
function canonicalMemberContent(value) {
|
|
2407
|
+
return canonicalValue2(value);
|
|
2408
|
+
}
|
|
2409
|
+
async function memberContentDigest(document) {
|
|
2410
|
+
const digest = await crypto.subtle.digest("SHA-256", encoder2.encode(canonicalMemberContent(document)));
|
|
2411
|
+
return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
// src/member-content-validation-helpers.ts
|
|
2415
|
+
var ID3 = /^[a-z][a-z0-9-]{0,79}$/;
|
|
2416
|
+
var SLUG2 = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
2417
|
+
var ROOT_KEYS3 = /* @__PURE__ */ new Set(["schemaVersion", "revision", "generatedAt", "source", "target", "pages", "upsellOverrides"]);
|
|
2418
|
+
var SOURCE_KEYS = /* @__PURE__ */ new Set(["type", "appId"]);
|
|
2419
|
+
var TARGET_KEYS = /* @__PURE__ */ new Set(["chapterId", "environment"]);
|
|
2420
|
+
var PAGE_KEYS = /* @__PURE__ */ new Set(["id", "slug", "title", "summary", "navigation", "audience", "teaserMarkdown", "bodyMarkdown", "upsell"]);
|
|
2421
|
+
var NAV_KEYS = /* @__PURE__ */ new Set(["section", "order", "parentId"]);
|
|
2422
|
+
var AUDIENCE_KEYS = /* @__PURE__ */ new Set(["kind", "tierIds"]);
|
|
2423
|
+
var UPSELL_KEYS = /* @__PURE__ */ new Set(["owner", "offer"]);
|
|
2424
|
+
var OFFER_KEYS = /* @__PURE__ */ new Set(["tierId", "label", "bodyMarkdown", "ctaLabel"]);
|
|
2425
|
+
var OVERRIDE_KEYS = /* @__PURE__ */ new Set(["pageId", "offer"]);
|
|
2426
|
+
var MAX_PAGES = 100;
|
|
2427
|
+
var MAX_MARKDOWN2 = 65536;
|
|
2428
|
+
var MAX_TEXT = 240;
|
|
2429
|
+
var record2 = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
2430
|
+
function unknownKeys2(value, allowed2, path, errors) {
|
|
2431
|
+
for (const key of Object.keys(value)) if (!allowed2.has(key)) errors.push(`${path}.${key} is not supported`);
|
|
2432
|
+
}
|
|
2433
|
+
function textValue(value, path, errors, max = MAX_TEXT, required = false) {
|
|
2434
|
+
if (value === void 0) {
|
|
2435
|
+
if (required) errors.push(`${path} is required`);
|
|
2436
|
+
return void 0;
|
|
2437
|
+
}
|
|
2438
|
+
if (typeof value !== "string") {
|
|
2439
|
+
errors.push(`${path} must be a string`);
|
|
2440
|
+
return void 0;
|
|
2441
|
+
}
|
|
2442
|
+
const valueText = value.trim();
|
|
2443
|
+
if (required && !valueText) errors.push(`${path} is required`);
|
|
2444
|
+
if (valueText.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
|
|
2445
|
+
return valueText;
|
|
2446
|
+
}
|
|
2447
|
+
function offerValue(value, path, errors, tiers, audience) {
|
|
2448
|
+
const raw = record2(value);
|
|
2449
|
+
if (!raw) {
|
|
2450
|
+
errors.push(`${path} must be an object`);
|
|
2451
|
+
return void 0;
|
|
2452
|
+
}
|
|
2453
|
+
unknownKeys2(raw, OFFER_KEYS, path, errors);
|
|
2454
|
+
const tierId = textValue(raw.tierId, `${path}.tierId`, errors, 80, true) ?? "";
|
|
2455
|
+
if (tierId && !ID3.test(tierId)) errors.push(`${path}.tierId must be a lowercase kebab-case id`);
|
|
2456
|
+
if (tiers.size && !tiers.has(tierId)) errors.push(`${path}.tierId must reference a declared tier`);
|
|
2457
|
+
if (audience?.kind === "tiers" && tierId && !audience.tierIds.includes(tierId)) {
|
|
2458
|
+
errors.push(`${path}.tierId must grant access to this page`);
|
|
2459
|
+
}
|
|
2460
|
+
const label = textValue(raw.label, `${path}.label`, errors);
|
|
2461
|
+
const bodyMarkdown = textValue(raw.bodyMarkdown, `${path}.bodyMarkdown`, errors, MAX_MARKDOWN2);
|
|
2462
|
+
const ctaLabel = textValue(raw.ctaLabel, `${path}.ctaLabel`, errors);
|
|
2463
|
+
return {
|
|
2464
|
+
tierId,
|
|
2465
|
+
...label ? { label } : {},
|
|
2466
|
+
...bodyMarkdown ? { bodyMarkdown } : {},
|
|
2467
|
+
...ctaLabel ? { ctaLabel } : {}
|
|
2468
|
+
};
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
// src/member-content-validation.ts
|
|
2472
|
+
function validateMemberContentDocument(input, options = {}) {
|
|
2473
|
+
const errors = [];
|
|
2474
|
+
const root = record2(input);
|
|
2475
|
+
if (!root) return { ok: false, errors: ["document must be an object"] };
|
|
2476
|
+
unknownKeys2(root, ROOT_KEYS3, "document", errors);
|
|
2477
|
+
if (root.schemaVersion !== MEMBER_CONTENT_SCHEMA_VERSION) errors.push("document.schemaVersion must be 1");
|
|
2478
|
+
if (!Number.isInteger(root.revision) || Number(root.revision) < 1) errors.push("document.revision must be a positive integer");
|
|
2479
|
+
if (!Number.isInteger(root.generatedAt) || Number(root.generatedAt) < 0) errors.push("document.generatedAt must be a non-negative integer timestamp");
|
|
2480
|
+
const sourceRaw = record2(root.source);
|
|
2481
|
+
let source = { type: "chapter", appId: "invalid" };
|
|
2482
|
+
if (!sourceRaw) errors.push("document.source must be an object");
|
|
2483
|
+
else {
|
|
2484
|
+
unknownKeys2(sourceRaw, SOURCE_KEYS, "document.source", errors);
|
|
2485
|
+
const type = sourceRaw.type;
|
|
2486
|
+
const appId = textValue(sourceRaw.appId, "document.source.appId", errors, 63, true) ?? "";
|
|
2487
|
+
if (type !== "parent" && type !== "chapter") errors.push("document.source.type must be parent or chapter");
|
|
2488
|
+
if (appId && !ID3.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
|
|
2489
|
+
if (options.sourceType && type !== options.sourceType) errors.push(`document.source.type must be ${options.sourceType}`);
|
|
2490
|
+
if (options.sourceAppId && appId !== options.sourceAppId) errors.push(`document.source.appId must be ${options.sourceAppId}`);
|
|
2491
|
+
if (type === "parent" || type === "chapter") source = { type, appId };
|
|
2492
|
+
}
|
|
2493
|
+
let target;
|
|
2494
|
+
if (root.target !== void 0) {
|
|
2495
|
+
const targetRaw = record2(root.target);
|
|
2496
|
+
if (!targetRaw) errors.push("document.target must be an object");
|
|
2497
|
+
else {
|
|
2498
|
+
unknownKeys2(targetRaw, TARGET_KEYS, "document.target", errors);
|
|
2499
|
+
const chapterId = textValue(targetRaw.chapterId, "document.target.chapterId", errors, 63, true) ?? "";
|
|
2500
|
+
const environment = textValue(targetRaw.environment, "document.target.environment", errors, 80, true) ?? "";
|
|
2501
|
+
target = { chapterId, environment };
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
if (source.type === "parent" && !target) errors.push("document.target is required for parent content");
|
|
2505
|
+
if (source.type === "chapter" && target) errors.push("document.target is not supported for chapter content");
|
|
2506
|
+
if (!Array.isArray(root.pages)) errors.push("document.pages must be an array");
|
|
2507
|
+
const rawPages = Array.isArray(root.pages) ? root.pages : [];
|
|
2508
|
+
if (rawPages.length > MAX_PAGES) errors.push(`document.pages supports at most ${MAX_PAGES} entries`);
|
|
2509
|
+
const tierIds = new Set((options.tiers ?? []).map((tier) => tier.id));
|
|
2510
|
+
const knownParentIds = new Set((options.parentPages ?? []).map((page) => page.id));
|
|
2511
|
+
const ids = /* @__PURE__ */ new Set();
|
|
2512
|
+
const slugs = /* @__PURE__ */ new Set();
|
|
2513
|
+
const pages = [];
|
|
2514
|
+
for (const [index, value] of rawPages.slice(0, MAX_PAGES).entries()) {
|
|
2515
|
+
const path = `document.pages[${index}]`;
|
|
2516
|
+
const raw = record2(value);
|
|
2517
|
+
if (!raw) {
|
|
2518
|
+
errors.push(`${path} must be an object`);
|
|
2519
|
+
continue;
|
|
2520
|
+
}
|
|
2521
|
+
unknownKeys2(raw, PAGE_KEYS, path, errors);
|
|
2522
|
+
const id = textValue(raw.id, `${path}.id`, errors, 80, true) ?? "";
|
|
2523
|
+
const slug = textValue(raw.slug, `${path}.slug`, errors, 120, true) ?? "";
|
|
2524
|
+
const title = textValue(raw.title, `${path}.title`, errors, MAX_TEXT, true) ?? "";
|
|
2525
|
+
if (id && !ID3.test(id)) errors.push(`${path}.id must be a lowercase kebab-case id`);
|
|
2526
|
+
if (slug && !SLUG2.test(slug)) errors.push(`${path}.slug must be a lowercase URL slug`);
|
|
2527
|
+
if (ids.has(id) || knownParentIds.has(id)) errors.push(`${path}.id duplicates page "${id}"`);
|
|
2528
|
+
if (slugs.has(slug) || (options.parentPages ?? []).some((page) => page.slug === slug)) errors.push(`${path}.slug duplicates "${slug}"`);
|
|
2529
|
+
ids.add(id);
|
|
2530
|
+
slugs.add(slug);
|
|
2531
|
+
const navRaw = record2(raw.navigation);
|
|
2532
|
+
let navigation = { section: "General", order: index };
|
|
2533
|
+
if (!navRaw) errors.push(`${path}.navigation must be an object`);
|
|
2534
|
+
else {
|
|
2535
|
+
unknownKeys2(navRaw, NAV_KEYS, `${path}.navigation`, errors);
|
|
2536
|
+
const section = textValue(navRaw.section, `${path}.navigation.section`, errors, 120, true) ?? "";
|
|
2537
|
+
const order = navRaw.order;
|
|
2538
|
+
if (!Number.isInteger(order) || Number(order) < 0 || Number(order) > 1e6) {
|
|
2539
|
+
errors.push(`${path}.navigation.order must be an integer from 0 to 1,000,000`);
|
|
2540
|
+
}
|
|
2541
|
+
const parentId = textValue(navRaw.parentId, `${path}.navigation.parentId`, errors, 80);
|
|
2542
|
+
navigation = { section, order: Number.isInteger(order) ? Number(order) : index, ...parentId ? { parentId } : {} };
|
|
2543
|
+
}
|
|
2544
|
+
const audienceRaw = record2(raw.audience);
|
|
2545
|
+
let audience = { kind: "all-members" };
|
|
2546
|
+
if (!audienceRaw) errors.push(`${path}.audience must be an object`);
|
|
2547
|
+
else {
|
|
2548
|
+
unknownKeys2(audienceRaw, AUDIENCE_KEYS, `${path}.audience`, errors);
|
|
2549
|
+
if (audienceRaw.kind === "all-members") {
|
|
2550
|
+
if (audienceRaw.tierIds !== void 0) errors.push(`${path}.audience.tierIds is not supported for all-members`);
|
|
2551
|
+
} else if (audienceRaw.kind === "tiers") {
|
|
2552
|
+
if (!Array.isArray(audienceRaw.tierIds) || audienceRaw.tierIds.length === 0) {
|
|
2553
|
+
errors.push(`${path}.audience.tierIds must be a non-empty array`);
|
|
2554
|
+
}
|
|
2555
|
+
const values = Array.isArray(audienceRaw.tierIds) ? audienceRaw.tierIds : [];
|
|
2556
|
+
const normalized = [];
|
|
2557
|
+
for (const [tierIndex, value2] of values.entries()) {
|
|
2558
|
+
const tier = textValue(value2, `${path}.audience.tierIds[${tierIndex}]`, errors, 80, true) ?? "";
|
|
2559
|
+
if (normalized.includes(tier)) errors.push(`${path}.audience.tierIds duplicates "${tier}"`);
|
|
2560
|
+
if (tierIds.size && !tierIds.has(tier)) errors.push(`${path}.audience.tierIds references undeclared tier "${tier}"`);
|
|
2561
|
+
normalized.push(tier);
|
|
2562
|
+
}
|
|
2563
|
+
audience = { kind: "tiers", tierIds: normalized };
|
|
2564
|
+
} else errors.push(`${path}.audience.kind must be all-members or tiers`);
|
|
2565
|
+
}
|
|
2566
|
+
const summary = textValue(raw.summary, `${path}.summary`, errors, 2e3);
|
|
2567
|
+
const teaserMarkdown = textValue(raw.teaserMarkdown, `${path}.teaserMarkdown`, errors, MAX_MARKDOWN2);
|
|
2568
|
+
const bodyMarkdown = textValue(raw.bodyMarkdown, `${path}.bodyMarkdown`, errors, MAX_MARKDOWN2, true) ?? "";
|
|
2569
|
+
let upsell;
|
|
2570
|
+
if (raw.upsell !== void 0) {
|
|
2571
|
+
const upsellRaw = record2(raw.upsell);
|
|
2572
|
+
if (!upsellRaw) errors.push(`${path}.upsell must be an object`);
|
|
2573
|
+
else {
|
|
2574
|
+
unknownKeys2(upsellRaw, UPSELL_KEYS, `${path}.upsell`, errors);
|
|
2575
|
+
const owner = upsellRaw.owner;
|
|
2576
|
+
if (owner !== "parent" && owner !== "chapter") errors.push(`${path}.upsell.owner must be parent or chapter`);
|
|
2577
|
+
if (source.type === "chapter" && owner === "parent") errors.push(`${path}.upsell.owner cannot be parent on a chapter page`);
|
|
2578
|
+
if (source.type === "parent" && owner === "parent" && upsellRaw.offer === void 0) errors.push(`${path}.upsell.offer is required when the parent owns the offer`);
|
|
2579
|
+
if (source.type === "parent" && owner === "chapter" && upsellRaw.offer !== void 0) errors.push(`${path}.upsell.offer must be set by the chapter after delegation`);
|
|
2580
|
+
const offer = upsellRaw.offer === void 0 ? void 0 : offerValue(upsellRaw.offer, `${path}.upsell.offer`, errors, tierIds, audience);
|
|
2581
|
+
if (owner === "parent") upsell = { owner, offer: offer ?? { tierId: "" } };
|
|
2582
|
+
else if (owner === "chapter") upsell = { owner, ...offer ? { offer } : {} };
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
pages.push({ id, slug, title, ...summary ? { summary } : {}, navigation, audience, ...teaserMarkdown ? { teaserMarkdown } : {}, bodyMarkdown, ...upsell ? { upsell } : {} });
|
|
2586
|
+
}
|
|
2587
|
+
const allIds = /* @__PURE__ */ new Set([...knownParentIds, ...ids]);
|
|
2588
|
+
for (const [index, page] of pages.entries()) {
|
|
2589
|
+
const parentId = page.navigation.parentId;
|
|
2590
|
+
if (parentId && !allIds.has(parentId)) errors.push(`document.pages[${index}].navigation.parentId references unknown page "${parentId}"`);
|
|
2591
|
+
if (parentId === page.id) errors.push(`document.pages[${index}].navigation.parentId cannot reference itself`);
|
|
2592
|
+
}
|
|
2593
|
+
for (const page of pages) {
|
|
2594
|
+
const seen = /* @__PURE__ */ new Set([page.id]);
|
|
2595
|
+
let parentId = page.navigation.parentId;
|
|
2596
|
+
while (parentId) {
|
|
2597
|
+
if (seen.has(parentId)) {
|
|
2598
|
+
errors.push(`document.pages contains a navigation cycle at "${page.id}"`);
|
|
2599
|
+
break;
|
|
2600
|
+
}
|
|
2601
|
+
seen.add(parentId);
|
|
2602
|
+
parentId = pages.find((candidate) => candidate.id === parentId)?.navigation.parentId;
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
let upsellOverrides;
|
|
2606
|
+
if (root.upsellOverrides !== void 0) {
|
|
2607
|
+
if (source.type !== "chapter") errors.push("document.upsellOverrides is supported only for chapter content");
|
|
2608
|
+
if (!Array.isArray(root.upsellOverrides)) errors.push("document.upsellOverrides must be an array");
|
|
2609
|
+
const values = Array.isArray(root.upsellOverrides) ? root.upsellOverrides : [];
|
|
2610
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2611
|
+
upsellOverrides = [];
|
|
2612
|
+
for (const [index, value] of values.entries()) {
|
|
2613
|
+
const path = `document.upsellOverrides[${index}]`;
|
|
2614
|
+
const raw = record2(value);
|
|
2615
|
+
if (!raw) {
|
|
2616
|
+
errors.push(`${path} must be an object`);
|
|
2617
|
+
continue;
|
|
2618
|
+
}
|
|
2619
|
+
unknownKeys2(raw, OVERRIDE_KEYS, path, errors);
|
|
2620
|
+
const pageId = textValue(raw.pageId, `${path}.pageId`, errors, 80, true) ?? "";
|
|
2621
|
+
if (seen.has(pageId)) errors.push(`${path}.pageId duplicates "${pageId}"`);
|
|
2622
|
+
seen.add(pageId);
|
|
2623
|
+
const parentPage = (options.parentPages ?? []).find((page) => page.id === pageId);
|
|
2624
|
+
if (!parentPage) errors.push(`${path}.pageId must reference a parent page`);
|
|
2625
|
+
else if (parentPage.upsell?.owner !== "chapter") errors.push(`${path}.pageId does not delegate upsell authority to the chapter`);
|
|
2626
|
+
const offer = offerValue(raw.offer, `${path}.offer`, errors, tierIds, parentPage?.audience);
|
|
2627
|
+
if (offer) upsellOverrides.push({ pageId, offer });
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
if (errors.length) return { ok: false, errors };
|
|
2631
|
+
return { ok: true, value: {
|
|
2632
|
+
schemaVersion: MEMBER_CONTENT_SCHEMA_VERSION,
|
|
2633
|
+
revision: Number(root.revision),
|
|
2634
|
+
generatedAt: Number(root.generatedAt),
|
|
2635
|
+
source,
|
|
2636
|
+
...target ? { target } : {},
|
|
2637
|
+
pages,
|
|
2638
|
+
...upsellOverrides ? { upsellOverrides } : {}
|
|
2639
|
+
} };
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
// src/member-content.ts
|
|
2643
|
+
var MEMBER_CONTENT_SCHEMA_VERSION = 1;
|
|
2644
|
+
var MEMBER_CONTENT_PATH = "/api/member-content";
|
|
2645
|
+
var MEMBER_CONTENT_CONTROL_PATH = "/api/member-content-control";
|
|
2646
|
+
var pageOrder = (a, b) => a.navigation.section.localeCompare(b.navigation.section) || a.navigation.order - b.navigation.order || a.title.localeCompare(b.title) || a.id.localeCompare(b.id);
|
|
2647
|
+
function composeMemberContent(parent, chapter) {
|
|
2648
|
+
const pages = [];
|
|
2649
|
+
const parentPages = /* @__PURE__ */ new Map();
|
|
2650
|
+
for (const page of parent?.pages ?? []) {
|
|
2651
|
+
const effective = { ...page, source: parent.source };
|
|
2652
|
+
parentPages.set(page.id, effective);
|
|
2653
|
+
pages.push(effective);
|
|
2654
|
+
}
|
|
2655
|
+
for (const override of chapter?.upsellOverrides ?? []) {
|
|
2656
|
+
const page = parentPages.get(override.pageId);
|
|
2657
|
+
if (page?.upsell?.owner === "chapter") page.upsell = { owner: "chapter", offer: override.offer };
|
|
2658
|
+
}
|
|
2659
|
+
for (const page of chapter?.pages ?? []) {
|
|
2660
|
+
if (parentPages.has(page.id) || pages.some((candidate) => candidate.slug === page.slug)) {
|
|
2661
|
+
throw new TypeError(`Chapter member page "${page.id}" conflicts with parent content`);
|
|
2662
|
+
}
|
|
2663
|
+
pages.push({ ...page, source: chapter.source });
|
|
2664
|
+
}
|
|
2665
|
+
return {
|
|
2666
|
+
pages: pages.sort(pageOrder),
|
|
2667
|
+
parentRevision: parent?.revision ?? null,
|
|
2668
|
+
chapterRevision: chapter?.revision ?? null
|
|
2669
|
+
};
|
|
2670
|
+
}
|
|
2671
|
+
var allowed = (page, viewer) => viewer.admin || page.audience.kind === "all-members" || viewer.tierId !== null && page.audience.tierIds.includes(viewer.tierId);
|
|
2672
|
+
function projectMemberContent(content, viewer, tiers) {
|
|
2673
|
+
const authority = new Map(tiers.filter((tier) => tier.active !== false).map((tier) => [tier.id, tier]));
|
|
2674
|
+
return {
|
|
2675
|
+
viewer: { ...viewer },
|
|
2676
|
+
revisions: { parent: content.parentRevision, chapter: content.chapterRevision },
|
|
2677
|
+
pages: content.pages.map((page) => {
|
|
2678
|
+
const canRead = allowed(page, viewer);
|
|
2679
|
+
const offer = !canRead ? page.upsell?.offer : void 0;
|
|
2680
|
+
const tier = offer ? authority.get(offer.tierId) : void 0;
|
|
2681
|
+
const upsell = offer && tier && typeof tier.name === "string" && typeof tier.priceCents === "number" ? { ...offer, tierName: tier.name, priceCents: tier.priceCents } : void 0;
|
|
2682
|
+
return {
|
|
2683
|
+
id: page.id,
|
|
2684
|
+
slug: page.slug,
|
|
2685
|
+
title: page.title,
|
|
2686
|
+
...page.summary ? { summary: page.summary } : {},
|
|
2687
|
+
navigation: page.navigation,
|
|
2688
|
+
audience: page.audience,
|
|
2689
|
+
source: page.source,
|
|
2690
|
+
access: canRead ? "allowed" : "locked",
|
|
2691
|
+
...page.teaserMarkdown ? { teaserMarkdown: page.teaserMarkdown } : {},
|
|
2692
|
+
...canRead ? { bodyMarkdown: page.bodyMarkdown } : {},
|
|
2693
|
+
...upsell ? { upsell } : {}
|
|
2694
|
+
};
|
|
2695
|
+
})
|
|
2696
|
+
};
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2699
|
+
// src/member-content-store.ts
|
|
2700
|
+
var control = (row) => row && row.document && typeof row.document === "object" ? row : null;
|
|
2701
|
+
var local = (row) => row && row.document && typeof row.document === "object" ? row : null;
|
|
2702
|
+
async function exists(db, ns, where) {
|
|
2703
|
+
if (db.aggregate) return Number((await db.aggregate(ns, { count: true }, { where })).count ?? 0) > 0;
|
|
2704
|
+
const rows4 = (await db.query({ [ns]: {} }))[ns] ?? [];
|
|
2705
|
+
return rows4.some((row) => Object.entries(where).every(([key, value]) => row[key] === value));
|
|
2706
|
+
}
|
|
2707
|
+
async function loadActiveMemberContentControl(db, targetId, environment) {
|
|
2708
|
+
const where = { targetId, environment, active: true };
|
|
2709
|
+
if (!await exists(db, "memberContentControls", where)) return null;
|
|
2710
|
+
const rows4 = (await db.query({ memberContentControls: { $: {
|
|
2711
|
+
where,
|
|
2712
|
+
order: { revision: "desc" },
|
|
2713
|
+
limit: 1
|
|
2714
|
+
} } })).memberContentControls;
|
|
2715
|
+
return control(Array.isArray(rows4) ? [...rows4].sort((a, b) => Number(b.revision) - Number(a.revision))[0] : void 0);
|
|
2716
|
+
}
|
|
2717
|
+
async function loadLatestMemberContentControl(db, targetId, environment) {
|
|
2718
|
+
const where = { targetId, environment };
|
|
2719
|
+
if (!await exists(db, "memberContentControls", where)) return null;
|
|
2720
|
+
const rows4 = (await db.query({ memberContentControls: { $: {
|
|
2721
|
+
where,
|
|
2722
|
+
order: { revision: "desc" },
|
|
2723
|
+
limit: 1
|
|
2724
|
+
} } })).memberContentControls;
|
|
2725
|
+
return control(Array.isArray(rows4) ? [...rows4].sort((a, b) => Number(b.revision) - Number(a.revision))[0] : void 0);
|
|
2726
|
+
}
|
|
2727
|
+
async function loadLocalMemberContent(db, chapterId, environment) {
|
|
2728
|
+
const id = `member-content-local:${environment}`;
|
|
2729
|
+
const rows4 = (await db.query({ memberContentLocal: { $: { where: { id }, limit: 1 } } })).memberContentLocal;
|
|
2730
|
+
return local(Array.isArray(rows4) ? rows4[0] : void 0);
|
|
2731
|
+
}
|
|
2732
|
+
async function materializeMemberContentControl(db, envelope, previous, receivedAt) {
|
|
2733
|
+
const { document, digest } = envelope;
|
|
2734
|
+
const target = document.target;
|
|
2735
|
+
const id = `member-content-control:${target.environment}:${document.revision}`;
|
|
2736
|
+
const ops = [];
|
|
2737
|
+
if (previous?.active) {
|
|
2738
|
+
ops.push({ t: "update", ns: "memberContentControls", id: previous.id, attrs: { active: false } });
|
|
2739
|
+
}
|
|
2740
|
+
ops.push({ t: "update", ns: "memberContentControls", id, attrs: {
|
|
2741
|
+
id,
|
|
2742
|
+
sourceId: document.source.appId,
|
|
2743
|
+
targetId: target.chapterId,
|
|
2744
|
+
environment: target.environment,
|
|
2745
|
+
revision: document.revision,
|
|
2746
|
+
digest,
|
|
2747
|
+
document,
|
|
2748
|
+
active: true,
|
|
2749
|
+
receivedAt
|
|
2750
|
+
} });
|
|
2751
|
+
await db.transact(ops, { mutationId: `member-content-control:${target.environment}:${document.revision}:${digest}` });
|
|
2752
|
+
}
|
|
2753
|
+
async function saveLocalMemberContent(db, document, environment, expectedRevision, updatedAt, updatedBy) {
|
|
2754
|
+
const id = `member-content-local:${environment}`;
|
|
2755
|
+
const current = await loadLocalMemberContent(db, document.source.appId, environment);
|
|
2756
|
+
if ((current?.revision ?? 0) !== expectedRevision) throw new Error("member_content_revision_conflict");
|
|
2757
|
+
const row = {
|
|
2758
|
+
id,
|
|
2759
|
+
chapterId: document.source.appId,
|
|
2760
|
+
environment,
|
|
2761
|
+
revision: document.revision,
|
|
2762
|
+
document,
|
|
2763
|
+
updatedAt,
|
|
2764
|
+
...updatedBy ? { updatedBy } : {}
|
|
2765
|
+
};
|
|
2766
|
+
await db.transact([{ t: "update", ns: "memberContentLocal", id, attrs: row }], {
|
|
2767
|
+
mutationId: `member-content-local:${environment}:${document.revision}`,
|
|
2768
|
+
guards: current ? [{ ns: "memberContentLocal", id, exists: true, equals: { revision: expectedRevision } }] : [{ ns: "memberContentLocal", id, exists: false }]
|
|
2769
|
+
});
|
|
2770
|
+
return row;
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
// src/worker-routes-member-content.ts
|
|
2774
|
+
var ADMIN_PATH = "/api/admin/member-content";
|
|
2775
|
+
var BODY_CAP = 1e6;
|
|
2389
2776
|
var reject2 = (reason, status, errors) => json({ ok: false, reason, ...errors ? { errors } : {} }, status);
|
|
2777
|
+
async function groupAndTiers(db, chapterId) {
|
|
2778
|
+
const groups = (await db.query({ groups: { $: { where: { id: chapterId }, limit: 1 } } })).groups;
|
|
2779
|
+
const group = Array.isArray(groups) ? groups[0] : void 0;
|
|
2780
|
+
if (!group) return { group: null, tiers: [] };
|
|
2781
|
+
return { group, tiers: await loadTiers(db, group) };
|
|
2782
|
+
}
|
|
2783
|
+
async function memberTierId(db, chapterId, email) {
|
|
2784
|
+
if (!email) return null;
|
|
2785
|
+
let rows4 = (await db.query({ applications: { $: {
|
|
2786
|
+
where: { emailNormalized: normalizeAdmissionEmail(email) },
|
|
2787
|
+
limit: 20
|
|
2788
|
+
} } })).applications;
|
|
2789
|
+
if (!rows4?.length) rows4 = (await db.query({ applications: { $: { where: { email }, limit: 20 } } })).applications;
|
|
2790
|
+
const latest = Array.isArray(rows4) ? [...rows4].filter((row) => String(row.groupId ?? chapterId) === chapterId).sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0] : void 0;
|
|
2791
|
+
return typeof latest?.tierId === "string" && latest.tierId ? latest.tierId : null;
|
|
2792
|
+
}
|
|
2793
|
+
async function activeDocuments(db, chapterId, environment) {
|
|
2794
|
+
const [parent, chapter] = await Promise.all([
|
|
2795
|
+
loadActiveMemberContentControl(db, chapterId, environment),
|
|
2796
|
+
loadLocalMemberContent(db, chapterId, environment)
|
|
2797
|
+
]);
|
|
2798
|
+
return { parent, chapter };
|
|
2799
|
+
}
|
|
2800
|
+
var handleMemberContentControl = async (req, url, env, ctx) => {
|
|
2801
|
+
if (req.method !== "POST" || url.pathname !== MEMBER_CONTENT_CONTROL_PATH) return null;
|
|
2802
|
+
const settings = ctx.chapter.memberContent?.parent;
|
|
2803
|
+
if (!settings) return json({ error: "not found" }, 404);
|
|
2804
|
+
const db = ctx.makeDb(env);
|
|
2805
|
+
const secret = await getVaultSecret(db, settings.secretName);
|
|
2806
|
+
if (!secret) return json({ error: "unauthorized" }, 401);
|
|
2807
|
+
const verified = await verifyFederatedRequest3(req, { secret, senders: [settings.sourceId] });
|
|
2808
|
+
if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
|
|
2809
|
+
let input;
|
|
2810
|
+
try {
|
|
2811
|
+
input = JSON.parse(verified.body);
|
|
2812
|
+
} catch {
|
|
2813
|
+
return reject2("invalid_document", 400, ["request body must be JSON"]);
|
|
2814
|
+
}
|
|
2815
|
+
const digest = typeof input.digest === "string" ? input.digest : "";
|
|
2816
|
+
const initial = validateMemberContentDocument(input.document, {
|
|
2817
|
+
sourceType: "parent"
|
|
2818
|
+
});
|
|
2819
|
+
if (!initial.ok) return reject2("invalid_document", 400, initial.errors);
|
|
2820
|
+
const document = initial.value;
|
|
2821
|
+
if (document.source.appId !== settings.sourceId) return reject2("wrong_source", 403);
|
|
2822
|
+
if (document.target?.chapterId !== ctx.chapter.id) return reject2("wrong_target", 409);
|
|
2823
|
+
if (document.target.environment !== env.ODLA_ENV) return reject2("wrong_environment", 409);
|
|
2824
|
+
const { tiers } = await groupAndTiers(db, ctx.chapter.id);
|
|
2825
|
+
const checked = validateMemberContentDocument(document, {
|
|
2826
|
+
sourceType: "parent",
|
|
2827
|
+
sourceAppId: settings.sourceId,
|
|
2828
|
+
tiers
|
|
2829
|
+
});
|
|
2830
|
+
if (!checked.ok) return reject2("invalid_document", 400, checked.errors);
|
|
2831
|
+
const local2 = await loadLocalMemberContent(db, ctx.chapter.id, env.ODLA_ENV);
|
|
2832
|
+
if (local2) {
|
|
2833
|
+
const localCheck = validateMemberContentDocument(local2.document, {
|
|
2834
|
+
sourceType: "chapter",
|
|
2835
|
+
sourceAppId: ctx.chapter.id,
|
|
2836
|
+
tiers,
|
|
2837
|
+
parentPages: document.pages
|
|
2838
|
+
});
|
|
2839
|
+
if (!localCheck.ok) {
|
|
2840
|
+
return reject2("invalid_document", 409, localCheck.errors.map((error) => `existing chapter content: ${error}`));
|
|
2841
|
+
}
|
|
2842
|
+
}
|
|
2843
|
+
if (!digest || await memberContentDigest(document) !== digest) return reject2("invalid_digest", 400);
|
|
2844
|
+
const latest = await loadLatestMemberContentControl(db, ctx.chapter.id, env.ODLA_ENV);
|
|
2845
|
+
if (latest && document.revision < latest.revision) return reject2("stale_revision", 409);
|
|
2846
|
+
if (latest && document.revision === latest.revision) {
|
|
2847
|
+
if (latest.digest !== digest) return reject2("conflicting_revision", 409);
|
|
2848
|
+
return json({
|
|
2849
|
+
ok: true,
|
|
2850
|
+
target: document.target,
|
|
2851
|
+
revision: latest.revision,
|
|
2852
|
+
digest: latest.digest,
|
|
2853
|
+
status: "replayed",
|
|
2854
|
+
acknowledgedAt: latest.receivedAt
|
|
2855
|
+
});
|
|
2856
|
+
}
|
|
2857
|
+
const receivedAt = Date.now();
|
|
2858
|
+
try {
|
|
2859
|
+
await materializeMemberContentControl(db, { digest, document }, latest, receivedAt);
|
|
2860
|
+
} catch {
|
|
2861
|
+
return reject2("materialization_failed", 503);
|
|
2862
|
+
}
|
|
2863
|
+
return json({
|
|
2864
|
+
ok: true,
|
|
2865
|
+
target: document.target,
|
|
2866
|
+
revision: document.revision,
|
|
2867
|
+
digest,
|
|
2868
|
+
status: "accepted",
|
|
2869
|
+
acknowledgedAt: receivedAt
|
|
2870
|
+
});
|
|
2871
|
+
};
|
|
2872
|
+
var handleMemberContent = async (req, url, env, ctx) => {
|
|
2873
|
+
if (req.method !== "GET" || url.pathname !== MEMBER_CONTENT_PATH) return null;
|
|
2874
|
+
if (!ctx.chapter.memberContent) return json({ error: "not found" }, 404);
|
|
2875
|
+
const user = await ctx.verifyUser(req, env);
|
|
2876
|
+
if (!user) return json({ error: "unauthorized" }, 401);
|
|
2877
|
+
const db = ctx.makeDb(env);
|
|
2878
|
+
const role = await ctx.roleFor(db, user);
|
|
2879
|
+
const admin = await ctx.isAdmin(db, user);
|
|
2880
|
+
const roleIndex = ctx.chapter.auth.ladder.indexOf(role);
|
|
2881
|
+
const memberIndex = Math.max(0, ctx.chapter.auth.ladder.length - 2);
|
|
2882
|
+
if (!admin && roleIndex < memberIndex) return json({ error: "member access required" }, 403);
|
|
2883
|
+
const [{ parent, chapter }, { group, tiers }, tierId] = await Promise.all([
|
|
2884
|
+
activeDocuments(db, ctx.chapter.id, env.ODLA_ENV),
|
|
2885
|
+
groupAndTiers(db, ctx.chapter.id),
|
|
2886
|
+
memberTierId(db, ctx.chapter.id, user.email)
|
|
2887
|
+
]);
|
|
2888
|
+
const effective = composeMemberContent(parent?.document, chapter?.document);
|
|
2889
|
+
let offerable = tiers;
|
|
2890
|
+
if (group) {
|
|
2891
|
+
const stripeSecret = await getVaultSecret(db, "stripe_secret_key");
|
|
2892
|
+
offerable = await loadOfferableTiers(db, group, Boolean(stripeSecret));
|
|
2893
|
+
}
|
|
2894
|
+
return json(projectMemberContent(effective, { tierId, admin }, offerable));
|
|
2895
|
+
};
|
|
2896
|
+
var handleAdminMemberContent = async (req, url, env, ctx) => {
|
|
2897
|
+
if (url.pathname !== ADMIN_PATH) return null;
|
|
2898
|
+
if (!ctx.chapter.memberContent) return json({ error: "not found" }, 404);
|
|
2899
|
+
const user = await ctx.verifyUser(req, env);
|
|
2900
|
+
if (!user) return json({ error: "unauthorized" }, 401);
|
|
2901
|
+
const db = ctx.makeDb(env);
|
|
2902
|
+
if (!await ctx.isAdmin(db, user)) return json({ error: "forbidden" }, 403);
|
|
2903
|
+
const [{ parent, chapter }, { tiers }] = await Promise.all([
|
|
2904
|
+
activeDocuments(db, ctx.chapter.id, env.ODLA_ENV),
|
|
2905
|
+
groupAndTiers(db, ctx.chapter.id)
|
|
2906
|
+
]);
|
|
2907
|
+
if (req.method === "GET") {
|
|
2908
|
+
const effective = composeMemberContent(parent?.document, chapter?.document);
|
|
2909
|
+
return json({
|
|
2910
|
+
parent: parent ? { revision: parent.revision, digest: parent.digest, document: parent.document } : null,
|
|
2911
|
+
chapter: chapter ? { revision: chapter.revision, document: chapter.document } : null,
|
|
2912
|
+
effective,
|
|
2913
|
+
tiers: tiers.map(({ id, name, priceCents, active }) => ({ id, name, priceCents, active })),
|
|
2914
|
+
localAuthoring: ctx.chapter.memberContent.localAuthoring
|
|
2915
|
+
});
|
|
2916
|
+
}
|
|
2917
|
+
if (req.method !== "PUT") return json({ error: "method not allowed" }, 405);
|
|
2918
|
+
if (!ctx.chapter.memberContent.localAuthoring) return json({ error: "local authoring is disabled" }, 403);
|
|
2919
|
+
const raw = await req.text();
|
|
2920
|
+
if (raw.length > BODY_CAP) return json({ error: "request body too large" }, 413);
|
|
2921
|
+
let input;
|
|
2922
|
+
try {
|
|
2923
|
+
input = JSON.parse(raw);
|
|
2924
|
+
} catch {
|
|
2925
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
2926
|
+
}
|
|
2927
|
+
const expectedRevision = Number(input.expectedRevision);
|
|
2928
|
+
if (!Number.isInteger(expectedRevision) || expectedRevision < 0) {
|
|
2929
|
+
return json({ error: "expectedRevision must be a non-negative integer" }, 400);
|
|
2930
|
+
}
|
|
2931
|
+
const now = Date.now();
|
|
2932
|
+
const documentInput = withLocalAuthority(input.document, expectedRevision + 1, now, ctx.chapter.id);
|
|
2933
|
+
const checked = validateMemberContentDocument(documentInput, {
|
|
2934
|
+
sourceType: "chapter",
|
|
2935
|
+
sourceAppId: ctx.chapter.id,
|
|
2936
|
+
tiers,
|
|
2937
|
+
parentPages: parent?.document.pages ?? []
|
|
2938
|
+
});
|
|
2939
|
+
if (!checked.ok) return json({ error: "invalid member content", errors: checked.errors }, 400);
|
|
2940
|
+
try {
|
|
2941
|
+
const saved = await saveLocalMemberContent(
|
|
2942
|
+
db,
|
|
2943
|
+
checked.value,
|
|
2944
|
+
env.ODLA_ENV,
|
|
2945
|
+
expectedRevision,
|
|
2946
|
+
now,
|
|
2947
|
+
user.email ?? user.userId
|
|
2948
|
+
);
|
|
2949
|
+
return json({ ok: true, revision: saved.revision, document: saved.document });
|
|
2950
|
+
} catch (error) {
|
|
2951
|
+
if (error instanceof Error && error.message === "member_content_revision_conflict") {
|
|
2952
|
+
return json({ error: "member content changed; reload before saving" }, 409);
|
|
2953
|
+
}
|
|
2954
|
+
return json({ error: "member content could not be saved" }, 503);
|
|
2955
|
+
}
|
|
2956
|
+
};
|
|
2957
|
+
function withLocalAuthority(value, revision, generatedAt, appId) {
|
|
2958
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
|
2959
|
+
return {
|
|
2960
|
+
...value,
|
|
2961
|
+
schemaVersion: 1,
|
|
2962
|
+
revision,
|
|
2963
|
+
generatedAt,
|
|
2964
|
+
source: { type: "chapter", appId }
|
|
2965
|
+
};
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2968
|
+
// src/worker-routes-admission-grants.ts
|
|
2969
|
+
import { verifyFederatedRequest as verifyFederatedRequest4 } from "@odla-ai/db";
|
|
2970
|
+
var reject3 = (reason, status, errors) => json({ ok: false, reason, ...errors ? { errors } : {} }, status);
|
|
2390
2971
|
var handleAdmissionGrants = async (req, url, env, ctx) => {
|
|
2391
2972
|
if (req.method !== "POST" || url.pathname !== ADMISSION_GRANTS_PATH) return null;
|
|
2392
2973
|
const authority = ctx.chapter.signupControl;
|
|
@@ -2394,26 +2975,26 @@ var handleAdmissionGrants = async (req, url, env, ctx) => {
|
|
|
2394
2975
|
const db = ctx.makeDb(env);
|
|
2395
2976
|
const secret = await getVaultSecret(db, authority.secretName);
|
|
2396
2977
|
if (!secret) return json({ error: "unauthorized" }, 401);
|
|
2397
|
-
const verified = await
|
|
2978
|
+
const verified = await verifyFederatedRequest4(req, { secret, senders: [authority.sourceId] });
|
|
2398
2979
|
if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
|
|
2399
2980
|
let input;
|
|
2400
2981
|
try {
|
|
2401
2982
|
input = JSON.parse(verified.body);
|
|
2402
2983
|
} catch {
|
|
2403
|
-
return
|
|
2984
|
+
return reject3("invalid_document", 400, ["request body must be JSON"]);
|
|
2404
2985
|
}
|
|
2405
2986
|
const digest = typeof input.digest === "string" ? input.digest : "";
|
|
2406
2987
|
const checked = validateAdmissionGrantDocument(input.document);
|
|
2407
|
-
if (!checked.ok) return
|
|
2988
|
+
if (!checked.ok) return reject3("invalid_document", 400, checked.errors);
|
|
2408
2989
|
const document = checked.value;
|
|
2409
|
-
if (document.source.appId !== authority.sourceId) return
|
|
2410
|
-
if (document.target.chapterId !== ctx.chapter.id) return
|
|
2411
|
-
if (document.target.environment !== env.ODLA_ENV) return
|
|
2412
|
-
if (!digest || await signupControlDigest(document) !== digest) return
|
|
2990
|
+
if (document.source.appId !== authority.sourceId) return reject3("wrong_source", 403);
|
|
2991
|
+
if (document.target.chapterId !== ctx.chapter.id) return reject3("wrong_target", 409);
|
|
2992
|
+
if (document.target.environment !== env.ODLA_ENV) return reject3("wrong_environment", 409);
|
|
2993
|
+
if (!digest || await signupControlDigest(document) !== digest) return reject3("invalid_digest", 400);
|
|
2413
2994
|
const latest = await loadLatestAdmissionRevision(db, authority.sourceId, ctx.chapter.id, env.ODLA_ENV);
|
|
2414
|
-
if (latest && document.revision < latest.revision) return
|
|
2995
|
+
if (latest && document.revision < latest.revision) return reject3("stale_revision", 409);
|
|
2415
2996
|
if (latest && document.revision === latest.revision) {
|
|
2416
|
-
if (latest.digest !== digest) return
|
|
2997
|
+
if (latest.digest !== digest) return reject3("conflicting_revision", 409);
|
|
2417
2998
|
return json({
|
|
2418
2999
|
ok: true,
|
|
2419
3000
|
target: { chapterId: ctx.chapter.id, environment: env.ODLA_ENV },
|
|
@@ -2427,7 +3008,7 @@ var handleAdmissionGrants = async (req, url, env, ctx) => {
|
|
|
2427
3008
|
try {
|
|
2428
3009
|
await materializeAdmissionGrants(db, { digest, document }, latest, receivedAt);
|
|
2429
3010
|
} catch {
|
|
2430
|
-
return
|
|
3011
|
+
return reject3("materialization_failed", 503);
|
|
2431
3012
|
}
|
|
2432
3013
|
return json({
|
|
2433
3014
|
ok: true,
|
|
@@ -3255,8 +3836,8 @@ async function startSubscription(req, env, ctx) {
|
|
|
3255
3836
|
const secretKey = await getVaultSecret(db, "stripe_secret_key");
|
|
3256
3837
|
if (!group || !secretKey) return json({ error: "payments not configured" }, 503);
|
|
3257
3838
|
if (typeof app.giftOfferId === "string") {
|
|
3258
|
-
const
|
|
3259
|
-
const offer = findGiftOffer(
|
|
3839
|
+
const control2 = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
|
|
3840
|
+
const offer = findGiftOffer(control2?.document.giftOffers, app.giftOfferId, Date.now());
|
|
3260
3841
|
if (!offer || typeof group.stripePublishableKey !== "string" || !group.stripePublishableKey) {
|
|
3261
3842
|
return json({ error: "gift offer is unavailable" }, 409);
|
|
3262
3843
|
}
|
|
@@ -3415,14 +3996,14 @@ var handleGifts = async (req, url, env, ctx) => {
|
|
|
3415
3996
|
if (req.method === "GET" && url.pathname === "/api/gifts") {
|
|
3416
3997
|
const user = await userFor(req, env, ctx);
|
|
3417
3998
|
if (!user) return json({ error: "unauthorized" }, 401);
|
|
3418
|
-
const
|
|
3999
|
+
const control2 = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
|
|
3419
4000
|
const found = await db.query({ giftClaims: { $: { where: {
|
|
3420
4001
|
purchaserEmailNormalized: normalizeAdmissionEmail(user.email),
|
|
3421
4002
|
chapterId: ctx.chapter.id
|
|
3422
4003
|
}, limit: 200 } } });
|
|
3423
4004
|
const group = first3((await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups);
|
|
3424
4005
|
return json({
|
|
3425
|
-
offers: publicGiftOffers(
|
|
4006
|
+
offers: publicGiftOffers(control2?.document.giftOffers, Date.now()),
|
|
3426
4007
|
claims: buyerGiftClaims(rows2(found.giftClaims).sort((a, b) => Number(b.createdAt) - Number(a.createdAt)), Date.now()),
|
|
3427
4008
|
refundPolicyText: typeof group?.refundPolicyText === "string" ? group.refundPolicyText : ""
|
|
3428
4009
|
});
|
|
@@ -3438,8 +4019,8 @@ var handleGifts = async (req, url, env, ctx) => {
|
|
|
3438
4019
|
}
|
|
3439
4020
|
const purchaseId = typeof body.purchaseId === "string" ? body.purchaseId : "";
|
|
3440
4021
|
if (!/^[A-Za-z0-9_-]{16,128}$/.test(purchaseId)) return json({ error: "purchaseId is invalid" }, 400);
|
|
3441
|
-
const
|
|
3442
|
-
const offer = findGiftOffer(
|
|
4022
|
+
const control2 = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
|
|
4023
|
+
const offer = findGiftOffer(control2?.document.giftOffers, typeof body.offerId === "string" ? body.offerId : "", Date.now());
|
|
3443
4024
|
if (!offer) return json({ error: "gift offer is unavailable" }, 409);
|
|
3444
4025
|
const group = first3((await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups);
|
|
3445
4026
|
const stripeSecret = await getVaultSecret(db, "stripe_secret_key");
|
|
@@ -4360,7 +4941,7 @@ var handleAdminComms = async (req, url, env, ctx) => {
|
|
|
4360
4941
|
|
|
4361
4942
|
// src/worker-routes-network.ts
|
|
4362
4943
|
import { getRecord, typeSummary } from "@odla-ai/crm";
|
|
4363
|
-
import { signFederatedRequest as signFederatedRequest2, verifyFederatedRequest as
|
|
4944
|
+
import { signFederatedRequest as signFederatedRequest2, verifyFederatedRequest as verifyFederatedRequest5 } from "@odla-ai/db";
|
|
4364
4945
|
|
|
4365
4946
|
// src/worker-network-fetch.ts
|
|
4366
4947
|
function targetFetcher(env, target) {
|
|
@@ -4379,7 +4960,7 @@ function fetchNetworkTarget(env, target, destination, init) {
|
|
|
4379
4960
|
// src/worker-network-push.ts
|
|
4380
4961
|
import { recordDeliveryAttempt } from "@odla-ai/crm";
|
|
4381
4962
|
import { signFederatedRequest } from "@odla-ai/db";
|
|
4382
|
-
async function pushNetworkRecord(db, env, ctx, target,
|
|
4963
|
+
async function pushNetworkRecord(db, env, ctx, target, record3) {
|
|
4383
4964
|
const secret = await getVaultSecret(db, target.secretName);
|
|
4384
4965
|
const crmDeps3 = {
|
|
4385
4966
|
crm: ctx.chapter.crm,
|
|
@@ -4390,7 +4971,7 @@ async function pushNetworkRecord(db, env, ctx, target, record2) {
|
|
|
4390
4971
|
if (!secret) {
|
|
4391
4972
|
const error = `vault secret "${target.secretName}" is missing`;
|
|
4392
4973
|
await recordDeliveryAttempt(crmDeps3, {
|
|
4393
|
-
recordId:
|
|
4974
|
+
recordId: record3.id,
|
|
4394
4975
|
targetId: target.id,
|
|
4395
4976
|
status: "failed",
|
|
4396
4977
|
payloadVersion: 2,
|
|
@@ -4400,7 +4981,7 @@ async function pushNetworkRecord(db, env, ctx, target, record2) {
|
|
|
4400
4981
|
}
|
|
4401
4982
|
let payload;
|
|
4402
4983
|
try {
|
|
4403
|
-
payload = sharedRecordFromCrm(ctx.chapter.crm,
|
|
4984
|
+
payload = sharedRecordFromCrm(ctx.chapter.crm, record3, target, ctx.chapter.id);
|
|
4404
4985
|
} catch (err) {
|
|
4405
4986
|
return {
|
|
4406
4987
|
id: target.id,
|
|
@@ -4429,7 +5010,7 @@ async function pushNetworkRecord(db, env, ctx, target, record2) {
|
|
|
4429
5010
|
if (!res.ok) {
|
|
4430
5011
|
const error = responseBody.error ?? "follower rejected the record";
|
|
4431
5012
|
await recordDeliveryAttempt(crmDeps3, {
|
|
4432
|
-
recordId:
|
|
5013
|
+
recordId: record3.id,
|
|
4433
5014
|
targetId: target.id,
|
|
4434
5015
|
status: "failed",
|
|
4435
5016
|
payloadVersion: 2,
|
|
@@ -4444,7 +5025,7 @@ async function pushNetworkRecord(db, env, ctx, target, record2) {
|
|
|
4444
5025
|
};
|
|
4445
5026
|
}
|
|
4446
5027
|
await recordDeliveryAttempt(crmDeps3, {
|
|
4447
|
-
recordId:
|
|
5028
|
+
recordId: record3.id,
|
|
4448
5029
|
targetId: target.id,
|
|
4449
5030
|
status: "delivered",
|
|
4450
5031
|
payloadVersion: 2,
|
|
@@ -4460,7 +5041,7 @@ async function pushNetworkRecord(db, env, ctx, target, record2) {
|
|
|
4460
5041
|
} catch (err) {
|
|
4461
5042
|
const error = err instanceof Error ? err.message : "delivery failed";
|
|
4462
5043
|
await recordDeliveryAttempt(crmDeps3, {
|
|
4463
|
-
recordId:
|
|
5044
|
+
recordId: record3.id,
|
|
4464
5045
|
targetId: target.id,
|
|
4465
5046
|
status: "failed",
|
|
4466
5047
|
payloadVersion: 2,
|
|
@@ -4497,7 +5078,7 @@ var handleNetworkSnapshot = async (req, url, env, ctx) => {
|
|
|
4497
5078
|
const db = ctx.makeDb(env);
|
|
4498
5079
|
const secret = await getVaultSecret(db, "network_share_secret");
|
|
4499
5080
|
if (!secret) return json({ error: "unauthorized" }, 401);
|
|
4500
|
-
const verified = await
|
|
5081
|
+
const verified = await verifyFederatedRequest5(req, { secret });
|
|
4501
5082
|
if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
|
|
4502
5083
|
const types = await Promise.all(
|
|
4503
5084
|
Object.keys(ctx.chapter.crm.config.types).map((type) => typeSummary({ crm: ctx.chapter.crm, db }, type))
|
|
@@ -4598,17 +5179,17 @@ var handleAdminNetworkPush = async (req, url, env, ctx) => {
|
|
|
4598
5179
|
if (requested.size !== body.targetIds.length) return json({ error: "targetIds must contain unique strings" }, 400);
|
|
4599
5180
|
const targets = ctx.chapter.network.targets.filter((target) => requested.has(target.id));
|
|
4600
5181
|
if (targets.length !== requested.size) return json({ error: "one or more targetIds are not configured" }, 400);
|
|
4601
|
-
const
|
|
4602
|
-
if (!
|
|
5182
|
+
const record3 = await getRecord({ crm: ctx.chapter.crm, db: got.db }, body.recordId);
|
|
5183
|
+
if (!record3) return json({ error: "record not found" }, 404);
|
|
4603
5184
|
const results = await Promise.all(
|
|
4604
|
-
targets.map((target) => pushNetworkRecord(got.db, env, ctx, target,
|
|
5185
|
+
targets.map((target) => pushNetworkRecord(got.db, env, ctx, target, record3))
|
|
4605
5186
|
);
|
|
4606
5187
|
return json({ ok: results.every((result) => result.ok), results });
|
|
4607
5188
|
};
|
|
4608
5189
|
|
|
4609
5190
|
// src/worker-routes-network-records.ts
|
|
4610
5191
|
import { listRecords } from "@odla-ai/crm";
|
|
4611
|
-
import { signFederatedRequest as signFederatedRequest3, verifyFederatedRequest as
|
|
5192
|
+
import { signFederatedRequest as signFederatedRequest3, verifyFederatedRequest as verifyFederatedRequest6 } from "@odla-ai/db";
|
|
4612
5193
|
var IDENTIFIER = /^[a-z][a-zA-Z0-9_]*$/;
|
|
4613
5194
|
var MAX_PAGE = 50;
|
|
4614
5195
|
var MAX_OFFSET = 1e4;
|
|
@@ -4626,18 +5207,18 @@ function integer(value, fallback, min, max) {
|
|
|
4626
5207
|
const parsed = Number(value);
|
|
4627
5208
|
return Number.isSafeInteger(parsed) && parsed >= min && parsed <= max ? parsed : null;
|
|
4628
5209
|
}
|
|
4629
|
-
function projectRecord(
|
|
5210
|
+
function projectRecord(record3, allowed2) {
|
|
4630
5211
|
const fields = Object.fromEntries(
|
|
4631
|
-
|
|
5212
|
+
allowed2.flatMap((name) => Object.prototype.hasOwnProperty.call(record3.fields, name) ? [[name, record3.fields[name]]] : [])
|
|
4632
5213
|
);
|
|
4633
5214
|
return {
|
|
4634
|
-
id:
|
|
4635
|
-
type:
|
|
4636
|
-
name:
|
|
4637
|
-
...
|
|
5215
|
+
id: record3.id,
|
|
5216
|
+
type: record3.type,
|
|
5217
|
+
name: record3.name,
|
|
5218
|
+
...record3.stage ? { stage: record3.stage } : {},
|
|
4638
5219
|
fields,
|
|
4639
|
-
createdAt:
|
|
4640
|
-
updatedAt:
|
|
5220
|
+
createdAt: record3.createdAt,
|
|
5221
|
+
updatedAt: record3.updatedAt
|
|
4641
5222
|
};
|
|
4642
5223
|
}
|
|
4643
5224
|
var handleNetworkRecords = async (req, url, env, ctx) => {
|
|
@@ -4646,15 +5227,15 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
|
|
|
4646
5227
|
const secret = await getVaultSecret(db, "network_share_secret");
|
|
4647
5228
|
const readers = ctx.chapter.network.readers;
|
|
4648
5229
|
if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
|
|
4649
|
-
const verified = await
|
|
5230
|
+
const verified = await verifyFederatedRequest6(req, {
|
|
4650
5231
|
secret,
|
|
4651
5232
|
senders: readers.map((reader2) => reader2.id)
|
|
4652
5233
|
});
|
|
4653
5234
|
if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
|
|
4654
5235
|
const reader = readers.find((candidate) => candidate.id === verified.sender);
|
|
4655
5236
|
const type = url.searchParams.get("type") ?? "";
|
|
4656
|
-
const
|
|
4657
|
-
if (!IDENTIFIER.test(type) || !
|
|
5237
|
+
const allowed2 = reader?.fields[type];
|
|
5238
|
+
if (!IDENTIFIER.test(type) || !allowed2) return json({ error: "record type is not shared with this reader" }, 403);
|
|
4658
5239
|
const limit = integer(url.searchParams.get("limit"), 25, 1, MAX_PAGE);
|
|
4659
5240
|
const offset = integer(url.searchParams.get("offset"), 0, 0, MAX_OFFSET);
|
|
4660
5241
|
const search = (url.searchParams.get("search") ?? "").trim();
|
|
@@ -4675,7 +5256,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
|
|
|
4675
5256
|
version: 1,
|
|
4676
5257
|
site: { id: ctx.chapter.id, name: ctx.chapter.name },
|
|
4677
5258
|
type,
|
|
4678
|
-
records: page.records.map((
|
|
5259
|
+
records: page.records.map((record3) => projectRecord(record3, allowed2)),
|
|
4679
5260
|
total: page.total,
|
|
4680
5261
|
limit: page.limit,
|
|
4681
5262
|
offset: page.offset
|
|
@@ -4830,7 +5411,7 @@ import {
|
|
|
4830
5411
|
CRM_NS,
|
|
4831
5412
|
getRecord as getRecord2
|
|
4832
5413
|
} from "@odla-ai/crm";
|
|
4833
|
-
import { signFederatedRequest as signFederatedRequest4, verifyFederatedRequest as
|
|
5414
|
+
import { signFederatedRequest as signFederatedRequest4, verifyFederatedRequest as verifyFederatedRequest7 } from "@odla-ai/db";
|
|
4834
5415
|
var IDENTIFIER3 = /^[a-z][a-zA-Z0-9_]*$/;
|
|
4835
5416
|
var RECORD_ID2 = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
|
|
4836
5417
|
var MUTATION_ID = /^[A-Za-z0-9_-]{16,128}$/;
|
|
@@ -4889,7 +5470,7 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
4889
5470
|
const secret = await getVaultSecret(db, "network_share_secret");
|
|
4890
5471
|
const readers = ctx.chapter.network.readers;
|
|
4891
5472
|
if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
|
|
4892
|
-
const verified = await
|
|
5473
|
+
const verified = await verifyFederatedRequest7(req, {
|
|
4893
5474
|
secret,
|
|
4894
5475
|
senders: readers.map((reader2) => reader2.id)
|
|
4895
5476
|
});
|
|
@@ -4900,8 +5481,8 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
4900
5481
|
if (!IDENTIFIER3.test(recordType) || !RECORD_ID2.test(recordId) || !reader?.sharedNotes.includes(recordType)) {
|
|
4901
5482
|
return json({ error: "shared notes are not enabled for this reader and record type" }, 403);
|
|
4902
5483
|
}
|
|
4903
|
-
const
|
|
4904
|
-
if (!
|
|
5484
|
+
const record3 = await getRecord2({ crm: ctx.chapter.crm, db }, recordId);
|
|
5485
|
+
if (!record3 || record3.type !== recordType) return json({ error: "record not found" }, 404);
|
|
4905
5486
|
if (req.method === "GET") {
|
|
4906
5487
|
return json({
|
|
4907
5488
|
version: 1,
|
|
@@ -5055,15 +5636,15 @@ var handleFormation = async (req, url, env, ctx) => {
|
|
|
5055
5636
|
if (submissionId && !SUBMISSION.test(submissionId)) {
|
|
5056
5637
|
return json({ error: "submissionId must be an 8\u2013128 character base64url value" }, 400);
|
|
5057
5638
|
}
|
|
5058
|
-
const
|
|
5059
|
-
const unknown = Object.keys(body).filter((field) => field !== "submissionId" && !
|
|
5639
|
+
const allowed2 = /* @__PURE__ */ new Set([...formation.required, ...formation.optional]);
|
|
5640
|
+
const unknown = Object.keys(body).filter((field) => field !== "submissionId" && !allowed2.has(field));
|
|
5060
5641
|
if (unknown.length) return json({ error: `field "${unknown[0]}" is not accepted` }, 400);
|
|
5061
5642
|
for (const field of formation.required) {
|
|
5062
5643
|
const value = body[field];
|
|
5063
5644
|
if (typeof value !== "string" || value.trim() === "") return json({ error: `${field} is required` }, 400);
|
|
5064
5645
|
}
|
|
5065
5646
|
const input = {};
|
|
5066
|
-
for (const field of
|
|
5647
|
+
for (const field of allowed2) {
|
|
5067
5648
|
const value = body[field];
|
|
5068
5649
|
if (value === void 0) continue;
|
|
5069
5650
|
const cap = formation.maxLen[field] ?? formation.defaultMaxLen;
|
|
@@ -5131,12 +5712,37 @@ async function deliverSignupControl(options) {
|
|
|
5131
5712
|
return result;
|
|
5132
5713
|
}
|
|
5133
5714
|
|
|
5134
|
-
// src/
|
|
5715
|
+
// src/member-content-delivery.ts
|
|
5135
5716
|
import { signFederatedRequest as signFederatedRequest6 } from "@odla-ai/db";
|
|
5717
|
+
async function deliverMemberContent(options) {
|
|
5718
|
+
const destination = new URL(MEMBER_CONTENT_CONTROL_PATH, options.targetUrl);
|
|
5719
|
+
const body = JSON.stringify(options.envelope);
|
|
5720
|
+
const signed = await signFederatedRequest6({
|
|
5721
|
+
secret: options.secret,
|
|
5722
|
+
sender: options.sender,
|
|
5723
|
+
method: "POST",
|
|
5724
|
+
url: destination,
|
|
5725
|
+
body
|
|
5726
|
+
});
|
|
5727
|
+
const response = await (options.fetch ?? globalThis.fetch)(destination, {
|
|
5728
|
+
method: "POST",
|
|
5729
|
+
headers: { ...signed, "content-type": "application/json" },
|
|
5730
|
+
body
|
|
5731
|
+
});
|
|
5732
|
+
const result = await response.json();
|
|
5733
|
+
if (!response.ok || !("ok" in result) || result.ok !== true) {
|
|
5734
|
+
const failure = result;
|
|
5735
|
+
throw new Error(`member content rejected (${response.status}): ${failure.reason ?? failure.error ?? "unknown"}`);
|
|
5736
|
+
}
|
|
5737
|
+
return result;
|
|
5738
|
+
}
|
|
5739
|
+
|
|
5740
|
+
// src/admission-grants-delivery.ts
|
|
5741
|
+
import { signFederatedRequest as signFederatedRequest7 } from "@odla-ai/db";
|
|
5136
5742
|
async function deliverAdmissionGrants(options) {
|
|
5137
5743
|
const destination = new URL(ADMISSION_GRANTS_PATH, options.targetUrl);
|
|
5138
5744
|
const body = JSON.stringify(options.envelope);
|
|
5139
|
-
const signed = await
|
|
5745
|
+
const signed = await signFederatedRequest7({
|
|
5140
5746
|
secret: options.secret,
|
|
5141
5747
|
sender: options.sender,
|
|
5142
5748
|
method: "POST",
|
|
@@ -5295,12 +5901,14 @@ var BUILTIN_ROUTES = [
|
|
|
5295
5901
|
handleCrm,
|
|
5296
5902
|
handleNetworkShared,
|
|
5297
5903
|
handleSignupControl,
|
|
5904
|
+
handleMemberContentControl,
|
|
5298
5905
|
handleAdmissionGrants,
|
|
5299
5906
|
handleNetworkSnapshot,
|
|
5300
5907
|
handleNetworkRecords,
|
|
5301
5908
|
handleNetworkSharedNotes,
|
|
5302
5909
|
handleFormation,
|
|
5303
5910
|
handleMember,
|
|
5911
|
+
handleMemberContent,
|
|
5304
5912
|
handleGifts,
|
|
5305
5913
|
handleSchedule,
|
|
5306
5914
|
handlePayments,
|
|
@@ -5316,6 +5924,7 @@ var BUILTIN_ROUTES = [
|
|
|
5316
5924
|
// Aggregation
|
|
5317
5925
|
handleAdminDashboard,
|
|
5318
5926
|
handleAdminBilling,
|
|
5927
|
+
handleAdminMemberContent,
|
|
5319
5928
|
// Lifecycle actions
|
|
5320
5929
|
handleAdminMeetingReschedule,
|
|
5321
5930
|
handleAdminMeetingCancel,
|
|
@@ -5357,6 +5966,7 @@ export {
|
|
|
5357
5966
|
chapterWorker,
|
|
5358
5967
|
createWorkerContext,
|
|
5359
5968
|
deliverAdmissionGrants,
|
|
5969
|
+
deliverMemberContent,
|
|
5360
5970
|
deliverSignupControl,
|
|
5361
5971
|
renderActiveSignupControlHtml,
|
|
5362
5972
|
withActiveSignupControlTheme
|