@odla-ai/chapter 0.36.0 → 0.38.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 +136 -2
- 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 +807 -345
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +295 -36
- package/dist/index.d.ts +295 -36
- package/dist/index.js +807 -345
- 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 +63 -4
- package/dist/ui/member/index.js +3 -1
- package/dist/worker/index.cjs +1032 -354
- 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 +1025 -347
- 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.cjs
CHANGED
|
@@ -23,6 +23,7 @@ __export(worker_exports, {
|
|
|
23
23
|
chapterWorker: () => chapterWorker,
|
|
24
24
|
createWorkerContext: () => createWorkerContext,
|
|
25
25
|
deliverAdmissionGrants: () => deliverAdmissionGrants,
|
|
26
|
+
deliverMemberContent: () => deliverMemberContent,
|
|
26
27
|
deliverSignupControl: () => deliverSignupControl,
|
|
27
28
|
renderActiveSignupControlHtml: () => renderActiveSignupControlHtml,
|
|
28
29
|
withActiveSignupControlTheme: () => withActiveSignupControlTheme
|
|
@@ -198,204 +199,10 @@ async function applicationIdForSubmission(submissionId) {
|
|
|
198
199
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
199
200
|
}
|
|
200
201
|
|
|
201
|
-
// src/
|
|
202
|
-
|
|
203
|
-
var ADMISSION_GRANTS_PATH = "/api/admission-grants";
|
|
204
|
-
|
|
205
|
-
// src/signup-control-canonical.ts
|
|
206
|
-
var encoder = new TextEncoder();
|
|
207
|
-
function canonicalValue(value) {
|
|
208
|
-
if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
|
|
209
|
-
return JSON.stringify(value);
|
|
210
|
-
}
|
|
211
|
-
if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
|
|
212
|
-
if (typeof value !== "object") throw new TypeError("signup control contains an unsupported value");
|
|
213
|
-
const record2 = value;
|
|
214
|
-
const entries = Object.keys(record2).sort().flatMap((key) => {
|
|
215
|
-
const item = record2[key];
|
|
216
|
-
return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue(item)}`];
|
|
217
|
-
});
|
|
218
|
-
return `{${entries.join(",")}}`;
|
|
219
|
-
}
|
|
220
|
-
function canonicalSignupControl(value) {
|
|
221
|
-
return canonicalValue(value);
|
|
222
|
-
}
|
|
223
|
-
async function signupControlDigest(document) {
|
|
224
|
-
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(canonicalSignupControl(document)));
|
|
225
|
-
return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// src/admission-grants.ts
|
|
229
|
-
var ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
|
|
230
|
-
var MAX_GRANTS = 2e3;
|
|
231
|
-
var MAX_NOTE = 2e3;
|
|
232
|
-
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
233
|
-
var isAdmissionEmail = (value) => EMAIL_RE.test(value);
|
|
234
|
-
function normalizeAdmissionEmail(email) {
|
|
202
|
+
// src/email-identity.ts
|
|
203
|
+
function normalizeChapterEmail(email) {
|
|
235
204
|
return email.trim().toLowerCase();
|
|
236
205
|
}
|
|
237
|
-
function validateAdmissionGrantDocument(input) {
|
|
238
|
-
const errors = [];
|
|
239
|
-
const root = input && typeof input === "object" ? input : {};
|
|
240
|
-
if (root.schemaVersion !== ADMISSION_GRANTS_SCHEMA_VERSION) errors.push("document.schemaVersion must be 1");
|
|
241
|
-
const revision = Number(root.revision);
|
|
242
|
-
const generatedAt = Number(root.generatedAt);
|
|
243
|
-
if (!Number.isSafeInteger(revision) || revision < 1) errors.push("document.revision must be a positive integer");
|
|
244
|
-
if (!Number.isFinite(generatedAt) || generatedAt <= 0) errors.push("document.generatedAt must be epoch milliseconds");
|
|
245
|
-
const source = root.source && typeof root.source === "object" ? root.source : {};
|
|
246
|
-
const target = root.target && typeof root.target === "object" ? root.target : {};
|
|
247
|
-
const sourceId = typeof source.appId === "string" ? source.appId : "";
|
|
248
|
-
const sourceActor = typeof source.actor === "string" ? source.actor.trim() : "";
|
|
249
|
-
const chapterId = typeof target.chapterId === "string" ? target.chapterId : "";
|
|
250
|
-
const environment = typeof target.environment === "string" ? target.environment : "";
|
|
251
|
-
if (!ID.test(sourceId)) errors.push("document.source.appId must be an identifier");
|
|
252
|
-
if (!sourceActor || sourceActor.length > 256) errors.push("document.source.actor must be 1-256 characters");
|
|
253
|
-
if (!ID.test(chapterId)) errors.push("document.target.chapterId must be an identifier");
|
|
254
|
-
if (!ID.test(environment)) errors.push("document.target.environment must be an identifier");
|
|
255
|
-
if (!Array.isArray(root.grants)) errors.push("document.grants must be an array");
|
|
256
|
-
const rawGrants = Array.isArray(root.grants) ? root.grants : [];
|
|
257
|
-
if (rawGrants.length > MAX_GRANTS) errors.push(`document.grants supports at most ${MAX_GRANTS} entries`);
|
|
258
|
-
const grants = [];
|
|
259
|
-
const seenIds = /* @__PURE__ */ new Set();
|
|
260
|
-
const seenEmails = /* @__PURE__ */ new Set();
|
|
261
|
-
for (const [index, raw] of rawGrants.slice(0, MAX_GRANTS).entries()) {
|
|
262
|
-
const grant = raw && typeof raw === "object" ? raw : {};
|
|
263
|
-
const id = typeof grant.id === "string" ? grant.id : "";
|
|
264
|
-
const email = typeof grant.email === "string" ? grant.email.trim() : "";
|
|
265
|
-
const normalized = normalizeAdmissionEmail(email);
|
|
266
|
-
const decision = grant.decision;
|
|
267
|
-
const note = typeof grant.note === "string" ? grant.note.trim() : void 0;
|
|
268
|
-
const expiresAt = grant.expiresAt === void 0 ? void 0 : Number(grant.expiresAt);
|
|
269
|
-
if (!ID.test(id)) errors.push(`document.grants[${index}].id must be an identifier`);
|
|
270
|
-
if (seenIds.has(id)) errors.push(`document.grants repeats id "${id}"`);
|
|
271
|
-
if (!isAdmissionEmail(normalized)) errors.push(`document.grants[${index}].email must be valid`);
|
|
272
|
-
if (seenEmails.has(normalized)) errors.push(`document.grants repeats email "${normalized}"`);
|
|
273
|
-
if (decision !== "waive_interview" && decision !== "require_interview") {
|
|
274
|
-
errors.push(`document.grants[${index}].decision is invalid`);
|
|
275
|
-
}
|
|
276
|
-
if (note && note.length > MAX_NOTE) errors.push(`document.grants[${index}].note is too long`);
|
|
277
|
-
if (expiresAt !== void 0 && (!Number.isFinite(expiresAt) || expiresAt <= 0)) {
|
|
278
|
-
errors.push(`document.grants[${index}].expiresAt must be epoch milliseconds`);
|
|
279
|
-
}
|
|
280
|
-
seenIds.add(id);
|
|
281
|
-
seenEmails.add(normalized);
|
|
282
|
-
grants.push({
|
|
283
|
-
id,
|
|
284
|
-
email,
|
|
285
|
-
decision,
|
|
286
|
-
...note ? { note } : {},
|
|
287
|
-
...expiresAt !== void 0 ? { expiresAt } : {}
|
|
288
|
-
});
|
|
289
|
-
}
|
|
290
|
-
if (errors.length) return { ok: false, errors };
|
|
291
|
-
return {
|
|
292
|
-
ok: true,
|
|
293
|
-
value: {
|
|
294
|
-
schemaVersion: ADMISSION_GRANTS_SCHEMA_VERSION,
|
|
295
|
-
revision,
|
|
296
|
-
generatedAt,
|
|
297
|
-
source: { appId: sourceId, actor: sourceActor },
|
|
298
|
-
target: { chapterId, environment },
|
|
299
|
-
grants
|
|
300
|
-
}
|
|
301
|
-
};
|
|
302
|
-
}
|
|
303
|
-
var rows = (value) => Array.isArray(value) ? value : [];
|
|
304
|
-
function asGrant(row) {
|
|
305
|
-
if (typeof row.id !== "string" || typeof row.emailNormalized !== "string") return null;
|
|
306
|
-
if (row.sourceScope !== "local" && row.sourceScope !== "parent") return null;
|
|
307
|
-
if (row.decision !== "waive_interview" && row.decision !== "require_interview") return null;
|
|
308
|
-
return row;
|
|
309
|
-
}
|
|
310
|
-
async function findAdmissionGrant(db, targetId, email, now) {
|
|
311
|
-
const emailNormalized = normalizeAdmissionEmail(email);
|
|
312
|
-
if (!isAdmissionEmail(emailNormalized)) return null;
|
|
313
|
-
const result = await db.query({ admissionGrants: { $: { where: { emailNormalized }, limit: 100 } } });
|
|
314
|
-
const active = rows(result.admissionGrants).map(asGrant).filter((grant) => Boolean(
|
|
315
|
-
grant && grant.targetId === targetId && grant.active === true && !grant.revokedAt && (!grant.expiresAt || grant.expiresAt > now) && !grant.consumedApplicationId
|
|
316
|
-
)).sort((a, b) => {
|
|
317
|
-
const scope = Number(b.sourceScope === "local") - Number(a.sourceScope === "local");
|
|
318
|
-
return scope || b.createdAt - a.createdAt;
|
|
319
|
-
});
|
|
320
|
-
return active[0] ?? null;
|
|
321
|
-
}
|
|
322
|
-
async function hasAdmissionRevision(db, sourceId, targetId, environment) {
|
|
323
|
-
const where = { sourceId, targetId, environment };
|
|
324
|
-
if (db.aggregate) {
|
|
325
|
-
return Number((await db.aggregate("admissionGrantRevisions", { count: true }, { where })).count ?? 0) > 0;
|
|
326
|
-
}
|
|
327
|
-
const found = await db.query({ admissionGrantRevisions: {} });
|
|
328
|
-
return rows(found.admissionGrantRevisions).some((row) => row.sourceId === sourceId && row.targetId === targetId && row.environment === environment);
|
|
329
|
-
}
|
|
330
|
-
async function loadLatestAdmissionRevision(db, sourceId, targetId, environment) {
|
|
331
|
-
if (!await hasAdmissionRevision(db, sourceId, targetId, environment)) return null;
|
|
332
|
-
const result = await db.query({ admissionGrantRevisions: { $: {
|
|
333
|
-
where: { sourceId, targetId, environment },
|
|
334
|
-
order: { revision: "desc" },
|
|
335
|
-
limit: 1
|
|
336
|
-
} } });
|
|
337
|
-
const row = rows(result.admissionGrantRevisions).sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0];
|
|
338
|
-
return row ? row : null;
|
|
339
|
-
}
|
|
340
|
-
async function materializeAdmissionGrants(db, envelope, previous, receivedAt) {
|
|
341
|
-
const { document, digest } = envelope;
|
|
342
|
-
const current = rows((await db.query({ admissionGrants: { $: {
|
|
343
|
-
where: { sourceScope: "parent", sourceId: document.source.appId, targetId: document.target.chapterId },
|
|
344
|
-
limit: MAX_GRANTS
|
|
345
|
-
} } })).admissionGrants);
|
|
346
|
-
const incomingIds = new Set(document.grants.map((grant) => `admission:parent:${document.source.appId}:${grant.id}`));
|
|
347
|
-
const ops = [];
|
|
348
|
-
for (const row of current) {
|
|
349
|
-
const id = String(row.id ?? "");
|
|
350
|
-
if (id && !incomingIds.has(id)) ops.push({ t: "update", ns: "admissionGrants", id, attrs: {
|
|
351
|
-
active: false,
|
|
352
|
-
revokedAt: receivedAt,
|
|
353
|
-
revokedBy: document.source.actor,
|
|
354
|
-
updatedAt: receivedAt,
|
|
355
|
-
updatedBy: document.source.actor
|
|
356
|
-
} });
|
|
357
|
-
}
|
|
358
|
-
for (const grant of document.grants) {
|
|
359
|
-
const id = `admission:parent:${document.source.appId}:${grant.id}`;
|
|
360
|
-
const existing = current.find((row) => row.id === id);
|
|
361
|
-
ops.push({ t: "update", ns: "admissionGrants", id, attrs: {
|
|
362
|
-
id,
|
|
363
|
-
sourceGrantId: grant.id,
|
|
364
|
-
sourceScope: "parent",
|
|
365
|
-
sourceId: document.source.appId,
|
|
366
|
-
targetId: document.target.chapterId,
|
|
367
|
-
email: grant.email,
|
|
368
|
-
emailNormalized: normalizeAdmissionEmail(grant.email),
|
|
369
|
-
decision: grant.decision,
|
|
370
|
-
...grant.note ? { note: grant.note } : {},
|
|
371
|
-
...grant.expiresAt ? { expiresAt: grant.expiresAt } : {},
|
|
372
|
-
active: true,
|
|
373
|
-
sourceRevision: document.revision,
|
|
374
|
-
createdAt: Number(existing?.createdAt ?? receivedAt),
|
|
375
|
-
createdBy: String(existing?.createdBy ?? document.source.actor),
|
|
376
|
-
updatedAt: receivedAt,
|
|
377
|
-
updatedBy: document.source.actor
|
|
378
|
-
} });
|
|
379
|
-
const retract = [
|
|
380
|
-
...!grant.note && existing?.note !== void 0 ? ["note"] : [],
|
|
381
|
-
...grant.expiresAt === void 0 && existing?.expiresAt !== void 0 ? ["expiresAt"] : []
|
|
382
|
-
];
|
|
383
|
-
if (retract.length) ops.push({ t: "retract", ns: "admissionGrants", id, attrs: retract });
|
|
384
|
-
}
|
|
385
|
-
if (previous?.active) ops.push({ t: "update", ns: "admissionGrantRevisions", id: previous.id, attrs: { active: false } });
|
|
386
|
-
const revisionId = `admission-revision:${document.source.appId}:${document.target.environment}:${document.revision}`;
|
|
387
|
-
ops.push({ t: "update", ns: "admissionGrantRevisions", id: revisionId, attrs: {
|
|
388
|
-
id: revisionId,
|
|
389
|
-
sourceId: document.source.appId,
|
|
390
|
-
targetId: document.target.chapterId,
|
|
391
|
-
environment: document.target.environment,
|
|
392
|
-
revision: document.revision,
|
|
393
|
-
digest,
|
|
394
|
-
active: true,
|
|
395
|
-
receivedAt
|
|
396
|
-
} });
|
|
397
|
-
await db.transact(ops, { mutationId: `admission-grants:${document.source.appId}:${document.target.environment}:${document.revision}:${digest}` });
|
|
398
|
-
}
|
|
399
206
|
|
|
400
207
|
// src/member.ts
|
|
401
208
|
var DEFAULT_REQUIRED = ["firstName", "lastName", "email", "referral", "whoYouAre", "message"];
|
|
@@ -448,9 +255,9 @@ function resolveApplication(a) {
|
|
|
448
255
|
validateEmail: a?.validateEmail ?? true
|
|
449
256
|
};
|
|
450
257
|
}
|
|
451
|
-
var
|
|
258
|
+
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
452
259
|
function isValidEmail(value) {
|
|
453
|
-
return typeof value === "string" &&
|
|
260
|
+
return typeof value === "string" && EMAIL_RE.test(value);
|
|
454
261
|
}
|
|
455
262
|
function clampArray(value, max) {
|
|
456
263
|
if (!Array.isArray(value)) return value;
|
|
@@ -462,14 +269,14 @@ function hasDisclaimerAck(fields) {
|
|
|
462
269
|
var IDENTITY_FIELDS = /* @__PURE__ */ new Set(["email", "firstName", "lastName"]);
|
|
463
270
|
function applicantProfile(chapter, fields) {
|
|
464
271
|
const app = chapter.application;
|
|
465
|
-
const
|
|
272
|
+
const allowed2 = (f) => app.profileFields.includes(f);
|
|
466
273
|
const profile = {};
|
|
467
274
|
for (const f of [...app.required, ...app.optional]) {
|
|
468
|
-
if (IDENTITY_FIELDS.has(f) || !
|
|
275
|
+
if (IDENTITY_FIELDS.has(f) || !allowed2(f)) continue;
|
|
469
276
|
const v = fields[f];
|
|
470
277
|
if (typeof v === "string" && v.trim() !== "") profile[f] = v.trim();
|
|
471
278
|
}
|
|
472
|
-
if (fields.focus !== void 0 &&
|
|
279
|
+
if (fields.focus !== void 0 && allowed2("focus")) profile.focus = clampArray(fields.focus, app.maxArrayLen);
|
|
473
280
|
return Object.keys(profile).length > 0 ? profile : void 0;
|
|
474
281
|
}
|
|
475
282
|
async function submitApplication(db, chapter, fields, opts) {
|
|
@@ -485,7 +292,7 @@ async function submitApplication(db, chapter, fields, opts) {
|
|
|
485
292
|
const cap = app.maxLen[f] ?? app.defaultMaxLen;
|
|
486
293
|
if (typeof v === "string" && v.length > cap) return { ok: false, error: `${f} exceeds ${cap} characters` };
|
|
487
294
|
}
|
|
488
|
-
if (app.validateEmail && typeof fields.email === "string" && !isValidEmail(fields.email)) {
|
|
295
|
+
if (app.validateEmail && typeof fields.email === "string" && !isValidEmail(fields.email.trim())) {
|
|
489
296
|
return { ok: false, error: "email must be a valid email address" };
|
|
490
297
|
}
|
|
491
298
|
const acked = hasDisclaimerAck(fields);
|
|
@@ -501,7 +308,7 @@ async function submitApplication(db, chapter, fields, opts) {
|
|
|
501
308
|
if (fields.focus !== void 0) row.focus = clampArray(fields.focus, app.maxArrayLen);
|
|
502
309
|
if (opts.groupId) row.groupId = opts.groupId;
|
|
503
310
|
if (typeof fields.email === "string" && isValidEmail(fields.email.trim())) {
|
|
504
|
-
row.emailNormalized =
|
|
311
|
+
row.emailNormalized = normalizeChapterEmail(fields.email);
|
|
505
312
|
}
|
|
506
313
|
if (opts.admissionGrant?.decision === "waive_interview") {
|
|
507
314
|
row.admissionGrantId = opts.admissionGrant.id;
|
|
@@ -718,14 +525,289 @@ function meetingCreateRow(i) {
|
|
|
718
525
|
createdAt: i.createdAt
|
|
719
526
|
};
|
|
720
527
|
}
|
|
721
|
-
function meetingRescheduleUpdate(startAt, endAt) {
|
|
722
|
-
return { startAt, endAt, drift: "none" };
|
|
528
|
+
function meetingRescheduleUpdate(startAt, endAt) {
|
|
529
|
+
return { startAt, endAt, drift: "none" };
|
|
530
|
+
}
|
|
531
|
+
function applicationBookingUpdate(currentStatus, startAt, htmlLink) {
|
|
532
|
+
return {
|
|
533
|
+
meetingAt: startAt,
|
|
534
|
+
...htmlLink ? { meetingLink: htmlLink } : {},
|
|
535
|
+
...currentStatus !== "call_scheduled" ? { status: "call_scheduled" } : {}
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// src/admission-grants-contract.ts
|
|
540
|
+
var ADMISSION_GRANTS_SCHEMA_VERSION = 1;
|
|
541
|
+
var ADMISSION_GRANTS_PATH = "/api/admission-grants";
|
|
542
|
+
|
|
543
|
+
// src/signup-control-canonical.ts
|
|
544
|
+
var encoder = new TextEncoder();
|
|
545
|
+
function canonicalValue(value) {
|
|
546
|
+
if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
|
|
547
|
+
return JSON.stringify(value);
|
|
548
|
+
}
|
|
549
|
+
if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
|
|
550
|
+
if (typeof value !== "object") throw new TypeError("signup control contains an unsupported value");
|
|
551
|
+
const record3 = value;
|
|
552
|
+
const entries = Object.keys(record3).sort().flatMap((key) => {
|
|
553
|
+
const item = record3[key];
|
|
554
|
+
return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue(item)}`];
|
|
555
|
+
});
|
|
556
|
+
return `{${entries.join(",")}}`;
|
|
557
|
+
}
|
|
558
|
+
function canonicalSignupControl(value) {
|
|
559
|
+
return canonicalValue(value);
|
|
560
|
+
}
|
|
561
|
+
async function signupControlDigest(document) {
|
|
562
|
+
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(canonicalSignupControl(document)));
|
|
563
|
+
return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// src/admission-grants.ts
|
|
567
|
+
var ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
|
|
568
|
+
var MAX_GRANTS = 2e3;
|
|
569
|
+
var MAX_NOTE = 2e3;
|
|
570
|
+
var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
571
|
+
var isAdmissionEmail = (value) => EMAIL_RE2.test(value);
|
|
572
|
+
function normalizeAdmissionEmail(email) {
|
|
573
|
+
return normalizeChapterEmail(email);
|
|
574
|
+
}
|
|
575
|
+
function validateAdmissionGrantDocument(input) {
|
|
576
|
+
const errors = [];
|
|
577
|
+
const root = input && typeof input === "object" ? input : {};
|
|
578
|
+
if (root.schemaVersion !== ADMISSION_GRANTS_SCHEMA_VERSION) errors.push("document.schemaVersion must be 1");
|
|
579
|
+
const revision = Number(root.revision);
|
|
580
|
+
const generatedAt = Number(root.generatedAt);
|
|
581
|
+
if (!Number.isSafeInteger(revision) || revision < 1) errors.push("document.revision must be a positive integer");
|
|
582
|
+
if (!Number.isFinite(generatedAt) || generatedAt <= 0) errors.push("document.generatedAt must be epoch milliseconds");
|
|
583
|
+
const source = root.source && typeof root.source === "object" ? root.source : {};
|
|
584
|
+
const target = root.target && typeof root.target === "object" ? root.target : {};
|
|
585
|
+
const sourceId = typeof source.appId === "string" ? source.appId : "";
|
|
586
|
+
const sourceActor = typeof source.actor === "string" ? source.actor.trim() : "";
|
|
587
|
+
const chapterId = typeof target.chapterId === "string" ? target.chapterId : "";
|
|
588
|
+
const environment = typeof target.environment === "string" ? target.environment : "";
|
|
589
|
+
if (!ID.test(sourceId)) errors.push("document.source.appId must be an identifier");
|
|
590
|
+
if (!sourceActor || sourceActor.length > 256) errors.push("document.source.actor must be 1-256 characters");
|
|
591
|
+
if (!ID.test(chapterId)) errors.push("document.target.chapterId must be an identifier");
|
|
592
|
+
if (!ID.test(environment)) errors.push("document.target.environment must be an identifier");
|
|
593
|
+
if (!Array.isArray(root.grants)) errors.push("document.grants must be an array");
|
|
594
|
+
const rawGrants = Array.isArray(root.grants) ? root.grants : [];
|
|
595
|
+
if (rawGrants.length > MAX_GRANTS) errors.push(`document.grants supports at most ${MAX_GRANTS} entries`);
|
|
596
|
+
const grants = [];
|
|
597
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
598
|
+
const seenEmails = /* @__PURE__ */ new Set();
|
|
599
|
+
for (const [index, raw] of rawGrants.slice(0, MAX_GRANTS).entries()) {
|
|
600
|
+
const grant = raw && typeof raw === "object" ? raw : {};
|
|
601
|
+
const id = typeof grant.id === "string" ? grant.id : "";
|
|
602
|
+
const email = typeof grant.email === "string" ? grant.email.trim() : "";
|
|
603
|
+
const normalized = normalizeAdmissionEmail(email);
|
|
604
|
+
const decision = grant.decision;
|
|
605
|
+
const note = typeof grant.note === "string" ? grant.note.trim() : void 0;
|
|
606
|
+
const expiresAt = grant.expiresAt === void 0 ? void 0 : Number(grant.expiresAt);
|
|
607
|
+
if (!ID.test(id)) errors.push(`document.grants[${index}].id must be an identifier`);
|
|
608
|
+
if (seenIds.has(id)) errors.push(`document.grants repeats id "${id}"`);
|
|
609
|
+
if (!isAdmissionEmail(normalized)) errors.push(`document.grants[${index}].email must be valid`);
|
|
610
|
+
if (seenEmails.has(normalized)) errors.push(`document.grants repeats email "${normalized}"`);
|
|
611
|
+
if (decision !== "waive_interview" && decision !== "require_interview") {
|
|
612
|
+
errors.push(`document.grants[${index}].decision is invalid`);
|
|
613
|
+
}
|
|
614
|
+
if (note && note.length > MAX_NOTE) errors.push(`document.grants[${index}].note is too long`);
|
|
615
|
+
if (expiresAt !== void 0 && (!Number.isFinite(expiresAt) || expiresAt <= 0)) {
|
|
616
|
+
errors.push(`document.grants[${index}].expiresAt must be epoch milliseconds`);
|
|
617
|
+
}
|
|
618
|
+
seenIds.add(id);
|
|
619
|
+
seenEmails.add(normalized);
|
|
620
|
+
grants.push({
|
|
621
|
+
id,
|
|
622
|
+
email,
|
|
623
|
+
decision,
|
|
624
|
+
...note ? { note } : {},
|
|
625
|
+
...expiresAt !== void 0 ? { expiresAt } : {}
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
if (errors.length) return { ok: false, errors };
|
|
629
|
+
return {
|
|
630
|
+
ok: true,
|
|
631
|
+
value: {
|
|
632
|
+
schemaVersion: ADMISSION_GRANTS_SCHEMA_VERSION,
|
|
633
|
+
revision,
|
|
634
|
+
generatedAt,
|
|
635
|
+
source: { appId: sourceId, actor: sourceActor },
|
|
636
|
+
target: { chapterId, environment },
|
|
637
|
+
grants
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
var rows = (value) => Array.isArray(value) ? value : [];
|
|
642
|
+
function asGrant(row) {
|
|
643
|
+
if (typeof row.id !== "string" || typeof row.emailNormalized !== "string") return null;
|
|
644
|
+
if (row.sourceScope !== "local" && row.sourceScope !== "parent") return null;
|
|
645
|
+
if (row.decision !== "waive_interview" && row.decision !== "require_interview") return null;
|
|
646
|
+
return row;
|
|
647
|
+
}
|
|
648
|
+
async function findAdmissionGrant(db, targetId, email, now) {
|
|
649
|
+
const emailNormalized = normalizeAdmissionEmail(email);
|
|
650
|
+
if (!isAdmissionEmail(emailNormalized)) return null;
|
|
651
|
+
const result = await db.query({ admissionGrants: { $: { where: { emailNormalized }, limit: 100 } } });
|
|
652
|
+
const active = rows(result.admissionGrants).map(asGrant).filter((grant) => Boolean(
|
|
653
|
+
grant && grant.targetId === targetId && grant.active === true && !grant.revokedAt && (!grant.expiresAt || grant.expiresAt > now) && !grant.consumedApplicationId
|
|
654
|
+
)).sort((a, b) => {
|
|
655
|
+
const scope = Number(b.sourceScope === "local") - Number(a.sourceScope === "local");
|
|
656
|
+
return scope || b.createdAt - a.createdAt;
|
|
657
|
+
});
|
|
658
|
+
return active[0] ?? null;
|
|
659
|
+
}
|
|
660
|
+
async function hasAdmissionRevision(db, sourceId, targetId, environment) {
|
|
661
|
+
const where = { sourceId, targetId, environment };
|
|
662
|
+
if (db.aggregate) {
|
|
663
|
+
return Number((await db.aggregate("admissionGrantRevisions", { count: true }, { where })).count ?? 0) > 0;
|
|
664
|
+
}
|
|
665
|
+
const found = await db.query({ admissionGrantRevisions: {} });
|
|
666
|
+
return rows(found.admissionGrantRevisions).some((row) => row.sourceId === sourceId && row.targetId === targetId && row.environment === environment);
|
|
667
|
+
}
|
|
668
|
+
async function loadLatestAdmissionRevision(db, sourceId, targetId, environment) {
|
|
669
|
+
if (!await hasAdmissionRevision(db, sourceId, targetId, environment)) return null;
|
|
670
|
+
const result = await db.query({ admissionGrantRevisions: { $: {
|
|
671
|
+
where: { sourceId, targetId, environment },
|
|
672
|
+
order: { revision: "desc" },
|
|
673
|
+
limit: 1
|
|
674
|
+
} } });
|
|
675
|
+
const row = rows(result.admissionGrantRevisions).sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0];
|
|
676
|
+
return row ? row : null;
|
|
677
|
+
}
|
|
678
|
+
async function materializeAdmissionGrants(db, envelope, previous, receivedAt) {
|
|
679
|
+
const { document, digest } = envelope;
|
|
680
|
+
const current = rows((await db.query({ admissionGrants: { $: {
|
|
681
|
+
where: { sourceScope: "parent", sourceId: document.source.appId, targetId: document.target.chapterId },
|
|
682
|
+
limit: MAX_GRANTS
|
|
683
|
+
} } })).admissionGrants);
|
|
684
|
+
const incomingIds = new Set(document.grants.map((grant) => `admission:parent:${document.source.appId}:${grant.id}`));
|
|
685
|
+
const ops = [];
|
|
686
|
+
for (const row of current) {
|
|
687
|
+
const id = String(row.id ?? "");
|
|
688
|
+
if (id && !incomingIds.has(id)) ops.push({ t: "update", ns: "admissionGrants", id, attrs: {
|
|
689
|
+
active: false,
|
|
690
|
+
revokedAt: receivedAt,
|
|
691
|
+
revokedBy: document.source.actor,
|
|
692
|
+
updatedAt: receivedAt,
|
|
693
|
+
updatedBy: document.source.actor
|
|
694
|
+
} });
|
|
695
|
+
}
|
|
696
|
+
for (const grant of document.grants) {
|
|
697
|
+
const id = `admission:parent:${document.source.appId}:${grant.id}`;
|
|
698
|
+
const existing = current.find((row) => row.id === id);
|
|
699
|
+
ops.push({ t: "update", ns: "admissionGrants", id, attrs: {
|
|
700
|
+
id,
|
|
701
|
+
sourceGrantId: grant.id,
|
|
702
|
+
sourceScope: "parent",
|
|
703
|
+
sourceId: document.source.appId,
|
|
704
|
+
targetId: document.target.chapterId,
|
|
705
|
+
email: grant.email,
|
|
706
|
+
emailNormalized: normalizeAdmissionEmail(grant.email),
|
|
707
|
+
decision: grant.decision,
|
|
708
|
+
...grant.note ? { note: grant.note } : {},
|
|
709
|
+
...grant.expiresAt ? { expiresAt: grant.expiresAt } : {},
|
|
710
|
+
active: true,
|
|
711
|
+
sourceRevision: document.revision,
|
|
712
|
+
createdAt: Number(existing?.createdAt ?? receivedAt),
|
|
713
|
+
createdBy: String(existing?.createdBy ?? document.source.actor),
|
|
714
|
+
updatedAt: receivedAt,
|
|
715
|
+
updatedBy: document.source.actor
|
|
716
|
+
} });
|
|
717
|
+
const retract = [
|
|
718
|
+
...!grant.note && existing?.note !== void 0 ? ["note"] : [],
|
|
719
|
+
...grant.expiresAt === void 0 && existing?.expiresAt !== void 0 ? ["expiresAt"] : []
|
|
720
|
+
];
|
|
721
|
+
if (retract.length) ops.push({ t: "retract", ns: "admissionGrants", id, attrs: retract });
|
|
722
|
+
}
|
|
723
|
+
if (previous?.active) ops.push({ t: "update", ns: "admissionGrantRevisions", id: previous.id, attrs: { active: false } });
|
|
724
|
+
const revisionId = `admission-revision:${document.source.appId}:${document.target.environment}:${document.revision}`;
|
|
725
|
+
ops.push({ t: "update", ns: "admissionGrantRevisions", id: revisionId, attrs: {
|
|
726
|
+
id: revisionId,
|
|
727
|
+
sourceId: document.source.appId,
|
|
728
|
+
targetId: document.target.chapterId,
|
|
729
|
+
environment: document.target.environment,
|
|
730
|
+
revision: document.revision,
|
|
731
|
+
digest,
|
|
732
|
+
active: true,
|
|
733
|
+
receivedAt
|
|
734
|
+
} });
|
|
735
|
+
await db.transact(ops, { mutationId: `admission-grants:${document.source.appId}:${document.target.environment}:${document.revision}:${digest}` });
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// src/flow-fields.ts
|
|
739
|
+
var CHAPTER_FLOW_FIELDS_SCHEMA_VERSION = 1;
|
|
740
|
+
var APPLICATION_LABELS = {
|
|
741
|
+
firstName: "First name",
|
|
742
|
+
lastName: "Last name",
|
|
743
|
+
email: "Email",
|
|
744
|
+
referral: "Referral source",
|
|
745
|
+
referralName: "Referral name",
|
|
746
|
+
whoYouAre: "Who you are",
|
|
747
|
+
linkedin: "LinkedIn",
|
|
748
|
+
message: "Intro message",
|
|
749
|
+
phone: "Phone",
|
|
750
|
+
state: "State"
|
|
751
|
+
};
|
|
752
|
+
function humanize(id) {
|
|
753
|
+
const text3 = id.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim();
|
|
754
|
+
return text3 ? `${text3[0].toUpperCase()}${text3.slice(1)}` : id;
|
|
755
|
+
}
|
|
756
|
+
function applicationDefs(chapter) {
|
|
757
|
+
try {
|
|
758
|
+
return chapter.crm.type("person").fields;
|
|
759
|
+
} catch {
|
|
760
|
+
return {};
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
function applicationType(id, def) {
|
|
764
|
+
if (def && ["string", "email", "url", "enum"].includes(def.type)) return def.type;
|
|
765
|
+
return id === "email" ? "email" : id === "linkedin" ? "url" : "string";
|
|
766
|
+
}
|
|
767
|
+
function applicationFlowFields(chapter, application = chapter.application) {
|
|
768
|
+
const defs = applicationDefs(chapter);
|
|
769
|
+
return [...application.required, ...application.optional].map((id, order) => {
|
|
770
|
+
const def = defs[id];
|
|
771
|
+
const conditions = application.conditions[id];
|
|
772
|
+
const type = applicationType(id, def);
|
|
773
|
+
return {
|
|
774
|
+
id,
|
|
775
|
+
label: def?.label ?? APPLICATION_LABELS[id] ?? humanize(id),
|
|
776
|
+
type,
|
|
777
|
+
required: application.required.includes(id),
|
|
778
|
+
order,
|
|
779
|
+
maxLength: application.maxLen[id] ?? application.defaultMaxLen,
|
|
780
|
+
...type === "enum" && def?.options ? { options: [...def.options] } : {},
|
|
781
|
+
...conditions ? { conditions: { ...conditions } } : {},
|
|
782
|
+
...type === "email" ? { emailIdentity: "literal" } : {}
|
|
783
|
+
};
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
function resolveFormationFlowFields(crm, formation) {
|
|
787
|
+
if (!formation.enabled) return [];
|
|
788
|
+
const def = crm.type(formation.type);
|
|
789
|
+
return [...formation.required, ...formation.optional].map((id, order) => {
|
|
790
|
+
const field = def.fields[id];
|
|
791
|
+
return {
|
|
792
|
+
id,
|
|
793
|
+
label: field.label ?? humanize(id),
|
|
794
|
+
type: field.type,
|
|
795
|
+
required: formation.required.includes(id),
|
|
796
|
+
order,
|
|
797
|
+
maxLength: formation.maxLen[id] ?? formation.defaultMaxLen,
|
|
798
|
+
...field.options ? { options: [...field.options] } : {},
|
|
799
|
+
...id === def.emailField || field.type === "email" ? { emailIdentity: "literal" } : {}
|
|
800
|
+
};
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
function formationFlowFields(chapter, formation = chapter.formation) {
|
|
804
|
+
return resolveFormationFlowFields(chapter.crm, formation);
|
|
723
805
|
}
|
|
724
|
-
function
|
|
806
|
+
function chapterFlowFieldContract(chapter, flow, application = chapter.application) {
|
|
725
807
|
return {
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
808
|
+
schemaVersion: CHAPTER_FLOW_FIELDS_SCHEMA_VERSION,
|
|
809
|
+
flow,
|
|
810
|
+
fields: flow === "join" ? applicationFlowFields(chapter, application) : formationFlowFields(chapter)
|
|
729
811
|
};
|
|
730
812
|
}
|
|
731
813
|
|
|
@@ -762,8 +844,8 @@ function validateGiftOfferCatalog(input, tierIds) {
|
|
|
762
844
|
errors.push(`${path} must be an object`);
|
|
763
845
|
continue;
|
|
764
846
|
}
|
|
765
|
-
const
|
|
766
|
-
for (const key of Object.keys(item)) if (!
|
|
847
|
+
const allowed2 = /* @__PURE__ */ new Set(["id", "target", "billingMode", "stripePriceId", "priceCents", "currency", "redemptionLifetimeHours", "activeFrom", "activeUntil", "title", "description", "ctaLabel"]);
|
|
848
|
+
for (const key of Object.keys(item)) if (!allowed2.has(key)) errors.push(`${path}.${key} is not supported`);
|
|
767
849
|
const id = text(item.id, `${path}.id`, errors, 80);
|
|
768
850
|
if (id && !ID2.test(id)) errors.push(`${path}.id must be a lowercase kebab-case id`);
|
|
769
851
|
if (ids.has(id)) errors.push(`${path}.id duplicates "${id}"`);
|
|
@@ -860,8 +942,8 @@ async function clerkListUsers(secretKey, fetchImpl = fetch) {
|
|
|
860
942
|
const data = await clerkGet(`/v1/users?limit=${PAGE}&offset=${offset}`, secretKey, fetchImpl);
|
|
861
943
|
const page = Array.isArray(data) ? data : [];
|
|
862
944
|
for (const u of page) {
|
|
863
|
-
const
|
|
864
|
-
if (
|
|
945
|
+
const record3 = toRecord(u);
|
|
946
|
+
if (record3) out.push(record3);
|
|
865
947
|
}
|
|
866
948
|
if (page.length < PAGE) break;
|
|
867
949
|
}
|
|
@@ -918,34 +1000,34 @@ function networkSourceTag(type, hubRecordId, sourceId) {
|
|
|
918
1000
|
if (readable && raw.length <= 64) return raw;
|
|
919
1001
|
return `network:${typeKey.slice(0, 16)}:${shortHash(`${sourceId ?? ""}\0${type}\0${hubRecordId}`)}`;
|
|
920
1002
|
}
|
|
921
|
-
function normalizeSharedRecord(
|
|
922
|
-
if ("input" in
|
|
1003
|
+
function normalizeSharedRecord(record3, fallbackSourceId = "legacy-source") {
|
|
1004
|
+
if ("input" in record3 && record3.version === 2) {
|
|
923
1005
|
return {
|
|
924
1006
|
version: 2,
|
|
925
|
-
sourceId:
|
|
926
|
-
sourceRecordId:
|
|
927
|
-
type:
|
|
928
|
-
input:
|
|
1007
|
+
sourceId: record3.source.siteId,
|
|
1008
|
+
sourceRecordId: record3.source.recordId,
|
|
1009
|
+
type: record3.type,
|
|
1010
|
+
input: record3.input
|
|
929
1011
|
};
|
|
930
1012
|
}
|
|
931
|
-
if ("input" in
|
|
1013
|
+
if ("input" in record3) {
|
|
932
1014
|
return {
|
|
933
1015
|
version: 1,
|
|
934
1016
|
sourceId: fallbackSourceId,
|
|
935
|
-
sourceRecordId:
|
|
936
|
-
type:
|
|
937
|
-
input:
|
|
1017
|
+
sourceRecordId: record3.hubRecordId,
|
|
1018
|
+
type: record3.type,
|
|
1019
|
+
input: record3.input
|
|
938
1020
|
};
|
|
939
1021
|
}
|
|
940
|
-
if ("type" in
|
|
941
|
-
const input = { name:
|
|
1022
|
+
if ("type" in record3 && record3.type === "company") {
|
|
1023
|
+
const input = { name: record3.name };
|
|
942
1024
|
for (const key of ["domain", "industry", "location", "linkedin", "notes"]) {
|
|
943
|
-
if (
|
|
1025
|
+
if (record3[key]) input[key] = record3[key];
|
|
944
1026
|
}
|
|
945
1027
|
return {
|
|
946
1028
|
version: 1,
|
|
947
1029
|
sourceId: fallbackSourceId,
|
|
948
|
-
sourceRecordId:
|
|
1030
|
+
sourceRecordId: record3.hubRecordId,
|
|
949
1031
|
type: "company",
|
|
950
1032
|
input
|
|
951
1033
|
};
|
|
@@ -953,28 +1035,28 @@ function normalizeSharedRecord(record2, fallbackSourceId = "legacy-source") {
|
|
|
953
1035
|
return {
|
|
954
1036
|
version: 1,
|
|
955
1037
|
sourceId: fallbackSourceId,
|
|
956
|
-
sourceRecordId:
|
|
1038
|
+
sourceRecordId: record3.hubRecordId,
|
|
957
1039
|
type: "person",
|
|
958
|
-
input: sharedPersonInput(
|
|
1040
|
+
input: sharedPersonInput(record3)
|
|
959
1041
|
};
|
|
960
1042
|
}
|
|
961
|
-
function sharedRecordFromCrm(crm,
|
|
962
|
-
if (target.fields && !target.fields[
|
|
963
|
-
throw new Error(`${target.name} does not accept "${
|
|
1043
|
+
function sharedRecordFromCrm(crm, record3, target, sourceId) {
|
|
1044
|
+
if (target.fields && !target.fields[record3.type]) {
|
|
1045
|
+
throw new Error(`${target.name} does not accept "${record3.type}" records`);
|
|
964
1046
|
}
|
|
965
|
-
const def = crm.type(
|
|
966
|
-
const fields = target.fields?.[
|
|
1047
|
+
const def = crm.type(record3.type);
|
|
1048
|
+
const fields = target.fields?.[record3.type] ?? DEFAULT_SHARE_FIELDS[record3.type];
|
|
967
1049
|
if (!fields) {
|
|
968
|
-
throw new Error(`${target.name} requires an explicit field allowlist for "${
|
|
1050
|
+
throw new Error(`${target.name} requires an explicit field allowlist for "${record3.type}" records`);
|
|
969
1051
|
}
|
|
970
1052
|
const nameField = def.nameField ?? "name";
|
|
971
1053
|
const input = {};
|
|
972
1054
|
for (const field of /* @__PURE__ */ new Set([nameField, ...fields])) {
|
|
973
|
-
const value =
|
|
1055
|
+
const value = record3.fields?.[field];
|
|
974
1056
|
if (value !== void 0) input[field] = value;
|
|
975
1057
|
}
|
|
976
|
-
if (input[nameField] === void 0) input[nameField] =
|
|
977
|
-
return sourceId ? { version: 2, source: { siteId: sourceId, recordId:
|
|
1058
|
+
if (input[nameField] === void 0) input[nameField] = record3.name;
|
|
1059
|
+
return sourceId ? { version: 2, source: { siteId: sourceId, recordId: record3.id }, type: record3.type, input } : { version: 1, type: record3.type, hubRecordId: record3.id, input };
|
|
978
1060
|
}
|
|
979
1061
|
async function upsertPerson(deps, opts) {
|
|
980
1062
|
const email = opts.email.toLowerCase();
|
|
@@ -988,11 +1070,11 @@ async function upsertPerson(deps, opts) {
|
|
|
988
1070
|
const created = await (0, import_crm2.createRecord)(crmDeps3, { type: "person", input: opts.input, mutationId: opts.mutationId });
|
|
989
1071
|
return { recordId: created.id };
|
|
990
1072
|
}
|
|
991
|
-
async function findSharedRecord(deps,
|
|
1073
|
+
async function findSharedRecord(deps, record3, tag) {
|
|
992
1074
|
const structured = await (0, import_crm2.getRecordByOrigin)(
|
|
993
1075
|
{ crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId },
|
|
994
|
-
|
|
995
|
-
|
|
1076
|
+
record3.sourceId,
|
|
1077
|
+
record3.sourceRecordId
|
|
996
1078
|
);
|
|
997
1079
|
if (structured) return structured;
|
|
998
1080
|
const mapped = await deps.db.query({ crm_tag: { $: { where: { tag }, limit: 1 } } });
|
|
@@ -1001,53 +1083,53 @@ async function findSharedRecord(deps, record2, tag) {
|
|
|
1001
1083
|
const found = await deps.db.query({ crm_record: { $: { where: { id: mappedId }, limit: 1 } } });
|
|
1002
1084
|
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
1003
1085
|
}
|
|
1004
|
-
const def = deps.crm.type(
|
|
1086
|
+
const def = deps.crm.type(record3.type);
|
|
1005
1087
|
const emailField = def.emailField;
|
|
1006
|
-
if (emailField && typeof
|
|
1007
|
-
const primaryEmail =
|
|
1088
|
+
if (emailField && typeof record3.input[emailField] === "string") {
|
|
1089
|
+
const primaryEmail = record3.input[emailField].toLowerCase();
|
|
1008
1090
|
const found = await deps.db.query({
|
|
1009
|
-
crm_record: { $: { where: { type:
|
|
1091
|
+
crm_record: { $: { where: { type: record3.type, primaryEmail }, limit: 1 } }
|
|
1010
1092
|
});
|
|
1011
1093
|
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
1012
1094
|
}
|
|
1013
|
-
const domain =
|
|
1095
|
+
const domain = record3.input.domain;
|
|
1014
1096
|
const domainSlot = def.fields.domain?.slot;
|
|
1015
1097
|
if (typeof domain === "string" && domainSlot) {
|
|
1016
1098
|
const found = await deps.db.query({
|
|
1017
|
-
crm_record: { $: { where: { type:
|
|
1099
|
+
crm_record: { $: { where: { type: record3.type, [domainSlot]: domain }, limit: 1 } }
|
|
1018
1100
|
});
|
|
1019
1101
|
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
1020
1102
|
}
|
|
1021
1103
|
const nameField = def.nameField ?? "name";
|
|
1022
|
-
const name =
|
|
1023
|
-
if (
|
|
1104
|
+
const name = record3.input[nameField];
|
|
1105
|
+
if (record3.type === "company" && typeof name === "string" && name.trim()) {
|
|
1024
1106
|
const found = await deps.db.query({
|
|
1025
|
-
crm_record: { $: { where: { type:
|
|
1107
|
+
crm_record: { $: { where: { type: record3.type, name: name.trim() }, limit: 1 } }
|
|
1026
1108
|
});
|
|
1027
1109
|
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
1028
1110
|
}
|
|
1029
1111
|
return void 0;
|
|
1030
1112
|
}
|
|
1031
1113
|
async function projectSharedRecord(deps, shared, options = {}) {
|
|
1032
|
-
const
|
|
1033
|
-
if (!
|
|
1114
|
+
const record3 = normalizeSharedRecord(shared, options.sourceId);
|
|
1115
|
+
if (!record3.type.trim() || !record3.sourceId.trim() || !record3.sourceRecordId.trim()) {
|
|
1034
1116
|
throw new Error("type, source site id, and source record id must be non-empty");
|
|
1035
1117
|
}
|
|
1036
|
-
if (options.sourceId && options.sourceId !==
|
|
1118
|
+
if (options.sourceId && options.sourceId !== record3.sourceId) {
|
|
1037
1119
|
throw new Error("signed sender does not match payload source");
|
|
1038
1120
|
}
|
|
1039
|
-
const tag = networkSourceTag(
|
|
1121
|
+
const tag = networkSourceTag(record3.type, record3.sourceRecordId, record3.version === 2 ? record3.sourceId : void 0);
|
|
1040
1122
|
const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
|
|
1041
|
-
const existing = await findSharedRecord(deps,
|
|
1123
|
+
const existing = await findSharedRecord(deps, record3, tag);
|
|
1042
1124
|
let recordId;
|
|
1043
1125
|
if (existing && typeof existing.id === "string") {
|
|
1044
|
-
await (0, import_crm2.updateRecord)(crmDeps3, { id: existing.id, input:
|
|
1126
|
+
await (0, import_crm2.updateRecord)(crmDeps3, { id: existing.id, input: record3.input });
|
|
1045
1127
|
recordId = existing.id;
|
|
1046
1128
|
} else {
|
|
1047
|
-
recordId = `network_${shortHash(`${
|
|
1129
|
+
recordId = `network_${shortHash(`${record3.sourceId}\0${record3.type}\0${record3.sourceRecordId}`)}`;
|
|
1048
1130
|
await (0, import_crm2.createRecord)({ ...crmDeps3, newId: () => recordId }, {
|
|
1049
|
-
type:
|
|
1050
|
-
input:
|
|
1131
|
+
type: record3.type,
|
|
1132
|
+
input: record3.input,
|
|
1051
1133
|
mutationId: `share-create:${tag}`
|
|
1052
1134
|
});
|
|
1053
1135
|
}
|
|
@@ -1057,10 +1139,10 @@ async function projectSharedRecord(deps, shared, options = {}) {
|
|
|
1057
1139
|
);
|
|
1058
1140
|
await (0, import_crm2.upsertRecordOrigin)(crmDeps3, {
|
|
1059
1141
|
recordId,
|
|
1060
|
-
sourceId:
|
|
1061
|
-
sourceRecordId:
|
|
1142
|
+
sourceId: record3.sourceId,
|
|
1143
|
+
sourceRecordId: record3.sourceRecordId,
|
|
1062
1144
|
...options.sourceUrl ? { sourceUrl: options.sourceUrl } : {},
|
|
1063
|
-
payloadVersion:
|
|
1145
|
+
payloadVersion: record3.version
|
|
1064
1146
|
});
|
|
1065
1147
|
return { recordId };
|
|
1066
1148
|
}
|
|
@@ -1509,7 +1591,8 @@ function applicationSummary(app) {
|
|
|
1509
1591
|
meetingLink: app.meetingLink ?? null,
|
|
1510
1592
|
paid: Boolean(app.stripeSubscriptionId) && app.status !== "refunded",
|
|
1511
1593
|
renewalAt: app.renewalAt ?? null,
|
|
1512
|
-
canceled: app.canceled === true
|
|
1594
|
+
canceled: app.canceled === true,
|
|
1595
|
+
tierId: app.tierId ?? null
|
|
1513
1596
|
};
|
|
1514
1597
|
}
|
|
1515
1598
|
function memberApplication(app, meeting, defaultTimezone) {
|
|
@@ -1733,7 +1816,7 @@ async function notifyAdminOfApplication(db, env, chapterId, applicationId, field
|
|
|
1733
1816
|
// src/worker-routes.ts
|
|
1734
1817
|
async function memberSessionApplication(db, chapterId, email) {
|
|
1735
1818
|
let apps = (await db.query({ applications: { $: {
|
|
1736
|
-
where: { emailNormalized:
|
|
1819
|
+
where: { emailNormalized: normalizeChapterEmail(email) },
|
|
1737
1820
|
limit: 20
|
|
1738
1821
|
} } })).applications;
|
|
1739
1822
|
if (!apps?.length) apps = (await db.query({ applications: { $: { where: { email }, limit: 20 } } })).applications;
|
|
@@ -1832,6 +1915,7 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
1832
1915
|
const application = controlledApplication(chapter.application, activeControl?.document);
|
|
1833
1916
|
return json({
|
|
1834
1917
|
...joinConfig(group, paymentsReady, joinConfigTiers(offered), application.conditions),
|
|
1918
|
+
fieldContract: chapterFlowFieldContract(chapter, "join", application),
|
|
1835
1919
|
copy: { ...chapter.copy.join, ...activeControl?.document.application?.copy ?? {} },
|
|
1836
1920
|
...activeControl ? { signupControl: {
|
|
1837
1921
|
revision: activeControl.revision,
|
|
@@ -1983,8 +2067,8 @@ var handleNetworkShared = async (req, url, env, ctx) => {
|
|
|
1983
2067
|
payload,
|
|
1984
2068
|
sourceId ? { sourceId } : {}
|
|
1985
2069
|
);
|
|
1986
|
-
const
|
|
1987
|
-
return json({ recordId, type:
|
|
2070
|
+
const record3 = normalizeSharedRecord(payload, sourceId);
|
|
2071
|
+
return json({ recordId, type: record3.type });
|
|
1988
2072
|
} catch (error) {
|
|
1989
2073
|
return json({
|
|
1990
2074
|
error: error instanceof Error ? error.message : "invalid shared record"
|
|
@@ -2024,9 +2108,9 @@ var MAX_BENEFITS = 24;
|
|
|
2024
2108
|
var MAX_MARKDOWN = 65536;
|
|
2025
2109
|
var MAX_LABEL = 160;
|
|
2026
2110
|
var objectRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
2027
|
-
function rejectUnknownKeys(value,
|
|
2111
|
+
function rejectUnknownKeys(value, allowed2, path, errors) {
|
|
2028
2112
|
for (const key of Object.keys(value)) {
|
|
2029
|
-
if (!
|
|
2113
|
+
if (!allowed2.has(key)) errors.push(`${path}.${key} is not supported`);
|
|
2030
2114
|
}
|
|
2031
2115
|
}
|
|
2032
2116
|
function boundedString(value, path, errors, max, required = false) {
|
|
@@ -2182,8 +2266,8 @@ var ROOT_KEYS2 = /* @__PURE__ */ new Set([
|
|
|
2182
2266
|
]);
|
|
2183
2267
|
var TIER_KEYS2 = /* @__PURE__ */ new Set(["id", "name", "available", "sortOrder", "priceCents", "stripePriceId", "blurb"]);
|
|
2184
2268
|
var recordOf = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
2185
|
-
function unknownKeys(value,
|
|
2186
|
-
for (const key of Object.keys(value)) if (!
|
|
2269
|
+
function unknownKeys(value, allowed2, path, errors) {
|
|
2270
|
+
for (const key of Object.keys(value)) if (!allowed2.has(key)) errors.push(`${path}.${key} is not supported`);
|
|
2187
2271
|
}
|
|
2188
2272
|
function text2(value, path, errors, max = 65536) {
|
|
2189
2273
|
if (typeof value !== "string" || !value.trim()) {
|
|
@@ -2268,10 +2352,10 @@ function policyOf(value, errors) {
|
|
|
2268
2352
|
errors.push("document.policy must be an object");
|
|
2269
2353
|
return void 0;
|
|
2270
2354
|
}
|
|
2271
|
-
const
|
|
2272
|
-
unknownKeys(input,
|
|
2355
|
+
const allowed2 = /* @__PURE__ */ new Set(["disclaimerText", "refundPolicyText", "trustCopy", "commitmentText", "normsText"]);
|
|
2356
|
+
unknownKeys(input, allowed2, "document.policy", errors);
|
|
2273
2357
|
const result = {};
|
|
2274
|
-
for (const key of
|
|
2358
|
+
for (const key of allowed2) {
|
|
2275
2359
|
if (input[key] === void 0 && key !== "disclaimerText" && key !== "refundPolicyText") continue;
|
|
2276
2360
|
result[key] = text2(input[key], `document.policy.${key}`, errors);
|
|
2277
2361
|
}
|
|
@@ -2410,9 +2494,589 @@ var handleSignupControl = async (req, url, env, ctx) => {
|
|
|
2410
2494
|
});
|
|
2411
2495
|
};
|
|
2412
2496
|
|
|
2413
|
-
// src/worker-routes-
|
|
2497
|
+
// src/worker-routes-member-content.ts
|
|
2414
2498
|
var import_db4 = require("@odla-ai/db");
|
|
2499
|
+
|
|
2500
|
+
// src/member-content-canonical.ts
|
|
2501
|
+
var encoder2 = new TextEncoder();
|
|
2502
|
+
function canonicalValue2(value) {
|
|
2503
|
+
if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
|
|
2504
|
+
return JSON.stringify(value);
|
|
2505
|
+
}
|
|
2506
|
+
if (Array.isArray(value)) return `[${value.map(canonicalValue2).join(",")}]`;
|
|
2507
|
+
if (typeof value !== "object") throw new TypeError("member content contains an unsupported value");
|
|
2508
|
+
const record3 = value;
|
|
2509
|
+
const entries = Object.keys(record3).sort().flatMap((key) => {
|
|
2510
|
+
const item = record3[key];
|
|
2511
|
+
return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue2(item)}`];
|
|
2512
|
+
});
|
|
2513
|
+
return `{${entries.join(",")}}`;
|
|
2514
|
+
}
|
|
2515
|
+
function canonicalMemberContent(value) {
|
|
2516
|
+
return canonicalValue2(value);
|
|
2517
|
+
}
|
|
2518
|
+
async function memberContentDigest(document) {
|
|
2519
|
+
const digest = await crypto.subtle.digest("SHA-256", encoder2.encode(canonicalMemberContent(document)));
|
|
2520
|
+
return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
// src/member-content-validation-helpers.ts
|
|
2524
|
+
var ID3 = /^[a-z][a-z0-9-]{0,79}$/;
|
|
2525
|
+
var SLUG2 = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
2526
|
+
var ROOT_KEYS3 = /* @__PURE__ */ new Set(["schemaVersion", "revision", "generatedAt", "source", "target", "pages", "upsellOverrides"]);
|
|
2527
|
+
var SOURCE_KEYS = /* @__PURE__ */ new Set(["type", "appId"]);
|
|
2528
|
+
var TARGET_KEYS = /* @__PURE__ */ new Set(["chapterId", "environment"]);
|
|
2529
|
+
var PAGE_KEYS = /* @__PURE__ */ new Set(["id", "slug", "title", "summary", "navigation", "audience", "teaserMarkdown", "bodyMarkdown", "upsell"]);
|
|
2530
|
+
var NAV_KEYS = /* @__PURE__ */ new Set(["section", "order", "parentId"]);
|
|
2531
|
+
var AUDIENCE_KEYS = /* @__PURE__ */ new Set(["kind", "tierIds"]);
|
|
2532
|
+
var UPSELL_KEYS = /* @__PURE__ */ new Set(["owner", "offer"]);
|
|
2533
|
+
var OFFER_KEYS = /* @__PURE__ */ new Set(["tierId", "label", "bodyMarkdown", "ctaLabel"]);
|
|
2534
|
+
var OVERRIDE_KEYS = /* @__PURE__ */ new Set(["pageId", "offer"]);
|
|
2535
|
+
var MAX_PAGES = 100;
|
|
2536
|
+
var MAX_MARKDOWN2 = 65536;
|
|
2537
|
+
var MAX_TEXT = 240;
|
|
2538
|
+
var record2 = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
2539
|
+
function unknownKeys2(value, allowed2, path, errors) {
|
|
2540
|
+
for (const key of Object.keys(value)) if (!allowed2.has(key)) errors.push(`${path}.${key} is not supported`);
|
|
2541
|
+
}
|
|
2542
|
+
function textValue(value, path, errors, max = MAX_TEXT, required = false) {
|
|
2543
|
+
if (value === void 0) {
|
|
2544
|
+
if (required) errors.push(`${path} is required`);
|
|
2545
|
+
return void 0;
|
|
2546
|
+
}
|
|
2547
|
+
if (typeof value !== "string") {
|
|
2548
|
+
errors.push(`${path} must be a string`);
|
|
2549
|
+
return void 0;
|
|
2550
|
+
}
|
|
2551
|
+
const valueText = value.trim();
|
|
2552
|
+
if (required && !valueText) errors.push(`${path} is required`);
|
|
2553
|
+
if (valueText.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
|
|
2554
|
+
return valueText;
|
|
2555
|
+
}
|
|
2556
|
+
function offerValue(value, path, errors, tiers, audience) {
|
|
2557
|
+
const raw = record2(value);
|
|
2558
|
+
if (!raw) {
|
|
2559
|
+
errors.push(`${path} must be an object`);
|
|
2560
|
+
return void 0;
|
|
2561
|
+
}
|
|
2562
|
+
unknownKeys2(raw, OFFER_KEYS, path, errors);
|
|
2563
|
+
const tierId = textValue(raw.tierId, `${path}.tierId`, errors, 80, true) ?? "";
|
|
2564
|
+
if (tierId && !ID3.test(tierId)) errors.push(`${path}.tierId must be a lowercase kebab-case id`);
|
|
2565
|
+
if (tiers.size && !tiers.has(tierId)) errors.push(`${path}.tierId must reference a declared tier`);
|
|
2566
|
+
if (audience?.kind === "tiers" && tierId && !audience.tierIds.includes(tierId)) {
|
|
2567
|
+
errors.push(`${path}.tierId must grant access to this page`);
|
|
2568
|
+
}
|
|
2569
|
+
const label = textValue(raw.label, `${path}.label`, errors);
|
|
2570
|
+
const bodyMarkdown = textValue(raw.bodyMarkdown, `${path}.bodyMarkdown`, errors, MAX_MARKDOWN2);
|
|
2571
|
+
const ctaLabel = textValue(raw.ctaLabel, `${path}.ctaLabel`, errors);
|
|
2572
|
+
return {
|
|
2573
|
+
tierId,
|
|
2574
|
+
...label ? { label } : {},
|
|
2575
|
+
...bodyMarkdown ? { bodyMarkdown } : {},
|
|
2576
|
+
...ctaLabel ? { ctaLabel } : {}
|
|
2577
|
+
};
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2580
|
+
// src/member-content-validation.ts
|
|
2581
|
+
function validateMemberContentDocument(input, options = {}) {
|
|
2582
|
+
const errors = [];
|
|
2583
|
+
const root = record2(input);
|
|
2584
|
+
if (!root) return { ok: false, errors: ["document must be an object"] };
|
|
2585
|
+
unknownKeys2(root, ROOT_KEYS3, "document", errors);
|
|
2586
|
+
if (root.schemaVersion !== MEMBER_CONTENT_SCHEMA_VERSION) errors.push("document.schemaVersion must be 1");
|
|
2587
|
+
if (!Number.isInteger(root.revision) || Number(root.revision) < 1) errors.push("document.revision must be a positive integer");
|
|
2588
|
+
if (!Number.isInteger(root.generatedAt) || Number(root.generatedAt) < 0) errors.push("document.generatedAt must be a non-negative integer timestamp");
|
|
2589
|
+
const sourceRaw = record2(root.source);
|
|
2590
|
+
let source = { type: "chapter", appId: "invalid" };
|
|
2591
|
+
if (!sourceRaw) errors.push("document.source must be an object");
|
|
2592
|
+
else {
|
|
2593
|
+
unknownKeys2(sourceRaw, SOURCE_KEYS, "document.source", errors);
|
|
2594
|
+
const type = sourceRaw.type;
|
|
2595
|
+
const appId = textValue(sourceRaw.appId, "document.source.appId", errors, 63, true) ?? "";
|
|
2596
|
+
if (type !== "parent" && type !== "chapter") errors.push("document.source.type must be parent or chapter");
|
|
2597
|
+
if (appId && !ID3.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
|
|
2598
|
+
if (options.sourceType && type !== options.sourceType) errors.push(`document.source.type must be ${options.sourceType}`);
|
|
2599
|
+
if (options.sourceAppId && appId !== options.sourceAppId) errors.push(`document.source.appId must be ${options.sourceAppId}`);
|
|
2600
|
+
if (type === "parent" || type === "chapter") source = { type, appId };
|
|
2601
|
+
}
|
|
2602
|
+
let target;
|
|
2603
|
+
if (root.target !== void 0) {
|
|
2604
|
+
const targetRaw = record2(root.target);
|
|
2605
|
+
if (!targetRaw) errors.push("document.target must be an object");
|
|
2606
|
+
else {
|
|
2607
|
+
unknownKeys2(targetRaw, TARGET_KEYS, "document.target", errors);
|
|
2608
|
+
const chapterId = textValue(targetRaw.chapterId, "document.target.chapterId", errors, 63, true) ?? "";
|
|
2609
|
+
const environment = textValue(targetRaw.environment, "document.target.environment", errors, 80, true) ?? "";
|
|
2610
|
+
target = { chapterId, environment };
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
if (source.type === "parent" && !target) errors.push("document.target is required for parent content");
|
|
2614
|
+
if (source.type === "chapter" && target) errors.push("document.target is not supported for chapter content");
|
|
2615
|
+
if (!Array.isArray(root.pages)) errors.push("document.pages must be an array");
|
|
2616
|
+
const rawPages = Array.isArray(root.pages) ? root.pages : [];
|
|
2617
|
+
if (rawPages.length > MAX_PAGES) errors.push(`document.pages supports at most ${MAX_PAGES} entries`);
|
|
2618
|
+
const tierIds = new Set((options.tiers ?? []).map((tier) => tier.id));
|
|
2619
|
+
const knownParentIds = new Set((options.parentPages ?? []).map((page) => page.id));
|
|
2620
|
+
const ids = /* @__PURE__ */ new Set();
|
|
2621
|
+
const slugs = /* @__PURE__ */ new Set();
|
|
2622
|
+
const pages = [];
|
|
2623
|
+
for (const [index, value] of rawPages.slice(0, MAX_PAGES).entries()) {
|
|
2624
|
+
const path = `document.pages[${index}]`;
|
|
2625
|
+
const raw = record2(value);
|
|
2626
|
+
if (!raw) {
|
|
2627
|
+
errors.push(`${path} must be an object`);
|
|
2628
|
+
continue;
|
|
2629
|
+
}
|
|
2630
|
+
unknownKeys2(raw, PAGE_KEYS, path, errors);
|
|
2631
|
+
const id = textValue(raw.id, `${path}.id`, errors, 80, true) ?? "";
|
|
2632
|
+
const slug = textValue(raw.slug, `${path}.slug`, errors, 120, true) ?? "";
|
|
2633
|
+
const title = textValue(raw.title, `${path}.title`, errors, MAX_TEXT, true) ?? "";
|
|
2634
|
+
if (id && !ID3.test(id)) errors.push(`${path}.id must be a lowercase kebab-case id`);
|
|
2635
|
+
if (slug && !SLUG2.test(slug)) errors.push(`${path}.slug must be a lowercase URL slug`);
|
|
2636
|
+
if (ids.has(id) || knownParentIds.has(id)) errors.push(`${path}.id duplicates page "${id}"`);
|
|
2637
|
+
if (slugs.has(slug) || (options.parentPages ?? []).some((page) => page.slug === slug)) errors.push(`${path}.slug duplicates "${slug}"`);
|
|
2638
|
+
ids.add(id);
|
|
2639
|
+
slugs.add(slug);
|
|
2640
|
+
const navRaw = record2(raw.navigation);
|
|
2641
|
+
let navigation = { section: "General", order: index };
|
|
2642
|
+
if (!navRaw) errors.push(`${path}.navigation must be an object`);
|
|
2643
|
+
else {
|
|
2644
|
+
unknownKeys2(navRaw, NAV_KEYS, `${path}.navigation`, errors);
|
|
2645
|
+
const section = textValue(navRaw.section, `${path}.navigation.section`, errors, 120, true) ?? "";
|
|
2646
|
+
const order = navRaw.order;
|
|
2647
|
+
if (!Number.isInteger(order) || Number(order) < 0 || Number(order) > 1e6) {
|
|
2648
|
+
errors.push(`${path}.navigation.order must be an integer from 0 to 1,000,000`);
|
|
2649
|
+
}
|
|
2650
|
+
const parentId = textValue(navRaw.parentId, `${path}.navigation.parentId`, errors, 80);
|
|
2651
|
+
navigation = { section, order: Number.isInteger(order) ? Number(order) : index, ...parentId ? { parentId } : {} };
|
|
2652
|
+
}
|
|
2653
|
+
const audienceRaw = record2(raw.audience);
|
|
2654
|
+
let audience = { kind: "all-members" };
|
|
2655
|
+
if (!audienceRaw) errors.push(`${path}.audience must be an object`);
|
|
2656
|
+
else {
|
|
2657
|
+
unknownKeys2(audienceRaw, AUDIENCE_KEYS, `${path}.audience`, errors);
|
|
2658
|
+
if (audienceRaw.kind === "all-members") {
|
|
2659
|
+
if (audienceRaw.tierIds !== void 0) errors.push(`${path}.audience.tierIds is not supported for all-members`);
|
|
2660
|
+
} else if (audienceRaw.kind === "tiers") {
|
|
2661
|
+
if (!Array.isArray(audienceRaw.tierIds) || audienceRaw.tierIds.length === 0) {
|
|
2662
|
+
errors.push(`${path}.audience.tierIds must be a non-empty array`);
|
|
2663
|
+
}
|
|
2664
|
+
const values = Array.isArray(audienceRaw.tierIds) ? audienceRaw.tierIds : [];
|
|
2665
|
+
const normalized = [];
|
|
2666
|
+
for (const [tierIndex, value2] of values.entries()) {
|
|
2667
|
+
const tier = textValue(value2, `${path}.audience.tierIds[${tierIndex}]`, errors, 80, true) ?? "";
|
|
2668
|
+
if (normalized.includes(tier)) errors.push(`${path}.audience.tierIds duplicates "${tier}"`);
|
|
2669
|
+
if (tierIds.size && !tierIds.has(tier)) errors.push(`${path}.audience.tierIds references undeclared tier "${tier}"`);
|
|
2670
|
+
normalized.push(tier);
|
|
2671
|
+
}
|
|
2672
|
+
audience = { kind: "tiers", tierIds: normalized };
|
|
2673
|
+
} else errors.push(`${path}.audience.kind must be all-members or tiers`);
|
|
2674
|
+
}
|
|
2675
|
+
const summary = textValue(raw.summary, `${path}.summary`, errors, 2e3);
|
|
2676
|
+
const teaserMarkdown = textValue(raw.teaserMarkdown, `${path}.teaserMarkdown`, errors, MAX_MARKDOWN2);
|
|
2677
|
+
const bodyMarkdown = textValue(raw.bodyMarkdown, `${path}.bodyMarkdown`, errors, MAX_MARKDOWN2, true) ?? "";
|
|
2678
|
+
let upsell;
|
|
2679
|
+
if (raw.upsell !== void 0) {
|
|
2680
|
+
const upsellRaw = record2(raw.upsell);
|
|
2681
|
+
if (!upsellRaw) errors.push(`${path}.upsell must be an object`);
|
|
2682
|
+
else {
|
|
2683
|
+
unknownKeys2(upsellRaw, UPSELL_KEYS, `${path}.upsell`, errors);
|
|
2684
|
+
const owner = upsellRaw.owner;
|
|
2685
|
+
if (owner !== "parent" && owner !== "chapter") errors.push(`${path}.upsell.owner must be parent or chapter`);
|
|
2686
|
+
if (source.type === "chapter" && owner === "parent") errors.push(`${path}.upsell.owner cannot be parent on a chapter page`);
|
|
2687
|
+
if (source.type === "parent" && owner === "parent" && upsellRaw.offer === void 0) errors.push(`${path}.upsell.offer is required when the parent owns the offer`);
|
|
2688
|
+
if (source.type === "parent" && owner === "chapter" && upsellRaw.offer !== void 0) errors.push(`${path}.upsell.offer must be set by the chapter after delegation`);
|
|
2689
|
+
const offer = upsellRaw.offer === void 0 ? void 0 : offerValue(upsellRaw.offer, `${path}.upsell.offer`, errors, tierIds, audience);
|
|
2690
|
+
if (owner === "parent") upsell = { owner, offer: offer ?? { tierId: "" } };
|
|
2691
|
+
else if (owner === "chapter") upsell = { owner, ...offer ? { offer } : {} };
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
pages.push({ id, slug, title, ...summary ? { summary } : {}, navigation, audience, ...teaserMarkdown ? { teaserMarkdown } : {}, bodyMarkdown, ...upsell ? { upsell } : {} });
|
|
2695
|
+
}
|
|
2696
|
+
const allIds = /* @__PURE__ */ new Set([...knownParentIds, ...ids]);
|
|
2697
|
+
for (const [index, page] of pages.entries()) {
|
|
2698
|
+
const parentId = page.navigation.parentId;
|
|
2699
|
+
if (parentId && !allIds.has(parentId)) errors.push(`document.pages[${index}].navigation.parentId references unknown page "${parentId}"`);
|
|
2700
|
+
if (parentId === page.id) errors.push(`document.pages[${index}].navigation.parentId cannot reference itself`);
|
|
2701
|
+
}
|
|
2702
|
+
for (const page of pages) {
|
|
2703
|
+
const seen = /* @__PURE__ */ new Set([page.id]);
|
|
2704
|
+
let parentId = page.navigation.parentId;
|
|
2705
|
+
while (parentId) {
|
|
2706
|
+
if (seen.has(parentId)) {
|
|
2707
|
+
errors.push(`document.pages contains a navigation cycle at "${page.id}"`);
|
|
2708
|
+
break;
|
|
2709
|
+
}
|
|
2710
|
+
seen.add(parentId);
|
|
2711
|
+
parentId = pages.find((candidate) => candidate.id === parentId)?.navigation.parentId;
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
let upsellOverrides;
|
|
2715
|
+
if (root.upsellOverrides !== void 0) {
|
|
2716
|
+
if (source.type !== "chapter") errors.push("document.upsellOverrides is supported only for chapter content");
|
|
2717
|
+
if (!Array.isArray(root.upsellOverrides)) errors.push("document.upsellOverrides must be an array");
|
|
2718
|
+
const values = Array.isArray(root.upsellOverrides) ? root.upsellOverrides : [];
|
|
2719
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2720
|
+
upsellOverrides = [];
|
|
2721
|
+
for (const [index, value] of values.entries()) {
|
|
2722
|
+
const path = `document.upsellOverrides[${index}]`;
|
|
2723
|
+
const raw = record2(value);
|
|
2724
|
+
if (!raw) {
|
|
2725
|
+
errors.push(`${path} must be an object`);
|
|
2726
|
+
continue;
|
|
2727
|
+
}
|
|
2728
|
+
unknownKeys2(raw, OVERRIDE_KEYS, path, errors);
|
|
2729
|
+
const pageId = textValue(raw.pageId, `${path}.pageId`, errors, 80, true) ?? "";
|
|
2730
|
+
if (seen.has(pageId)) errors.push(`${path}.pageId duplicates "${pageId}"`);
|
|
2731
|
+
seen.add(pageId);
|
|
2732
|
+
const parentPage = (options.parentPages ?? []).find((page) => page.id === pageId);
|
|
2733
|
+
if (!parentPage) errors.push(`${path}.pageId must reference a parent page`);
|
|
2734
|
+
else if (parentPage.upsell?.owner !== "chapter") errors.push(`${path}.pageId does not delegate upsell authority to the chapter`);
|
|
2735
|
+
const offer = offerValue(raw.offer, `${path}.offer`, errors, tierIds, parentPage?.audience);
|
|
2736
|
+
if (offer) upsellOverrides.push({ pageId, offer });
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
2739
|
+
if (errors.length) return { ok: false, errors };
|
|
2740
|
+
return { ok: true, value: {
|
|
2741
|
+
schemaVersion: MEMBER_CONTENT_SCHEMA_VERSION,
|
|
2742
|
+
revision: Number(root.revision),
|
|
2743
|
+
generatedAt: Number(root.generatedAt),
|
|
2744
|
+
source,
|
|
2745
|
+
...target ? { target } : {},
|
|
2746
|
+
pages,
|
|
2747
|
+
...upsellOverrides ? { upsellOverrides } : {}
|
|
2748
|
+
} };
|
|
2749
|
+
}
|
|
2750
|
+
|
|
2751
|
+
// src/member-content.ts
|
|
2752
|
+
var MEMBER_CONTENT_SCHEMA_VERSION = 1;
|
|
2753
|
+
var MEMBER_CONTENT_PATH = "/api/member-content";
|
|
2754
|
+
var MEMBER_CONTENT_CONTROL_PATH = "/api/member-content-control";
|
|
2755
|
+
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);
|
|
2756
|
+
function composeMemberContent(parent, chapter) {
|
|
2757
|
+
const pages = [];
|
|
2758
|
+
const parentPages = /* @__PURE__ */ new Map();
|
|
2759
|
+
for (const page of parent?.pages ?? []) {
|
|
2760
|
+
const effective = { ...page, source: parent.source };
|
|
2761
|
+
parentPages.set(page.id, effective);
|
|
2762
|
+
pages.push(effective);
|
|
2763
|
+
}
|
|
2764
|
+
for (const override of chapter?.upsellOverrides ?? []) {
|
|
2765
|
+
const page = parentPages.get(override.pageId);
|
|
2766
|
+
if (page?.upsell?.owner === "chapter") page.upsell = { owner: "chapter", offer: override.offer };
|
|
2767
|
+
}
|
|
2768
|
+
for (const page of chapter?.pages ?? []) {
|
|
2769
|
+
if (parentPages.has(page.id) || pages.some((candidate) => candidate.slug === page.slug)) {
|
|
2770
|
+
throw new TypeError(`Chapter member page "${page.id}" conflicts with parent content`);
|
|
2771
|
+
}
|
|
2772
|
+
pages.push({ ...page, source: chapter.source });
|
|
2773
|
+
}
|
|
2774
|
+
return {
|
|
2775
|
+
pages: pages.sort(pageOrder),
|
|
2776
|
+
parentRevision: parent?.revision ?? null,
|
|
2777
|
+
chapterRevision: chapter?.revision ?? null
|
|
2778
|
+
};
|
|
2779
|
+
}
|
|
2780
|
+
var allowed = (page, viewer) => viewer.admin || page.audience.kind === "all-members" || viewer.tierId !== null && page.audience.tierIds.includes(viewer.tierId);
|
|
2781
|
+
function projectMemberContent(content, viewer, tiers) {
|
|
2782
|
+
const authority = new Map(tiers.filter((tier) => tier.active !== false).map((tier) => [tier.id, tier]));
|
|
2783
|
+
return {
|
|
2784
|
+
viewer: { ...viewer },
|
|
2785
|
+
revisions: { parent: content.parentRevision, chapter: content.chapterRevision },
|
|
2786
|
+
pages: content.pages.map((page) => {
|
|
2787
|
+
const canRead = allowed(page, viewer);
|
|
2788
|
+
const offer = !canRead ? page.upsell?.offer : void 0;
|
|
2789
|
+
const tier = offer ? authority.get(offer.tierId) : void 0;
|
|
2790
|
+
const upsell = offer && tier && typeof tier.name === "string" && typeof tier.priceCents === "number" ? { ...offer, tierName: tier.name, priceCents: tier.priceCents } : void 0;
|
|
2791
|
+
return {
|
|
2792
|
+
id: page.id,
|
|
2793
|
+
slug: page.slug,
|
|
2794
|
+
title: page.title,
|
|
2795
|
+
...page.summary ? { summary: page.summary } : {},
|
|
2796
|
+
navigation: page.navigation,
|
|
2797
|
+
audience: page.audience,
|
|
2798
|
+
source: page.source,
|
|
2799
|
+
access: canRead ? "allowed" : "locked",
|
|
2800
|
+
...page.teaserMarkdown ? { teaserMarkdown: page.teaserMarkdown } : {},
|
|
2801
|
+
...canRead ? { bodyMarkdown: page.bodyMarkdown } : {},
|
|
2802
|
+
...upsell ? { upsell } : {}
|
|
2803
|
+
};
|
|
2804
|
+
})
|
|
2805
|
+
};
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2808
|
+
// src/member-content-store.ts
|
|
2809
|
+
var control = (row) => row && row.document && typeof row.document === "object" ? row : null;
|
|
2810
|
+
var local = (row) => row && row.document && typeof row.document === "object" ? row : null;
|
|
2811
|
+
async function exists(db, ns, where) {
|
|
2812
|
+
if (db.aggregate) return Number((await db.aggregate(ns, { count: true }, { where })).count ?? 0) > 0;
|
|
2813
|
+
const rows4 = (await db.query({ [ns]: {} }))[ns] ?? [];
|
|
2814
|
+
return rows4.some((row) => Object.entries(where).every(([key, value]) => row[key] === value));
|
|
2815
|
+
}
|
|
2816
|
+
async function loadActiveMemberContentControl(db, targetId, environment) {
|
|
2817
|
+
const where = { targetId, environment, active: true };
|
|
2818
|
+
if (!await exists(db, "memberContentControls", where)) return null;
|
|
2819
|
+
const rows4 = (await db.query({ memberContentControls: { $: {
|
|
2820
|
+
where,
|
|
2821
|
+
order: { revision: "desc" },
|
|
2822
|
+
limit: 1
|
|
2823
|
+
} } })).memberContentControls;
|
|
2824
|
+
return control(Array.isArray(rows4) ? [...rows4].sort((a, b) => Number(b.revision) - Number(a.revision))[0] : void 0);
|
|
2825
|
+
}
|
|
2826
|
+
async function loadLatestMemberContentControl(db, targetId, environment) {
|
|
2827
|
+
const where = { targetId, environment };
|
|
2828
|
+
if (!await exists(db, "memberContentControls", where)) return null;
|
|
2829
|
+
const rows4 = (await db.query({ memberContentControls: { $: {
|
|
2830
|
+
where,
|
|
2831
|
+
order: { revision: "desc" },
|
|
2832
|
+
limit: 1
|
|
2833
|
+
} } })).memberContentControls;
|
|
2834
|
+
return control(Array.isArray(rows4) ? [...rows4].sort((a, b) => Number(b.revision) - Number(a.revision))[0] : void 0);
|
|
2835
|
+
}
|
|
2836
|
+
async function loadLocalMemberContent(db, chapterId, environment) {
|
|
2837
|
+
const id = `member-content-local:${environment}`;
|
|
2838
|
+
const rows4 = (await db.query({ memberContentLocal: { $: { where: { id }, limit: 1 } } })).memberContentLocal;
|
|
2839
|
+
return local(Array.isArray(rows4) ? rows4[0] : void 0);
|
|
2840
|
+
}
|
|
2841
|
+
async function materializeMemberContentControl(db, envelope, previous, receivedAt) {
|
|
2842
|
+
const { document, digest } = envelope;
|
|
2843
|
+
const target = document.target;
|
|
2844
|
+
const id = `member-content-control:${target.environment}:${document.revision}`;
|
|
2845
|
+
const ops = [];
|
|
2846
|
+
if (previous?.active) {
|
|
2847
|
+
ops.push({ t: "update", ns: "memberContentControls", id: previous.id, attrs: { active: false } });
|
|
2848
|
+
}
|
|
2849
|
+
ops.push({ t: "update", ns: "memberContentControls", id, attrs: {
|
|
2850
|
+
id,
|
|
2851
|
+
sourceId: document.source.appId,
|
|
2852
|
+
targetId: target.chapterId,
|
|
2853
|
+
environment: target.environment,
|
|
2854
|
+
revision: document.revision,
|
|
2855
|
+
digest,
|
|
2856
|
+
document,
|
|
2857
|
+
active: true,
|
|
2858
|
+
receivedAt
|
|
2859
|
+
} });
|
|
2860
|
+
await db.transact(ops, { mutationId: `member-content-control:${target.environment}:${document.revision}:${digest}` });
|
|
2861
|
+
}
|
|
2862
|
+
async function saveLocalMemberContent(db, document, environment, expectedRevision, updatedAt, updatedBy) {
|
|
2863
|
+
const id = `member-content-local:${environment}`;
|
|
2864
|
+
const current = await loadLocalMemberContent(db, document.source.appId, environment);
|
|
2865
|
+
if ((current?.revision ?? 0) !== expectedRevision) throw new Error("member_content_revision_conflict");
|
|
2866
|
+
const row = {
|
|
2867
|
+
id,
|
|
2868
|
+
chapterId: document.source.appId,
|
|
2869
|
+
environment,
|
|
2870
|
+
revision: document.revision,
|
|
2871
|
+
document,
|
|
2872
|
+
updatedAt,
|
|
2873
|
+
...updatedBy ? { updatedBy } : {}
|
|
2874
|
+
};
|
|
2875
|
+
await db.transact([{ t: "update", ns: "memberContentLocal", id, attrs: row }], {
|
|
2876
|
+
mutationId: `member-content-local:${environment}:${document.revision}`,
|
|
2877
|
+
guards: current ? [{ ns: "memberContentLocal", id, exists: true, equals: { revision: expectedRevision } }] : [{ ns: "memberContentLocal", id, exists: false }]
|
|
2878
|
+
});
|
|
2879
|
+
return row;
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
// src/worker-routes-member-content.ts
|
|
2883
|
+
var ADMIN_PATH = "/api/admin/member-content";
|
|
2884
|
+
var BODY_CAP = 1e6;
|
|
2415
2885
|
var reject2 = (reason, status, errors) => json({ ok: false, reason, ...errors ? { errors } : {} }, status);
|
|
2886
|
+
async function groupAndTiers(db, chapterId) {
|
|
2887
|
+
const groups = (await db.query({ groups: { $: { where: { id: chapterId }, limit: 1 } } })).groups;
|
|
2888
|
+
const group = Array.isArray(groups) ? groups[0] : void 0;
|
|
2889
|
+
if (!group) return { group: null, tiers: [] };
|
|
2890
|
+
return { group, tiers: await loadTiers(db, group) };
|
|
2891
|
+
}
|
|
2892
|
+
async function memberTierId(db, chapterId, email) {
|
|
2893
|
+
if (!email) return null;
|
|
2894
|
+
let rows4 = (await db.query({ applications: { $: {
|
|
2895
|
+
where: { emailNormalized: normalizeAdmissionEmail(email) },
|
|
2896
|
+
limit: 20
|
|
2897
|
+
} } })).applications;
|
|
2898
|
+
if (!rows4?.length) rows4 = (await db.query({ applications: { $: { where: { email }, limit: 20 } } })).applications;
|
|
2899
|
+
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;
|
|
2900
|
+
return typeof latest?.tierId === "string" && latest.tierId ? latest.tierId : null;
|
|
2901
|
+
}
|
|
2902
|
+
async function activeDocuments(db, chapterId, environment) {
|
|
2903
|
+
const [parent, chapter] = await Promise.all([
|
|
2904
|
+
loadActiveMemberContentControl(db, chapterId, environment),
|
|
2905
|
+
loadLocalMemberContent(db, chapterId, environment)
|
|
2906
|
+
]);
|
|
2907
|
+
return { parent, chapter };
|
|
2908
|
+
}
|
|
2909
|
+
var handleMemberContentControl = async (req, url, env, ctx) => {
|
|
2910
|
+
if (req.method !== "POST" || url.pathname !== MEMBER_CONTENT_CONTROL_PATH) return null;
|
|
2911
|
+
const settings = ctx.chapter.memberContent?.parent;
|
|
2912
|
+
if (!settings) return json({ error: "not found" }, 404);
|
|
2913
|
+
const db = ctx.makeDb(env);
|
|
2914
|
+
const secret = await getVaultSecret(db, settings.secretName);
|
|
2915
|
+
if (!secret) return json({ error: "unauthorized" }, 401);
|
|
2916
|
+
const verified = await (0, import_db4.verifyFederatedRequest)(req, { secret, senders: [settings.sourceId] });
|
|
2917
|
+
if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
|
|
2918
|
+
let input;
|
|
2919
|
+
try {
|
|
2920
|
+
input = JSON.parse(verified.body);
|
|
2921
|
+
} catch {
|
|
2922
|
+
return reject2("invalid_document", 400, ["request body must be JSON"]);
|
|
2923
|
+
}
|
|
2924
|
+
const digest = typeof input.digest === "string" ? input.digest : "";
|
|
2925
|
+
const initial = validateMemberContentDocument(input.document, {
|
|
2926
|
+
sourceType: "parent"
|
|
2927
|
+
});
|
|
2928
|
+
if (!initial.ok) return reject2("invalid_document", 400, initial.errors);
|
|
2929
|
+
const document = initial.value;
|
|
2930
|
+
if (document.source.appId !== settings.sourceId) return reject2("wrong_source", 403);
|
|
2931
|
+
if (document.target?.chapterId !== ctx.chapter.id) return reject2("wrong_target", 409);
|
|
2932
|
+
if (document.target.environment !== env.ODLA_ENV) return reject2("wrong_environment", 409);
|
|
2933
|
+
const { tiers } = await groupAndTiers(db, ctx.chapter.id);
|
|
2934
|
+
const checked = validateMemberContentDocument(document, {
|
|
2935
|
+
sourceType: "parent",
|
|
2936
|
+
sourceAppId: settings.sourceId,
|
|
2937
|
+
tiers
|
|
2938
|
+
});
|
|
2939
|
+
if (!checked.ok) return reject2("invalid_document", 400, checked.errors);
|
|
2940
|
+
const local2 = await loadLocalMemberContent(db, ctx.chapter.id, env.ODLA_ENV);
|
|
2941
|
+
if (local2) {
|
|
2942
|
+
const localCheck = validateMemberContentDocument(local2.document, {
|
|
2943
|
+
sourceType: "chapter",
|
|
2944
|
+
sourceAppId: ctx.chapter.id,
|
|
2945
|
+
tiers,
|
|
2946
|
+
parentPages: document.pages
|
|
2947
|
+
});
|
|
2948
|
+
if (!localCheck.ok) {
|
|
2949
|
+
return reject2("invalid_document", 409, localCheck.errors.map((error) => `existing chapter content: ${error}`));
|
|
2950
|
+
}
|
|
2951
|
+
}
|
|
2952
|
+
if (!digest || await memberContentDigest(document) !== digest) return reject2("invalid_digest", 400);
|
|
2953
|
+
const latest = await loadLatestMemberContentControl(db, ctx.chapter.id, env.ODLA_ENV);
|
|
2954
|
+
if (latest && document.revision < latest.revision) return reject2("stale_revision", 409);
|
|
2955
|
+
if (latest && document.revision === latest.revision) {
|
|
2956
|
+
if (latest.digest !== digest) return reject2("conflicting_revision", 409);
|
|
2957
|
+
return json({
|
|
2958
|
+
ok: true,
|
|
2959
|
+
target: document.target,
|
|
2960
|
+
revision: latest.revision,
|
|
2961
|
+
digest: latest.digest,
|
|
2962
|
+
status: "replayed",
|
|
2963
|
+
acknowledgedAt: latest.receivedAt
|
|
2964
|
+
});
|
|
2965
|
+
}
|
|
2966
|
+
const receivedAt = Date.now();
|
|
2967
|
+
try {
|
|
2968
|
+
await materializeMemberContentControl(db, { digest, document }, latest, receivedAt);
|
|
2969
|
+
} catch {
|
|
2970
|
+
return reject2("materialization_failed", 503);
|
|
2971
|
+
}
|
|
2972
|
+
return json({
|
|
2973
|
+
ok: true,
|
|
2974
|
+
target: document.target,
|
|
2975
|
+
revision: document.revision,
|
|
2976
|
+
digest,
|
|
2977
|
+
status: "accepted",
|
|
2978
|
+
acknowledgedAt: receivedAt
|
|
2979
|
+
});
|
|
2980
|
+
};
|
|
2981
|
+
var handleMemberContent = async (req, url, env, ctx) => {
|
|
2982
|
+
if (req.method !== "GET" || url.pathname !== MEMBER_CONTENT_PATH) return null;
|
|
2983
|
+
if (!ctx.chapter.memberContent) return json({ error: "not found" }, 404);
|
|
2984
|
+
const user = await ctx.verifyUser(req, env);
|
|
2985
|
+
if (!user) return json({ error: "unauthorized" }, 401);
|
|
2986
|
+
const db = ctx.makeDb(env);
|
|
2987
|
+
const role = await ctx.roleFor(db, user);
|
|
2988
|
+
const admin = await ctx.isAdmin(db, user);
|
|
2989
|
+
const roleIndex = ctx.chapter.auth.ladder.indexOf(role);
|
|
2990
|
+
const memberIndex = Math.max(0, ctx.chapter.auth.ladder.length - 2);
|
|
2991
|
+
if (!admin && roleIndex < memberIndex) return json({ error: "member access required" }, 403);
|
|
2992
|
+
const [{ parent, chapter }, { group, tiers }, tierId] = await Promise.all([
|
|
2993
|
+
activeDocuments(db, ctx.chapter.id, env.ODLA_ENV),
|
|
2994
|
+
groupAndTiers(db, ctx.chapter.id),
|
|
2995
|
+
memberTierId(db, ctx.chapter.id, user.email)
|
|
2996
|
+
]);
|
|
2997
|
+
const effective = composeMemberContent(parent?.document, chapter?.document);
|
|
2998
|
+
let offerable = tiers;
|
|
2999
|
+
if (group) {
|
|
3000
|
+
const stripeSecret = await getVaultSecret(db, "stripe_secret_key");
|
|
3001
|
+
offerable = await loadOfferableTiers(db, group, Boolean(stripeSecret));
|
|
3002
|
+
}
|
|
3003
|
+
return json(projectMemberContent(effective, { tierId, admin }, offerable));
|
|
3004
|
+
};
|
|
3005
|
+
var handleAdminMemberContent = async (req, url, env, ctx) => {
|
|
3006
|
+
if (url.pathname !== ADMIN_PATH) return null;
|
|
3007
|
+
if (!ctx.chapter.memberContent) return json({ error: "not found" }, 404);
|
|
3008
|
+
const user = await ctx.verifyUser(req, env);
|
|
3009
|
+
if (!user) return json({ error: "unauthorized" }, 401);
|
|
3010
|
+
const db = ctx.makeDb(env);
|
|
3011
|
+
if (!await ctx.isAdmin(db, user)) return json({ error: "forbidden" }, 403);
|
|
3012
|
+
const [{ parent, chapter }, { tiers }] = await Promise.all([
|
|
3013
|
+
activeDocuments(db, ctx.chapter.id, env.ODLA_ENV),
|
|
3014
|
+
groupAndTiers(db, ctx.chapter.id)
|
|
3015
|
+
]);
|
|
3016
|
+
if (req.method === "GET") {
|
|
3017
|
+
const effective = composeMemberContent(parent?.document, chapter?.document);
|
|
3018
|
+
return json({
|
|
3019
|
+
parent: parent ? { revision: parent.revision, digest: parent.digest, document: parent.document } : null,
|
|
3020
|
+
chapter: chapter ? { revision: chapter.revision, document: chapter.document } : null,
|
|
3021
|
+
effective,
|
|
3022
|
+
tiers: tiers.map(({ id, name, priceCents, active }) => ({ id, name, priceCents, active })),
|
|
3023
|
+
localAuthoring: ctx.chapter.memberContent.localAuthoring
|
|
3024
|
+
});
|
|
3025
|
+
}
|
|
3026
|
+
if (req.method !== "PUT") return json({ error: "method not allowed" }, 405);
|
|
3027
|
+
if (!ctx.chapter.memberContent.localAuthoring) return json({ error: "local authoring is disabled" }, 403);
|
|
3028
|
+
const raw = await req.text();
|
|
3029
|
+
if (raw.length > BODY_CAP) return json({ error: "request body too large" }, 413);
|
|
3030
|
+
let input;
|
|
3031
|
+
try {
|
|
3032
|
+
input = JSON.parse(raw);
|
|
3033
|
+
} catch {
|
|
3034
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
3035
|
+
}
|
|
3036
|
+
const expectedRevision = Number(input.expectedRevision);
|
|
3037
|
+
if (!Number.isInteger(expectedRevision) || expectedRevision < 0) {
|
|
3038
|
+
return json({ error: "expectedRevision must be a non-negative integer" }, 400);
|
|
3039
|
+
}
|
|
3040
|
+
const now = Date.now();
|
|
3041
|
+
const documentInput = withLocalAuthority(input.document, expectedRevision + 1, now, ctx.chapter.id);
|
|
3042
|
+
const checked = validateMemberContentDocument(documentInput, {
|
|
3043
|
+
sourceType: "chapter",
|
|
3044
|
+
sourceAppId: ctx.chapter.id,
|
|
3045
|
+
tiers,
|
|
3046
|
+
parentPages: parent?.document.pages ?? []
|
|
3047
|
+
});
|
|
3048
|
+
if (!checked.ok) return json({ error: "invalid member content", errors: checked.errors }, 400);
|
|
3049
|
+
try {
|
|
3050
|
+
const saved = await saveLocalMemberContent(
|
|
3051
|
+
db,
|
|
3052
|
+
checked.value,
|
|
3053
|
+
env.ODLA_ENV,
|
|
3054
|
+
expectedRevision,
|
|
3055
|
+
now,
|
|
3056
|
+
user.email ?? user.userId
|
|
3057
|
+
);
|
|
3058
|
+
return json({ ok: true, revision: saved.revision, document: saved.document });
|
|
3059
|
+
} catch (error) {
|
|
3060
|
+
if (error instanceof Error && error.message === "member_content_revision_conflict") {
|
|
3061
|
+
return json({ error: "member content changed; reload before saving" }, 409);
|
|
3062
|
+
}
|
|
3063
|
+
return json({ error: "member content could not be saved" }, 503);
|
|
3064
|
+
}
|
|
3065
|
+
};
|
|
3066
|
+
function withLocalAuthority(value, revision, generatedAt, appId) {
|
|
3067
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
|
3068
|
+
return {
|
|
3069
|
+
...value,
|
|
3070
|
+
schemaVersion: 1,
|
|
3071
|
+
revision,
|
|
3072
|
+
generatedAt,
|
|
3073
|
+
source: { type: "chapter", appId }
|
|
3074
|
+
};
|
|
3075
|
+
}
|
|
3076
|
+
|
|
3077
|
+
// src/worker-routes-admission-grants.ts
|
|
3078
|
+
var import_db5 = require("@odla-ai/db");
|
|
3079
|
+
var reject3 = (reason, status, errors) => json({ ok: false, reason, ...errors ? { errors } : {} }, status);
|
|
2416
3080
|
var handleAdmissionGrants = async (req, url, env, ctx) => {
|
|
2417
3081
|
if (req.method !== "POST" || url.pathname !== ADMISSION_GRANTS_PATH) return null;
|
|
2418
3082
|
const authority = ctx.chapter.signupControl;
|
|
@@ -2420,26 +3084,26 @@ var handleAdmissionGrants = async (req, url, env, ctx) => {
|
|
|
2420
3084
|
const db = ctx.makeDb(env);
|
|
2421
3085
|
const secret = await getVaultSecret(db, authority.secretName);
|
|
2422
3086
|
if (!secret) return json({ error: "unauthorized" }, 401);
|
|
2423
|
-
const verified = await (0,
|
|
3087
|
+
const verified = await (0, import_db5.verifyFederatedRequest)(req, { secret, senders: [authority.sourceId] });
|
|
2424
3088
|
if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
|
|
2425
3089
|
let input;
|
|
2426
3090
|
try {
|
|
2427
3091
|
input = JSON.parse(verified.body);
|
|
2428
3092
|
} catch {
|
|
2429
|
-
return
|
|
3093
|
+
return reject3("invalid_document", 400, ["request body must be JSON"]);
|
|
2430
3094
|
}
|
|
2431
3095
|
const digest = typeof input.digest === "string" ? input.digest : "";
|
|
2432
3096
|
const checked = validateAdmissionGrantDocument(input.document);
|
|
2433
|
-
if (!checked.ok) return
|
|
3097
|
+
if (!checked.ok) return reject3("invalid_document", 400, checked.errors);
|
|
2434
3098
|
const document = checked.value;
|
|
2435
|
-
if (document.source.appId !== authority.sourceId) return
|
|
2436
|
-
if (document.target.chapterId !== ctx.chapter.id) return
|
|
2437
|
-
if (document.target.environment !== env.ODLA_ENV) return
|
|
2438
|
-
if (!digest || await signupControlDigest(document) !== digest) return
|
|
3099
|
+
if (document.source.appId !== authority.sourceId) return reject3("wrong_source", 403);
|
|
3100
|
+
if (document.target.chapterId !== ctx.chapter.id) return reject3("wrong_target", 409);
|
|
3101
|
+
if (document.target.environment !== env.ODLA_ENV) return reject3("wrong_environment", 409);
|
|
3102
|
+
if (!digest || await signupControlDigest(document) !== digest) return reject3("invalid_digest", 400);
|
|
2439
3103
|
const latest = await loadLatestAdmissionRevision(db, authority.sourceId, ctx.chapter.id, env.ODLA_ENV);
|
|
2440
|
-
if (latest && document.revision < latest.revision) return
|
|
3104
|
+
if (latest && document.revision < latest.revision) return reject3("stale_revision", 409);
|
|
2441
3105
|
if (latest && document.revision === latest.revision) {
|
|
2442
|
-
if (latest.digest !== digest) return
|
|
3106
|
+
if (latest.digest !== digest) return reject3("conflicting_revision", 409);
|
|
2443
3107
|
return json({
|
|
2444
3108
|
ok: true,
|
|
2445
3109
|
target: { chapterId: ctx.chapter.id, environment: env.ODLA_ENV },
|
|
@@ -2453,7 +3117,7 @@ var handleAdmissionGrants = async (req, url, env, ctx) => {
|
|
|
2453
3117
|
try {
|
|
2454
3118
|
await materializeAdmissionGrants(db, { digest, document }, latest, receivedAt);
|
|
2455
3119
|
} catch {
|
|
2456
|
-
return
|
|
3120
|
+
return reject3("materialization_failed", 503);
|
|
2457
3121
|
}
|
|
2458
3122
|
return json({
|
|
2459
3123
|
ok: true,
|
|
@@ -2495,7 +3159,7 @@ var handleAdminAdmissionGrants = async (req, url, env, ctx) => {
|
|
|
2495
3159
|
return json({ error: "invalid JSON body" }, 400);
|
|
2496
3160
|
}
|
|
2497
3161
|
const email = typeof body.email === "string" ? body.email.trim() : "";
|
|
2498
|
-
const emailNormalized =
|
|
3162
|
+
const emailNormalized = normalizeChapterEmail(email);
|
|
2499
3163
|
if (!isValidEmail(emailNormalized)) return json({ error: "email must be valid" }, 400);
|
|
2500
3164
|
const decision = body.decision ?? "waive_interview";
|
|
2501
3165
|
if (decision !== "waive_interview" && decision !== "require_interview") {
|
|
@@ -2997,7 +3661,7 @@ async function rotateGiftClaim(db, claim, now) {
|
|
|
2997
3661
|
async function redeemGiftClaim(db, claim, beneficiary, now) {
|
|
2998
3662
|
if (claim.status !== "pending" || Number(claim.codeExpiresAt) <= now) return null;
|
|
2999
3663
|
const chapterId = String(claim.chapterId);
|
|
3000
|
-
const emailNormalized =
|
|
3664
|
+
const emailNormalized = normalizeChapterEmail(beneficiary.email);
|
|
3001
3665
|
const target = claim.target;
|
|
3002
3666
|
const ops = [];
|
|
3003
3667
|
let result;
|
|
@@ -3281,8 +3945,8 @@ async function startSubscription(req, env, ctx) {
|
|
|
3281
3945
|
const secretKey = await getVaultSecret(db, "stripe_secret_key");
|
|
3282
3946
|
if (!group || !secretKey) return json({ error: "payments not configured" }, 503);
|
|
3283
3947
|
if (typeof app.giftOfferId === "string") {
|
|
3284
|
-
const
|
|
3285
|
-
const offer = findGiftOffer(
|
|
3948
|
+
const control2 = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
|
|
3949
|
+
const offer = findGiftOffer(control2?.document.giftOffers, app.giftOfferId, Date.now());
|
|
3286
3950
|
if (!offer || typeof group.stripePublishableKey !== "string" || !group.stripePublishableKey) {
|
|
3287
3951
|
return json({ error: "gift offer is unavailable" }, 409);
|
|
3288
3952
|
}
|
|
@@ -3294,7 +3958,7 @@ async function startSubscription(req, env, ctx) {
|
|
|
3294
3958
|
offer,
|
|
3295
3959
|
purchaserType: "application",
|
|
3296
3960
|
purchaserEmail: String(app.email ?? ""),
|
|
3297
|
-
purchaserEmailNormalized:
|
|
3961
|
+
purchaserEmailNormalized: normalizeChapterEmail(String(app.email ?? "")),
|
|
3298
3962
|
purchaserApplicationId: applicationId,
|
|
3299
3963
|
stripeSecret: secretKey,
|
|
3300
3964
|
publishableKey: group.stripePublishableKey,
|
|
@@ -3441,14 +4105,14 @@ var handleGifts = async (req, url, env, ctx) => {
|
|
|
3441
4105
|
if (req.method === "GET" && url.pathname === "/api/gifts") {
|
|
3442
4106
|
const user = await userFor(req, env, ctx);
|
|
3443
4107
|
if (!user) return json({ error: "unauthorized" }, 401);
|
|
3444
|
-
const
|
|
4108
|
+
const control2 = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
|
|
3445
4109
|
const found = await db.query({ giftClaims: { $: { where: {
|
|
3446
|
-
purchaserEmailNormalized:
|
|
4110
|
+
purchaserEmailNormalized: normalizeChapterEmail(user.email),
|
|
3447
4111
|
chapterId: ctx.chapter.id
|
|
3448
4112
|
}, limit: 200 } } });
|
|
3449
4113
|
const group = first3((await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups);
|
|
3450
4114
|
return json({
|
|
3451
|
-
offers: publicGiftOffers(
|
|
4115
|
+
offers: publicGiftOffers(control2?.document.giftOffers, Date.now()),
|
|
3452
4116
|
claims: buyerGiftClaims(rows2(found.giftClaims).sort((a, b) => Number(b.createdAt) - Number(a.createdAt)), Date.now()),
|
|
3453
4117
|
refundPolicyText: typeof group?.refundPolicyText === "string" ? group.refundPolicyText : ""
|
|
3454
4118
|
});
|
|
@@ -3464,13 +4128,13 @@ var handleGifts = async (req, url, env, ctx) => {
|
|
|
3464
4128
|
}
|
|
3465
4129
|
const purchaseId = typeof body.purchaseId === "string" ? body.purchaseId : "";
|
|
3466
4130
|
if (!/^[A-Za-z0-9_-]{16,128}$/.test(purchaseId)) return json({ error: "purchaseId is invalid" }, 400);
|
|
3467
|
-
const
|
|
3468
|
-
const offer = findGiftOffer(
|
|
4131
|
+
const control2 = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
|
|
4132
|
+
const offer = findGiftOffer(control2?.document.giftOffers, typeof body.offerId === "string" ? body.offerId : "", Date.now());
|
|
3469
4133
|
if (!offer) return json({ error: "gift offer is unavailable" }, 409);
|
|
3470
4134
|
const group = first3((await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups);
|
|
3471
4135
|
const stripeSecret = await getVaultSecret(db, "stripe_secret_key");
|
|
3472
4136
|
if (!group || !stripeSecret || typeof group.stripePublishableKey !== "string") return json({ error: "payments not configured" }, 503);
|
|
3473
|
-
const emailNormalized =
|
|
4137
|
+
const emailNormalized = normalizeChapterEmail(user.email);
|
|
3474
4138
|
const applications = rows2((await db.query({ applications: { $: { where: { emailNormalized }, limit: 20 } } })).applications);
|
|
3475
4139
|
const app = applications.sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0];
|
|
3476
4140
|
try {
|
|
@@ -3512,7 +4176,7 @@ var handleGifts = async (req, url, env, ctx) => {
|
|
|
3512
4176
|
return invalid();
|
|
3513
4177
|
}
|
|
3514
4178
|
}
|
|
3515
|
-
const owns = user ? claim.purchaserEmailNormalized ===
|
|
4179
|
+
const owns = user ? claim.purchaserEmailNormalized === normalizeChapterEmail(user.email) : claim.purchaserApplicationId === applicationId;
|
|
3516
4180
|
if (!owns) return invalid();
|
|
3517
4181
|
const rotated = await rotateGiftClaim(db, claim, Date.now());
|
|
3518
4182
|
return rotated ? json(rotated) : invalid();
|
|
@@ -4386,7 +5050,7 @@ var handleAdminComms = async (req, url, env, ctx) => {
|
|
|
4386
5050
|
|
|
4387
5051
|
// src/worker-routes-network.ts
|
|
4388
5052
|
var import_crm6 = require("@odla-ai/crm");
|
|
4389
|
-
var
|
|
5053
|
+
var import_db7 = require("@odla-ai/db");
|
|
4390
5054
|
|
|
4391
5055
|
// src/worker-network-fetch.ts
|
|
4392
5056
|
function targetFetcher(env, target) {
|
|
@@ -4404,8 +5068,8 @@ function fetchNetworkTarget(env, target, destination, init) {
|
|
|
4404
5068
|
|
|
4405
5069
|
// src/worker-network-push.ts
|
|
4406
5070
|
var import_crm5 = require("@odla-ai/crm");
|
|
4407
|
-
var
|
|
4408
|
-
async function pushNetworkRecord(db, env, ctx, target,
|
|
5071
|
+
var import_db6 = require("@odla-ai/db");
|
|
5072
|
+
async function pushNetworkRecord(db, env, ctx, target, record3) {
|
|
4409
5073
|
const secret = await getVaultSecret(db, target.secretName);
|
|
4410
5074
|
const crmDeps3 = {
|
|
4411
5075
|
crm: ctx.chapter.crm,
|
|
@@ -4416,7 +5080,7 @@ async function pushNetworkRecord(db, env, ctx, target, record2) {
|
|
|
4416
5080
|
if (!secret) {
|
|
4417
5081
|
const error = `vault secret "${target.secretName}" is missing`;
|
|
4418
5082
|
await (0, import_crm5.recordDeliveryAttempt)(crmDeps3, {
|
|
4419
|
-
recordId:
|
|
5083
|
+
recordId: record3.id,
|
|
4420
5084
|
targetId: target.id,
|
|
4421
5085
|
status: "failed",
|
|
4422
5086
|
payloadVersion: 2,
|
|
@@ -4426,7 +5090,7 @@ async function pushNetworkRecord(db, env, ctx, target, record2) {
|
|
|
4426
5090
|
}
|
|
4427
5091
|
let payload;
|
|
4428
5092
|
try {
|
|
4429
|
-
payload = sharedRecordFromCrm(ctx.chapter.crm,
|
|
5093
|
+
payload = sharedRecordFromCrm(ctx.chapter.crm, record3, target, ctx.chapter.id);
|
|
4430
5094
|
} catch (err) {
|
|
4431
5095
|
return {
|
|
4432
5096
|
id: target.id,
|
|
@@ -4438,7 +5102,7 @@ async function pushNetworkRecord(db, env, ctx, target, record2) {
|
|
|
4438
5102
|
const payloadBody = JSON.stringify(payload);
|
|
4439
5103
|
const destination = new URL("/api/network/shared", target.url);
|
|
4440
5104
|
try {
|
|
4441
|
-
const signed = await (0,
|
|
5105
|
+
const signed = await (0, import_db6.signFederatedRequest)({
|
|
4442
5106
|
secret,
|
|
4443
5107
|
sender: ctx.chapter.id,
|
|
4444
5108
|
method: "POST",
|
|
@@ -4455,7 +5119,7 @@ async function pushNetworkRecord(db, env, ctx, target, record2) {
|
|
|
4455
5119
|
if (!res.ok) {
|
|
4456
5120
|
const error = responseBody.error ?? "follower rejected the record";
|
|
4457
5121
|
await (0, import_crm5.recordDeliveryAttempt)(crmDeps3, {
|
|
4458
|
-
recordId:
|
|
5122
|
+
recordId: record3.id,
|
|
4459
5123
|
targetId: target.id,
|
|
4460
5124
|
status: "failed",
|
|
4461
5125
|
payloadVersion: 2,
|
|
@@ -4470,7 +5134,7 @@ async function pushNetworkRecord(db, env, ctx, target, record2) {
|
|
|
4470
5134
|
};
|
|
4471
5135
|
}
|
|
4472
5136
|
await (0, import_crm5.recordDeliveryAttempt)(crmDeps3, {
|
|
4473
|
-
recordId:
|
|
5137
|
+
recordId: record3.id,
|
|
4474
5138
|
targetId: target.id,
|
|
4475
5139
|
status: "delivered",
|
|
4476
5140
|
payloadVersion: 2,
|
|
@@ -4486,7 +5150,7 @@ async function pushNetworkRecord(db, env, ctx, target, record2) {
|
|
|
4486
5150
|
} catch (err) {
|
|
4487
5151
|
const error = err instanceof Error ? err.message : "delivery failed";
|
|
4488
5152
|
await (0, import_crm5.recordDeliveryAttempt)(crmDeps3, {
|
|
4489
|
-
recordId:
|
|
5153
|
+
recordId: record3.id,
|
|
4490
5154
|
targetId: target.id,
|
|
4491
5155
|
status: "failed",
|
|
4492
5156
|
payloadVersion: 2,
|
|
@@ -4523,7 +5187,7 @@ var handleNetworkSnapshot = async (req, url, env, ctx) => {
|
|
|
4523
5187
|
const db = ctx.makeDb(env);
|
|
4524
5188
|
const secret = await getVaultSecret(db, "network_share_secret");
|
|
4525
5189
|
if (!secret) return json({ error: "unauthorized" }, 401);
|
|
4526
|
-
const verified = await (0,
|
|
5190
|
+
const verified = await (0, import_db7.verifyFederatedRequest)(req, { secret });
|
|
4527
5191
|
if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
|
|
4528
5192
|
const types = await Promise.all(
|
|
4529
5193
|
Object.keys(ctx.chapter.crm.config.types).map((type) => (0, import_crm6.typeSummary)({ crm: ctx.chapter.crm, db }, type))
|
|
@@ -4541,7 +5205,7 @@ async function snapshotOne(db, env, ctx, target) {
|
|
|
4541
5205
|
if (!secret) return { id: target.id, name: target.name, url: target.url, available: false, error: "edge secret is missing" };
|
|
4542
5206
|
const destination = new URL("/api/network/snapshot", target.url);
|
|
4543
5207
|
try {
|
|
4544
|
-
const headers = await (0,
|
|
5208
|
+
const headers = await (0, import_db7.signFederatedRequest)({
|
|
4545
5209
|
secret,
|
|
4546
5210
|
sender: ctx.chapter.id,
|
|
4547
5211
|
method: "GET",
|
|
@@ -4624,17 +5288,17 @@ var handleAdminNetworkPush = async (req, url, env, ctx) => {
|
|
|
4624
5288
|
if (requested.size !== body.targetIds.length) return json({ error: "targetIds must contain unique strings" }, 400);
|
|
4625
5289
|
const targets = ctx.chapter.network.targets.filter((target) => requested.has(target.id));
|
|
4626
5290
|
if (targets.length !== requested.size) return json({ error: "one or more targetIds are not configured" }, 400);
|
|
4627
|
-
const
|
|
4628
|
-
if (!
|
|
5291
|
+
const record3 = await (0, import_crm6.getRecord)({ crm: ctx.chapter.crm, db: got.db }, body.recordId);
|
|
5292
|
+
if (!record3) return json({ error: "record not found" }, 404);
|
|
4629
5293
|
const results = await Promise.all(
|
|
4630
|
-
targets.map((target) => pushNetworkRecord(got.db, env, ctx, target,
|
|
5294
|
+
targets.map((target) => pushNetworkRecord(got.db, env, ctx, target, record3))
|
|
4631
5295
|
);
|
|
4632
5296
|
return json({ ok: results.every((result) => result.ok), results });
|
|
4633
5297
|
};
|
|
4634
5298
|
|
|
4635
5299
|
// src/worker-routes-network-records.ts
|
|
4636
5300
|
var import_crm7 = require("@odla-ai/crm");
|
|
4637
|
-
var
|
|
5301
|
+
var import_db8 = require("@odla-ai/db");
|
|
4638
5302
|
var IDENTIFIER = /^[a-z][a-zA-Z0-9_]*$/;
|
|
4639
5303
|
var MAX_PAGE = 50;
|
|
4640
5304
|
var MAX_OFFSET = 1e4;
|
|
@@ -4652,18 +5316,18 @@ function integer(value, fallback, min, max) {
|
|
|
4652
5316
|
const parsed = Number(value);
|
|
4653
5317
|
return Number.isSafeInteger(parsed) && parsed >= min && parsed <= max ? parsed : null;
|
|
4654
5318
|
}
|
|
4655
|
-
function projectRecord(
|
|
5319
|
+
function projectRecord(record3, allowed2) {
|
|
4656
5320
|
const fields = Object.fromEntries(
|
|
4657
|
-
|
|
5321
|
+
allowed2.flatMap((name) => Object.prototype.hasOwnProperty.call(record3.fields, name) ? [[name, record3.fields[name]]] : [])
|
|
4658
5322
|
);
|
|
4659
5323
|
return {
|
|
4660
|
-
id:
|
|
4661
|
-
type:
|
|
4662
|
-
name:
|
|
4663
|
-
...
|
|
5324
|
+
id: record3.id,
|
|
5325
|
+
type: record3.type,
|
|
5326
|
+
name: record3.name,
|
|
5327
|
+
...record3.stage ? { stage: record3.stage } : {},
|
|
4664
5328
|
fields,
|
|
4665
|
-
createdAt:
|
|
4666
|
-
updatedAt:
|
|
5329
|
+
createdAt: record3.createdAt,
|
|
5330
|
+
updatedAt: record3.updatedAt
|
|
4667
5331
|
};
|
|
4668
5332
|
}
|
|
4669
5333
|
var handleNetworkRecords = async (req, url, env, ctx) => {
|
|
@@ -4672,15 +5336,15 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
|
|
|
4672
5336
|
const secret = await getVaultSecret(db, "network_share_secret");
|
|
4673
5337
|
const readers = ctx.chapter.network.readers;
|
|
4674
5338
|
if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
|
|
4675
|
-
const verified = await (0,
|
|
5339
|
+
const verified = await (0, import_db8.verifyFederatedRequest)(req, {
|
|
4676
5340
|
secret,
|
|
4677
5341
|
senders: readers.map((reader2) => reader2.id)
|
|
4678
5342
|
});
|
|
4679
5343
|
if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
|
|
4680
5344
|
const reader = readers.find((candidate) => candidate.id === verified.sender);
|
|
4681
5345
|
const type = url.searchParams.get("type") ?? "";
|
|
4682
|
-
const
|
|
4683
|
-
if (!IDENTIFIER.test(type) || !
|
|
5346
|
+
const allowed2 = reader?.fields[type];
|
|
5347
|
+
if (!IDENTIFIER.test(type) || !allowed2) return json({ error: "record type is not shared with this reader" }, 403);
|
|
4684
5348
|
const limit = integer(url.searchParams.get("limit"), 25, 1, MAX_PAGE);
|
|
4685
5349
|
const offset = integer(url.searchParams.get("offset"), 0, 0, MAX_OFFSET);
|
|
4686
5350
|
const search = (url.searchParams.get("search") ?? "").trim();
|
|
@@ -4701,7 +5365,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
|
|
|
4701
5365
|
version: 1,
|
|
4702
5366
|
site: { id: ctx.chapter.id, name: ctx.chapter.name },
|
|
4703
5367
|
type,
|
|
4704
|
-
records: page.records.map((
|
|
5368
|
+
records: page.records.map((record3) => projectRecord(record3, allowed2)),
|
|
4705
5369
|
total: page.total,
|
|
4706
5370
|
limit: page.limit,
|
|
4707
5371
|
offset: page.offset
|
|
@@ -4717,7 +5381,7 @@ async function readTargetRecords(db, env, ctx, target, query) {
|
|
|
4717
5381
|
const destination = new URL("/api/network/records", target.url);
|
|
4718
5382
|
destination.search = query.toString();
|
|
4719
5383
|
try {
|
|
4720
|
-
const headers = await (0,
|
|
5384
|
+
const headers = await (0, import_db8.signFederatedRequest)({
|
|
4721
5385
|
secret,
|
|
4722
5386
|
sender: ctx.chapter.id,
|
|
4723
5387
|
method: "GET",
|
|
@@ -4852,7 +5516,7 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
|
|
|
4852
5516
|
|
|
4853
5517
|
// src/worker-routes-network-shared-notes.ts
|
|
4854
5518
|
var import_crm8 = require("@odla-ai/crm");
|
|
4855
|
-
var
|
|
5519
|
+
var import_db9 = require("@odla-ai/db");
|
|
4856
5520
|
var IDENTIFIER3 = /^[a-z][a-zA-Z0-9_]*$/;
|
|
4857
5521
|
var RECORD_ID2 = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
|
|
4858
5522
|
var MUTATION_ID = /^[A-Za-z0-9_-]{16,128}$/;
|
|
@@ -4911,7 +5575,7 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
4911
5575
|
const secret = await getVaultSecret(db, "network_share_secret");
|
|
4912
5576
|
const readers = ctx.chapter.network.readers;
|
|
4913
5577
|
if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
|
|
4914
|
-
const verified = await (0,
|
|
5578
|
+
const verified = await (0, import_db9.verifyFederatedRequest)(req, {
|
|
4915
5579
|
secret,
|
|
4916
5580
|
senders: readers.map((reader2) => reader2.id)
|
|
4917
5581
|
});
|
|
@@ -4922,8 +5586,8 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
4922
5586
|
if (!IDENTIFIER3.test(recordType) || !RECORD_ID2.test(recordId) || !reader?.sharedNotes.includes(recordType)) {
|
|
4923
5587
|
return json({ error: "shared notes are not enabled for this reader and record type" }, 403);
|
|
4924
5588
|
}
|
|
4925
|
-
const
|
|
4926
|
-
if (!
|
|
5589
|
+
const record3 = await (0, import_crm8.getRecord)({ crm: ctx.chapter.crm, db }, recordId);
|
|
5590
|
+
if (!record3 || record3.type !== recordType) return json({ error: "record not found" }, 404);
|
|
4927
5591
|
if (req.method === "GET") {
|
|
4928
5592
|
return json({
|
|
4929
5593
|
version: 1,
|
|
@@ -5008,7 +5672,7 @@ var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
5008
5672
|
const destination = new URL("/api/network/shared-notes", target.url);
|
|
5009
5673
|
destination.search = new URLSearchParams({ type: recordType, recordId }).toString();
|
|
5010
5674
|
try {
|
|
5011
|
-
const headers = await (0,
|
|
5675
|
+
const headers = await (0, import_db9.signFederatedRequest)({
|
|
5012
5676
|
secret,
|
|
5013
5677
|
sender: ctx.chapter.id,
|
|
5014
5678
|
method: req.method,
|
|
@@ -5036,31 +5700,17 @@ var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
5036
5700
|
|
|
5037
5701
|
// src/worker-routes-formation.ts
|
|
5038
5702
|
var import_crm9 = require("@odla-ai/crm");
|
|
5039
|
-
|
|
5040
|
-
// src/formation.ts
|
|
5041
|
-
function formationFields(crm, formation) {
|
|
5042
|
-
const def = crm.type(formation.type);
|
|
5043
|
-
return [...formation.required, ...formation.optional].map((id) => {
|
|
5044
|
-
const field = def.fields[id];
|
|
5045
|
-
return {
|
|
5046
|
-
id,
|
|
5047
|
-
label: field.label ?? id,
|
|
5048
|
-
type: field.type,
|
|
5049
|
-
required: formation.required.includes(id),
|
|
5050
|
-
...field.options ? { options: field.options } : {}
|
|
5051
|
-
};
|
|
5052
|
-
});
|
|
5053
|
-
}
|
|
5054
|
-
|
|
5055
|
-
// src/worker-routes-formation.ts
|
|
5056
5703
|
var SUBMISSION = /^[A-Za-z0-9_-]{8,128}$/;
|
|
5057
5704
|
var handleFormation = async (req, url, env, ctx) => {
|
|
5058
5705
|
const formation = ctx.chapter.formation;
|
|
5059
5706
|
if (!formation.enabled) return null;
|
|
5060
5707
|
if (req.method === "GET" && url.pathname === "/api/formation/config") {
|
|
5708
|
+
const fieldContract = chapterFlowFieldContract(ctx.chapter, "formation");
|
|
5061
5709
|
return json({
|
|
5062
5710
|
type: formation.type,
|
|
5063
|
-
|
|
5711
|
+
fieldContract,
|
|
5712
|
+
// Backward compatibility for hosts that consumed the original array.
|
|
5713
|
+
fields: fieldContract.fields
|
|
5064
5714
|
});
|
|
5065
5715
|
}
|
|
5066
5716
|
if (req.method !== "POST" || url.pathname !== "/api/formation/applications") return null;
|
|
@@ -5077,15 +5727,15 @@ var handleFormation = async (req, url, env, ctx) => {
|
|
|
5077
5727
|
if (submissionId && !SUBMISSION.test(submissionId)) {
|
|
5078
5728
|
return json({ error: "submissionId must be an 8\u2013128 character base64url value" }, 400);
|
|
5079
5729
|
}
|
|
5080
|
-
const
|
|
5081
|
-
const unknown = Object.keys(body).filter((field) => field !== "submissionId" && !
|
|
5730
|
+
const allowed2 = /* @__PURE__ */ new Set([...formation.required, ...formation.optional]);
|
|
5731
|
+
const unknown = Object.keys(body).filter((field) => field !== "submissionId" && !allowed2.has(field));
|
|
5082
5732
|
if (unknown.length) return json({ error: `field "${unknown[0]}" is not accepted` }, 400);
|
|
5083
5733
|
for (const field of formation.required) {
|
|
5084
5734
|
const value = body[field];
|
|
5085
5735
|
if (typeof value !== "string" || value.trim() === "") return json({ error: `${field} is required` }, 400);
|
|
5086
5736
|
}
|
|
5087
5737
|
const input = {};
|
|
5088
|
-
for (const field of
|
|
5738
|
+
for (const field of allowed2) {
|
|
5089
5739
|
const value = body[field];
|
|
5090
5740
|
if (value === void 0) continue;
|
|
5091
5741
|
const cap = formation.maxLen[field] ?? formation.defaultMaxLen;
|
|
@@ -5129,11 +5779,11 @@ var handleFormation = async (req, url, env, ctx) => {
|
|
|
5129
5779
|
};
|
|
5130
5780
|
|
|
5131
5781
|
// src/signup-control-delivery.ts
|
|
5132
|
-
var
|
|
5782
|
+
var import_db10 = require("@odla-ai/db");
|
|
5133
5783
|
async function deliverSignupControl(options) {
|
|
5134
5784
|
const destination = new URL(SIGNUP_CONTROL_PATH, options.targetUrl);
|
|
5135
5785
|
const body = JSON.stringify(options.envelope);
|
|
5136
|
-
const signed = await (0,
|
|
5786
|
+
const signed = await (0, import_db10.signFederatedRequest)({
|
|
5137
5787
|
secret: options.secret,
|
|
5138
5788
|
sender: options.sender,
|
|
5139
5789
|
method: "POST",
|
|
@@ -5153,12 +5803,37 @@ async function deliverSignupControl(options) {
|
|
|
5153
5803
|
return result;
|
|
5154
5804
|
}
|
|
5155
5805
|
|
|
5806
|
+
// src/member-content-delivery.ts
|
|
5807
|
+
var import_db11 = require("@odla-ai/db");
|
|
5808
|
+
async function deliverMemberContent(options) {
|
|
5809
|
+
const destination = new URL(MEMBER_CONTENT_CONTROL_PATH, options.targetUrl);
|
|
5810
|
+
const body = JSON.stringify(options.envelope);
|
|
5811
|
+
const signed = await (0, import_db11.signFederatedRequest)({
|
|
5812
|
+
secret: options.secret,
|
|
5813
|
+
sender: options.sender,
|
|
5814
|
+
method: "POST",
|
|
5815
|
+
url: destination,
|
|
5816
|
+
body
|
|
5817
|
+
});
|
|
5818
|
+
const response = await (options.fetch ?? globalThis.fetch)(destination, {
|
|
5819
|
+
method: "POST",
|
|
5820
|
+
headers: { ...signed, "content-type": "application/json" },
|
|
5821
|
+
body
|
|
5822
|
+
});
|
|
5823
|
+
const result = await response.json();
|
|
5824
|
+
if (!response.ok || !("ok" in result) || result.ok !== true) {
|
|
5825
|
+
const failure = result;
|
|
5826
|
+
throw new Error(`member content rejected (${response.status}): ${failure.reason ?? failure.error ?? "unknown"}`);
|
|
5827
|
+
}
|
|
5828
|
+
return result;
|
|
5829
|
+
}
|
|
5830
|
+
|
|
5156
5831
|
// src/admission-grants-delivery.ts
|
|
5157
|
-
var
|
|
5832
|
+
var import_db12 = require("@odla-ai/db");
|
|
5158
5833
|
async function deliverAdmissionGrants(options) {
|
|
5159
5834
|
const destination = new URL(ADMISSION_GRANTS_PATH, options.targetUrl);
|
|
5160
5835
|
const body = JSON.stringify(options.envelope);
|
|
5161
|
-
const signed = await (0,
|
|
5836
|
+
const signed = await (0, import_db12.signFederatedRequest)({
|
|
5162
5837
|
secret: options.secret,
|
|
5163
5838
|
sender: options.sender,
|
|
5164
5839
|
method: "POST",
|
|
@@ -5317,12 +5992,14 @@ var BUILTIN_ROUTES = [
|
|
|
5317
5992
|
handleCrm,
|
|
5318
5993
|
handleNetworkShared,
|
|
5319
5994
|
handleSignupControl,
|
|
5995
|
+
handleMemberContentControl,
|
|
5320
5996
|
handleAdmissionGrants,
|
|
5321
5997
|
handleNetworkSnapshot,
|
|
5322
5998
|
handleNetworkRecords,
|
|
5323
5999
|
handleNetworkSharedNotes,
|
|
5324
6000
|
handleFormation,
|
|
5325
6001
|
handleMember,
|
|
6002
|
+
handleMemberContent,
|
|
5326
6003
|
handleGifts,
|
|
5327
6004
|
handleSchedule,
|
|
5328
6005
|
handlePayments,
|
|
@@ -5338,6 +6015,7 @@ var BUILTIN_ROUTES = [
|
|
|
5338
6015
|
// Aggregation
|
|
5339
6016
|
handleAdminDashboard,
|
|
5340
6017
|
handleAdminBilling,
|
|
6018
|
+
handleAdminMemberContent,
|
|
5341
6019
|
// Lifecycle actions
|
|
5342
6020
|
handleAdminMeetingReschedule,
|
|
5343
6021
|
handleAdminMeetingCancel,
|