@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.js
CHANGED
|
@@ -167,204 +167,10 @@ async function applicationIdForSubmission(submissionId) {
|
|
|
167
167
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
-
// src/
|
|
171
|
-
|
|
172
|
-
var ADMISSION_GRANTS_PATH = "/api/admission-grants";
|
|
173
|
-
|
|
174
|
-
// src/signup-control-canonical.ts
|
|
175
|
-
var encoder = new TextEncoder();
|
|
176
|
-
function canonicalValue(value) {
|
|
177
|
-
if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
|
|
178
|
-
return JSON.stringify(value);
|
|
179
|
-
}
|
|
180
|
-
if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
|
|
181
|
-
if (typeof value !== "object") throw new TypeError("signup control contains an unsupported value");
|
|
182
|
-
const record2 = value;
|
|
183
|
-
const entries = Object.keys(record2).sort().flatMap((key) => {
|
|
184
|
-
const item = record2[key];
|
|
185
|
-
return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue(item)}`];
|
|
186
|
-
});
|
|
187
|
-
return `{${entries.join(",")}}`;
|
|
188
|
-
}
|
|
189
|
-
function canonicalSignupControl(value) {
|
|
190
|
-
return canonicalValue(value);
|
|
191
|
-
}
|
|
192
|
-
async function signupControlDigest(document) {
|
|
193
|
-
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(canonicalSignupControl(document)));
|
|
194
|
-
return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// src/admission-grants.ts
|
|
198
|
-
var ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
|
|
199
|
-
var MAX_GRANTS = 2e3;
|
|
200
|
-
var MAX_NOTE = 2e3;
|
|
201
|
-
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
202
|
-
var isAdmissionEmail = (value) => EMAIL_RE.test(value);
|
|
203
|
-
function normalizeAdmissionEmail(email) {
|
|
170
|
+
// src/email-identity.ts
|
|
171
|
+
function normalizeChapterEmail(email) {
|
|
204
172
|
return email.trim().toLowerCase();
|
|
205
173
|
}
|
|
206
|
-
function validateAdmissionGrantDocument(input) {
|
|
207
|
-
const errors = [];
|
|
208
|
-
const root = input && typeof input === "object" ? input : {};
|
|
209
|
-
if (root.schemaVersion !== ADMISSION_GRANTS_SCHEMA_VERSION) errors.push("document.schemaVersion must be 1");
|
|
210
|
-
const revision = Number(root.revision);
|
|
211
|
-
const generatedAt = Number(root.generatedAt);
|
|
212
|
-
if (!Number.isSafeInteger(revision) || revision < 1) errors.push("document.revision must be a positive integer");
|
|
213
|
-
if (!Number.isFinite(generatedAt) || generatedAt <= 0) errors.push("document.generatedAt must be epoch milliseconds");
|
|
214
|
-
const source = root.source && typeof root.source === "object" ? root.source : {};
|
|
215
|
-
const target = root.target && typeof root.target === "object" ? root.target : {};
|
|
216
|
-
const sourceId = typeof source.appId === "string" ? source.appId : "";
|
|
217
|
-
const sourceActor = typeof source.actor === "string" ? source.actor.trim() : "";
|
|
218
|
-
const chapterId = typeof target.chapterId === "string" ? target.chapterId : "";
|
|
219
|
-
const environment = typeof target.environment === "string" ? target.environment : "";
|
|
220
|
-
if (!ID.test(sourceId)) errors.push("document.source.appId must be an identifier");
|
|
221
|
-
if (!sourceActor || sourceActor.length > 256) errors.push("document.source.actor must be 1-256 characters");
|
|
222
|
-
if (!ID.test(chapterId)) errors.push("document.target.chapterId must be an identifier");
|
|
223
|
-
if (!ID.test(environment)) errors.push("document.target.environment must be an identifier");
|
|
224
|
-
if (!Array.isArray(root.grants)) errors.push("document.grants must be an array");
|
|
225
|
-
const rawGrants = Array.isArray(root.grants) ? root.grants : [];
|
|
226
|
-
if (rawGrants.length > MAX_GRANTS) errors.push(`document.grants supports at most ${MAX_GRANTS} entries`);
|
|
227
|
-
const grants = [];
|
|
228
|
-
const seenIds = /* @__PURE__ */ new Set();
|
|
229
|
-
const seenEmails = /* @__PURE__ */ new Set();
|
|
230
|
-
for (const [index, raw] of rawGrants.slice(0, MAX_GRANTS).entries()) {
|
|
231
|
-
const grant = raw && typeof raw === "object" ? raw : {};
|
|
232
|
-
const id = typeof grant.id === "string" ? grant.id : "";
|
|
233
|
-
const email = typeof grant.email === "string" ? grant.email.trim() : "";
|
|
234
|
-
const normalized = normalizeAdmissionEmail(email);
|
|
235
|
-
const decision = grant.decision;
|
|
236
|
-
const note = typeof grant.note === "string" ? grant.note.trim() : void 0;
|
|
237
|
-
const expiresAt = grant.expiresAt === void 0 ? void 0 : Number(grant.expiresAt);
|
|
238
|
-
if (!ID.test(id)) errors.push(`document.grants[${index}].id must be an identifier`);
|
|
239
|
-
if (seenIds.has(id)) errors.push(`document.grants repeats id "${id}"`);
|
|
240
|
-
if (!isAdmissionEmail(normalized)) errors.push(`document.grants[${index}].email must be valid`);
|
|
241
|
-
if (seenEmails.has(normalized)) errors.push(`document.grants repeats email "${normalized}"`);
|
|
242
|
-
if (decision !== "waive_interview" && decision !== "require_interview") {
|
|
243
|
-
errors.push(`document.grants[${index}].decision is invalid`);
|
|
244
|
-
}
|
|
245
|
-
if (note && note.length > MAX_NOTE) errors.push(`document.grants[${index}].note is too long`);
|
|
246
|
-
if (expiresAt !== void 0 && (!Number.isFinite(expiresAt) || expiresAt <= 0)) {
|
|
247
|
-
errors.push(`document.grants[${index}].expiresAt must be epoch milliseconds`);
|
|
248
|
-
}
|
|
249
|
-
seenIds.add(id);
|
|
250
|
-
seenEmails.add(normalized);
|
|
251
|
-
grants.push({
|
|
252
|
-
id,
|
|
253
|
-
email,
|
|
254
|
-
decision,
|
|
255
|
-
...note ? { note } : {},
|
|
256
|
-
...expiresAt !== void 0 ? { expiresAt } : {}
|
|
257
|
-
});
|
|
258
|
-
}
|
|
259
|
-
if (errors.length) return { ok: false, errors };
|
|
260
|
-
return {
|
|
261
|
-
ok: true,
|
|
262
|
-
value: {
|
|
263
|
-
schemaVersion: ADMISSION_GRANTS_SCHEMA_VERSION,
|
|
264
|
-
revision,
|
|
265
|
-
generatedAt,
|
|
266
|
-
source: { appId: sourceId, actor: sourceActor },
|
|
267
|
-
target: { chapterId, environment },
|
|
268
|
-
grants
|
|
269
|
-
}
|
|
270
|
-
};
|
|
271
|
-
}
|
|
272
|
-
var rows = (value) => Array.isArray(value) ? value : [];
|
|
273
|
-
function asGrant(row) {
|
|
274
|
-
if (typeof row.id !== "string" || typeof row.emailNormalized !== "string") return null;
|
|
275
|
-
if (row.sourceScope !== "local" && row.sourceScope !== "parent") return null;
|
|
276
|
-
if (row.decision !== "waive_interview" && row.decision !== "require_interview") return null;
|
|
277
|
-
return row;
|
|
278
|
-
}
|
|
279
|
-
async function findAdmissionGrant(db, targetId, email, now) {
|
|
280
|
-
const emailNormalized = normalizeAdmissionEmail(email);
|
|
281
|
-
if (!isAdmissionEmail(emailNormalized)) return null;
|
|
282
|
-
const result = await db.query({ admissionGrants: { $: { where: { emailNormalized }, limit: 100 } } });
|
|
283
|
-
const active = rows(result.admissionGrants).map(asGrant).filter((grant) => Boolean(
|
|
284
|
-
grant && grant.targetId === targetId && grant.active === true && !grant.revokedAt && (!grant.expiresAt || grant.expiresAt > now) && !grant.consumedApplicationId
|
|
285
|
-
)).sort((a, b) => {
|
|
286
|
-
const scope = Number(b.sourceScope === "local") - Number(a.sourceScope === "local");
|
|
287
|
-
return scope || b.createdAt - a.createdAt;
|
|
288
|
-
});
|
|
289
|
-
return active[0] ?? null;
|
|
290
|
-
}
|
|
291
|
-
async function hasAdmissionRevision(db, sourceId, targetId, environment) {
|
|
292
|
-
const where = { sourceId, targetId, environment };
|
|
293
|
-
if (db.aggregate) {
|
|
294
|
-
return Number((await db.aggregate("admissionGrantRevisions", { count: true }, { where })).count ?? 0) > 0;
|
|
295
|
-
}
|
|
296
|
-
const found = await db.query({ admissionGrantRevisions: {} });
|
|
297
|
-
return rows(found.admissionGrantRevisions).some((row) => row.sourceId === sourceId && row.targetId === targetId && row.environment === environment);
|
|
298
|
-
}
|
|
299
|
-
async function loadLatestAdmissionRevision(db, sourceId, targetId, environment) {
|
|
300
|
-
if (!await hasAdmissionRevision(db, sourceId, targetId, environment)) return null;
|
|
301
|
-
const result = await db.query({ admissionGrantRevisions: { $: {
|
|
302
|
-
where: { sourceId, targetId, environment },
|
|
303
|
-
order: { revision: "desc" },
|
|
304
|
-
limit: 1
|
|
305
|
-
} } });
|
|
306
|
-
const row = rows(result.admissionGrantRevisions).sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0];
|
|
307
|
-
return row ? row : null;
|
|
308
|
-
}
|
|
309
|
-
async function materializeAdmissionGrants(db, envelope, previous, receivedAt) {
|
|
310
|
-
const { document, digest } = envelope;
|
|
311
|
-
const current = rows((await db.query({ admissionGrants: { $: {
|
|
312
|
-
where: { sourceScope: "parent", sourceId: document.source.appId, targetId: document.target.chapterId },
|
|
313
|
-
limit: MAX_GRANTS
|
|
314
|
-
} } })).admissionGrants);
|
|
315
|
-
const incomingIds = new Set(document.grants.map((grant) => `admission:parent:${document.source.appId}:${grant.id}`));
|
|
316
|
-
const ops = [];
|
|
317
|
-
for (const row of current) {
|
|
318
|
-
const id = String(row.id ?? "");
|
|
319
|
-
if (id && !incomingIds.has(id)) ops.push({ t: "update", ns: "admissionGrants", id, attrs: {
|
|
320
|
-
active: false,
|
|
321
|
-
revokedAt: receivedAt,
|
|
322
|
-
revokedBy: document.source.actor,
|
|
323
|
-
updatedAt: receivedAt,
|
|
324
|
-
updatedBy: document.source.actor
|
|
325
|
-
} });
|
|
326
|
-
}
|
|
327
|
-
for (const grant of document.grants) {
|
|
328
|
-
const id = `admission:parent:${document.source.appId}:${grant.id}`;
|
|
329
|
-
const existing = current.find((row) => row.id === id);
|
|
330
|
-
ops.push({ t: "update", ns: "admissionGrants", id, attrs: {
|
|
331
|
-
id,
|
|
332
|
-
sourceGrantId: grant.id,
|
|
333
|
-
sourceScope: "parent",
|
|
334
|
-
sourceId: document.source.appId,
|
|
335
|
-
targetId: document.target.chapterId,
|
|
336
|
-
email: grant.email,
|
|
337
|
-
emailNormalized: normalizeAdmissionEmail(grant.email),
|
|
338
|
-
decision: grant.decision,
|
|
339
|
-
...grant.note ? { note: grant.note } : {},
|
|
340
|
-
...grant.expiresAt ? { expiresAt: grant.expiresAt } : {},
|
|
341
|
-
active: true,
|
|
342
|
-
sourceRevision: document.revision,
|
|
343
|
-
createdAt: Number(existing?.createdAt ?? receivedAt),
|
|
344
|
-
createdBy: String(existing?.createdBy ?? document.source.actor),
|
|
345
|
-
updatedAt: receivedAt,
|
|
346
|
-
updatedBy: document.source.actor
|
|
347
|
-
} });
|
|
348
|
-
const retract = [
|
|
349
|
-
...!grant.note && existing?.note !== void 0 ? ["note"] : [],
|
|
350
|
-
...grant.expiresAt === void 0 && existing?.expiresAt !== void 0 ? ["expiresAt"] : []
|
|
351
|
-
];
|
|
352
|
-
if (retract.length) ops.push({ t: "retract", ns: "admissionGrants", id, attrs: retract });
|
|
353
|
-
}
|
|
354
|
-
if (previous?.active) ops.push({ t: "update", ns: "admissionGrantRevisions", id: previous.id, attrs: { active: false } });
|
|
355
|
-
const revisionId = `admission-revision:${document.source.appId}:${document.target.environment}:${document.revision}`;
|
|
356
|
-
ops.push({ t: "update", ns: "admissionGrantRevisions", id: revisionId, attrs: {
|
|
357
|
-
id: revisionId,
|
|
358
|
-
sourceId: document.source.appId,
|
|
359
|
-
targetId: document.target.chapterId,
|
|
360
|
-
environment: document.target.environment,
|
|
361
|
-
revision: document.revision,
|
|
362
|
-
digest,
|
|
363
|
-
active: true,
|
|
364
|
-
receivedAt
|
|
365
|
-
} });
|
|
366
|
-
await db.transact(ops, { mutationId: `admission-grants:${document.source.appId}:${document.target.environment}:${document.revision}:${digest}` });
|
|
367
|
-
}
|
|
368
174
|
|
|
369
175
|
// src/member.ts
|
|
370
176
|
var DEFAULT_REQUIRED = ["firstName", "lastName", "email", "referral", "whoYouAre", "message"];
|
|
@@ -417,9 +223,9 @@ function resolveApplication(a) {
|
|
|
417
223
|
validateEmail: a?.validateEmail ?? true
|
|
418
224
|
};
|
|
419
225
|
}
|
|
420
|
-
var
|
|
226
|
+
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
421
227
|
function isValidEmail(value) {
|
|
422
|
-
return typeof value === "string" &&
|
|
228
|
+
return typeof value === "string" && EMAIL_RE.test(value);
|
|
423
229
|
}
|
|
424
230
|
function clampArray(value, max) {
|
|
425
231
|
if (!Array.isArray(value)) return value;
|
|
@@ -431,14 +237,14 @@ function hasDisclaimerAck(fields) {
|
|
|
431
237
|
var IDENTITY_FIELDS = /* @__PURE__ */ new Set(["email", "firstName", "lastName"]);
|
|
432
238
|
function applicantProfile(chapter, fields) {
|
|
433
239
|
const app = chapter.application;
|
|
434
|
-
const
|
|
240
|
+
const allowed2 = (f) => app.profileFields.includes(f);
|
|
435
241
|
const profile = {};
|
|
436
242
|
for (const f of [...app.required, ...app.optional]) {
|
|
437
|
-
if (IDENTITY_FIELDS.has(f) || !
|
|
243
|
+
if (IDENTITY_FIELDS.has(f) || !allowed2(f)) continue;
|
|
438
244
|
const v = fields[f];
|
|
439
245
|
if (typeof v === "string" && v.trim() !== "") profile[f] = v.trim();
|
|
440
246
|
}
|
|
441
|
-
if (fields.focus !== void 0 &&
|
|
247
|
+
if (fields.focus !== void 0 && allowed2("focus")) profile.focus = clampArray(fields.focus, app.maxArrayLen);
|
|
442
248
|
return Object.keys(profile).length > 0 ? profile : void 0;
|
|
443
249
|
}
|
|
444
250
|
async function submitApplication(db, chapter, fields, opts) {
|
|
@@ -454,7 +260,7 @@ async function submitApplication(db, chapter, fields, opts) {
|
|
|
454
260
|
const cap = app.maxLen[f] ?? app.defaultMaxLen;
|
|
455
261
|
if (typeof v === "string" && v.length > cap) return { ok: false, error: `${f} exceeds ${cap} characters` };
|
|
456
262
|
}
|
|
457
|
-
if (app.validateEmail && typeof fields.email === "string" && !isValidEmail(fields.email)) {
|
|
263
|
+
if (app.validateEmail && typeof fields.email === "string" && !isValidEmail(fields.email.trim())) {
|
|
458
264
|
return { ok: false, error: "email must be a valid email address" };
|
|
459
265
|
}
|
|
460
266
|
const acked = hasDisclaimerAck(fields);
|
|
@@ -470,7 +276,7 @@ async function submitApplication(db, chapter, fields, opts) {
|
|
|
470
276
|
if (fields.focus !== void 0) row.focus = clampArray(fields.focus, app.maxArrayLen);
|
|
471
277
|
if (opts.groupId) row.groupId = opts.groupId;
|
|
472
278
|
if (typeof fields.email === "string" && isValidEmail(fields.email.trim())) {
|
|
473
|
-
row.emailNormalized =
|
|
279
|
+
row.emailNormalized = normalizeChapterEmail(fields.email);
|
|
474
280
|
}
|
|
475
281
|
if (opts.admissionGrant?.decision === "waive_interview") {
|
|
476
282
|
row.admissionGrantId = opts.admissionGrant.id;
|
|
@@ -687,14 +493,289 @@ function meetingCreateRow(i) {
|
|
|
687
493
|
createdAt: i.createdAt
|
|
688
494
|
};
|
|
689
495
|
}
|
|
690
|
-
function meetingRescheduleUpdate(startAt, endAt) {
|
|
691
|
-
return { startAt, endAt, drift: "none" };
|
|
496
|
+
function meetingRescheduleUpdate(startAt, endAt) {
|
|
497
|
+
return { startAt, endAt, drift: "none" };
|
|
498
|
+
}
|
|
499
|
+
function applicationBookingUpdate(currentStatus, startAt, htmlLink) {
|
|
500
|
+
return {
|
|
501
|
+
meetingAt: startAt,
|
|
502
|
+
...htmlLink ? { meetingLink: htmlLink } : {},
|
|
503
|
+
...currentStatus !== "call_scheduled" ? { status: "call_scheduled" } : {}
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// src/admission-grants-contract.ts
|
|
508
|
+
var ADMISSION_GRANTS_SCHEMA_VERSION = 1;
|
|
509
|
+
var ADMISSION_GRANTS_PATH = "/api/admission-grants";
|
|
510
|
+
|
|
511
|
+
// src/signup-control-canonical.ts
|
|
512
|
+
var encoder = new TextEncoder();
|
|
513
|
+
function canonicalValue(value) {
|
|
514
|
+
if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
|
|
515
|
+
return JSON.stringify(value);
|
|
516
|
+
}
|
|
517
|
+
if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
|
|
518
|
+
if (typeof value !== "object") throw new TypeError("signup control contains an unsupported value");
|
|
519
|
+
const record3 = value;
|
|
520
|
+
const entries = Object.keys(record3).sort().flatMap((key) => {
|
|
521
|
+
const item = record3[key];
|
|
522
|
+
return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue(item)}`];
|
|
523
|
+
});
|
|
524
|
+
return `{${entries.join(",")}}`;
|
|
525
|
+
}
|
|
526
|
+
function canonicalSignupControl(value) {
|
|
527
|
+
return canonicalValue(value);
|
|
528
|
+
}
|
|
529
|
+
async function signupControlDigest(document) {
|
|
530
|
+
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(canonicalSignupControl(document)));
|
|
531
|
+
return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// src/admission-grants.ts
|
|
535
|
+
var ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
|
|
536
|
+
var MAX_GRANTS = 2e3;
|
|
537
|
+
var MAX_NOTE = 2e3;
|
|
538
|
+
var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
539
|
+
var isAdmissionEmail = (value) => EMAIL_RE2.test(value);
|
|
540
|
+
function normalizeAdmissionEmail(email) {
|
|
541
|
+
return normalizeChapterEmail(email);
|
|
542
|
+
}
|
|
543
|
+
function validateAdmissionGrantDocument(input) {
|
|
544
|
+
const errors = [];
|
|
545
|
+
const root = input && typeof input === "object" ? input : {};
|
|
546
|
+
if (root.schemaVersion !== ADMISSION_GRANTS_SCHEMA_VERSION) errors.push("document.schemaVersion must be 1");
|
|
547
|
+
const revision = Number(root.revision);
|
|
548
|
+
const generatedAt = Number(root.generatedAt);
|
|
549
|
+
if (!Number.isSafeInteger(revision) || revision < 1) errors.push("document.revision must be a positive integer");
|
|
550
|
+
if (!Number.isFinite(generatedAt) || generatedAt <= 0) errors.push("document.generatedAt must be epoch milliseconds");
|
|
551
|
+
const source = root.source && typeof root.source === "object" ? root.source : {};
|
|
552
|
+
const target = root.target && typeof root.target === "object" ? root.target : {};
|
|
553
|
+
const sourceId = typeof source.appId === "string" ? source.appId : "";
|
|
554
|
+
const sourceActor = typeof source.actor === "string" ? source.actor.trim() : "";
|
|
555
|
+
const chapterId = typeof target.chapterId === "string" ? target.chapterId : "";
|
|
556
|
+
const environment = typeof target.environment === "string" ? target.environment : "";
|
|
557
|
+
if (!ID.test(sourceId)) errors.push("document.source.appId must be an identifier");
|
|
558
|
+
if (!sourceActor || sourceActor.length > 256) errors.push("document.source.actor must be 1-256 characters");
|
|
559
|
+
if (!ID.test(chapterId)) errors.push("document.target.chapterId must be an identifier");
|
|
560
|
+
if (!ID.test(environment)) errors.push("document.target.environment must be an identifier");
|
|
561
|
+
if (!Array.isArray(root.grants)) errors.push("document.grants must be an array");
|
|
562
|
+
const rawGrants = Array.isArray(root.grants) ? root.grants : [];
|
|
563
|
+
if (rawGrants.length > MAX_GRANTS) errors.push(`document.grants supports at most ${MAX_GRANTS} entries`);
|
|
564
|
+
const grants = [];
|
|
565
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
566
|
+
const seenEmails = /* @__PURE__ */ new Set();
|
|
567
|
+
for (const [index, raw] of rawGrants.slice(0, MAX_GRANTS).entries()) {
|
|
568
|
+
const grant = raw && typeof raw === "object" ? raw : {};
|
|
569
|
+
const id = typeof grant.id === "string" ? grant.id : "";
|
|
570
|
+
const email = typeof grant.email === "string" ? grant.email.trim() : "";
|
|
571
|
+
const normalized = normalizeAdmissionEmail(email);
|
|
572
|
+
const decision = grant.decision;
|
|
573
|
+
const note = typeof grant.note === "string" ? grant.note.trim() : void 0;
|
|
574
|
+
const expiresAt = grant.expiresAt === void 0 ? void 0 : Number(grant.expiresAt);
|
|
575
|
+
if (!ID.test(id)) errors.push(`document.grants[${index}].id must be an identifier`);
|
|
576
|
+
if (seenIds.has(id)) errors.push(`document.grants repeats id "${id}"`);
|
|
577
|
+
if (!isAdmissionEmail(normalized)) errors.push(`document.grants[${index}].email must be valid`);
|
|
578
|
+
if (seenEmails.has(normalized)) errors.push(`document.grants repeats email "${normalized}"`);
|
|
579
|
+
if (decision !== "waive_interview" && decision !== "require_interview") {
|
|
580
|
+
errors.push(`document.grants[${index}].decision is invalid`);
|
|
581
|
+
}
|
|
582
|
+
if (note && note.length > MAX_NOTE) errors.push(`document.grants[${index}].note is too long`);
|
|
583
|
+
if (expiresAt !== void 0 && (!Number.isFinite(expiresAt) || expiresAt <= 0)) {
|
|
584
|
+
errors.push(`document.grants[${index}].expiresAt must be epoch milliseconds`);
|
|
585
|
+
}
|
|
586
|
+
seenIds.add(id);
|
|
587
|
+
seenEmails.add(normalized);
|
|
588
|
+
grants.push({
|
|
589
|
+
id,
|
|
590
|
+
email,
|
|
591
|
+
decision,
|
|
592
|
+
...note ? { note } : {},
|
|
593
|
+
...expiresAt !== void 0 ? { expiresAt } : {}
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
if (errors.length) return { ok: false, errors };
|
|
597
|
+
return {
|
|
598
|
+
ok: true,
|
|
599
|
+
value: {
|
|
600
|
+
schemaVersion: ADMISSION_GRANTS_SCHEMA_VERSION,
|
|
601
|
+
revision,
|
|
602
|
+
generatedAt,
|
|
603
|
+
source: { appId: sourceId, actor: sourceActor },
|
|
604
|
+
target: { chapterId, environment },
|
|
605
|
+
grants
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
var rows = (value) => Array.isArray(value) ? value : [];
|
|
610
|
+
function asGrant(row) {
|
|
611
|
+
if (typeof row.id !== "string" || typeof row.emailNormalized !== "string") return null;
|
|
612
|
+
if (row.sourceScope !== "local" && row.sourceScope !== "parent") return null;
|
|
613
|
+
if (row.decision !== "waive_interview" && row.decision !== "require_interview") return null;
|
|
614
|
+
return row;
|
|
615
|
+
}
|
|
616
|
+
async function findAdmissionGrant(db, targetId, email, now) {
|
|
617
|
+
const emailNormalized = normalizeAdmissionEmail(email);
|
|
618
|
+
if (!isAdmissionEmail(emailNormalized)) return null;
|
|
619
|
+
const result = await db.query({ admissionGrants: { $: { where: { emailNormalized }, limit: 100 } } });
|
|
620
|
+
const active = rows(result.admissionGrants).map(asGrant).filter((grant) => Boolean(
|
|
621
|
+
grant && grant.targetId === targetId && grant.active === true && !grant.revokedAt && (!grant.expiresAt || grant.expiresAt > now) && !grant.consumedApplicationId
|
|
622
|
+
)).sort((a, b) => {
|
|
623
|
+
const scope = Number(b.sourceScope === "local") - Number(a.sourceScope === "local");
|
|
624
|
+
return scope || b.createdAt - a.createdAt;
|
|
625
|
+
});
|
|
626
|
+
return active[0] ?? null;
|
|
627
|
+
}
|
|
628
|
+
async function hasAdmissionRevision(db, sourceId, targetId, environment) {
|
|
629
|
+
const where = { sourceId, targetId, environment };
|
|
630
|
+
if (db.aggregate) {
|
|
631
|
+
return Number((await db.aggregate("admissionGrantRevisions", { count: true }, { where })).count ?? 0) > 0;
|
|
632
|
+
}
|
|
633
|
+
const found = await db.query({ admissionGrantRevisions: {} });
|
|
634
|
+
return rows(found.admissionGrantRevisions).some((row) => row.sourceId === sourceId && row.targetId === targetId && row.environment === environment);
|
|
635
|
+
}
|
|
636
|
+
async function loadLatestAdmissionRevision(db, sourceId, targetId, environment) {
|
|
637
|
+
if (!await hasAdmissionRevision(db, sourceId, targetId, environment)) return null;
|
|
638
|
+
const result = await db.query({ admissionGrantRevisions: { $: {
|
|
639
|
+
where: { sourceId, targetId, environment },
|
|
640
|
+
order: { revision: "desc" },
|
|
641
|
+
limit: 1
|
|
642
|
+
} } });
|
|
643
|
+
const row = rows(result.admissionGrantRevisions).sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0];
|
|
644
|
+
return row ? row : null;
|
|
645
|
+
}
|
|
646
|
+
async function materializeAdmissionGrants(db, envelope, previous, receivedAt) {
|
|
647
|
+
const { document, digest } = envelope;
|
|
648
|
+
const current = rows((await db.query({ admissionGrants: { $: {
|
|
649
|
+
where: { sourceScope: "parent", sourceId: document.source.appId, targetId: document.target.chapterId },
|
|
650
|
+
limit: MAX_GRANTS
|
|
651
|
+
} } })).admissionGrants);
|
|
652
|
+
const incomingIds = new Set(document.grants.map((grant) => `admission:parent:${document.source.appId}:${grant.id}`));
|
|
653
|
+
const ops = [];
|
|
654
|
+
for (const row of current) {
|
|
655
|
+
const id = String(row.id ?? "");
|
|
656
|
+
if (id && !incomingIds.has(id)) ops.push({ t: "update", ns: "admissionGrants", id, attrs: {
|
|
657
|
+
active: false,
|
|
658
|
+
revokedAt: receivedAt,
|
|
659
|
+
revokedBy: document.source.actor,
|
|
660
|
+
updatedAt: receivedAt,
|
|
661
|
+
updatedBy: document.source.actor
|
|
662
|
+
} });
|
|
663
|
+
}
|
|
664
|
+
for (const grant of document.grants) {
|
|
665
|
+
const id = `admission:parent:${document.source.appId}:${grant.id}`;
|
|
666
|
+
const existing = current.find((row) => row.id === id);
|
|
667
|
+
ops.push({ t: "update", ns: "admissionGrants", id, attrs: {
|
|
668
|
+
id,
|
|
669
|
+
sourceGrantId: grant.id,
|
|
670
|
+
sourceScope: "parent",
|
|
671
|
+
sourceId: document.source.appId,
|
|
672
|
+
targetId: document.target.chapterId,
|
|
673
|
+
email: grant.email,
|
|
674
|
+
emailNormalized: normalizeAdmissionEmail(grant.email),
|
|
675
|
+
decision: grant.decision,
|
|
676
|
+
...grant.note ? { note: grant.note } : {},
|
|
677
|
+
...grant.expiresAt ? { expiresAt: grant.expiresAt } : {},
|
|
678
|
+
active: true,
|
|
679
|
+
sourceRevision: document.revision,
|
|
680
|
+
createdAt: Number(existing?.createdAt ?? receivedAt),
|
|
681
|
+
createdBy: String(existing?.createdBy ?? document.source.actor),
|
|
682
|
+
updatedAt: receivedAt,
|
|
683
|
+
updatedBy: document.source.actor
|
|
684
|
+
} });
|
|
685
|
+
const retract = [
|
|
686
|
+
...!grant.note && existing?.note !== void 0 ? ["note"] : [],
|
|
687
|
+
...grant.expiresAt === void 0 && existing?.expiresAt !== void 0 ? ["expiresAt"] : []
|
|
688
|
+
];
|
|
689
|
+
if (retract.length) ops.push({ t: "retract", ns: "admissionGrants", id, attrs: retract });
|
|
690
|
+
}
|
|
691
|
+
if (previous?.active) ops.push({ t: "update", ns: "admissionGrantRevisions", id: previous.id, attrs: { active: false } });
|
|
692
|
+
const revisionId = `admission-revision:${document.source.appId}:${document.target.environment}:${document.revision}`;
|
|
693
|
+
ops.push({ t: "update", ns: "admissionGrantRevisions", id: revisionId, attrs: {
|
|
694
|
+
id: revisionId,
|
|
695
|
+
sourceId: document.source.appId,
|
|
696
|
+
targetId: document.target.chapterId,
|
|
697
|
+
environment: document.target.environment,
|
|
698
|
+
revision: document.revision,
|
|
699
|
+
digest,
|
|
700
|
+
active: true,
|
|
701
|
+
receivedAt
|
|
702
|
+
} });
|
|
703
|
+
await db.transact(ops, { mutationId: `admission-grants:${document.source.appId}:${document.target.environment}:${document.revision}:${digest}` });
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// src/flow-fields.ts
|
|
707
|
+
var CHAPTER_FLOW_FIELDS_SCHEMA_VERSION = 1;
|
|
708
|
+
var APPLICATION_LABELS = {
|
|
709
|
+
firstName: "First name",
|
|
710
|
+
lastName: "Last name",
|
|
711
|
+
email: "Email",
|
|
712
|
+
referral: "Referral source",
|
|
713
|
+
referralName: "Referral name",
|
|
714
|
+
whoYouAre: "Who you are",
|
|
715
|
+
linkedin: "LinkedIn",
|
|
716
|
+
message: "Intro message",
|
|
717
|
+
phone: "Phone",
|
|
718
|
+
state: "State"
|
|
719
|
+
};
|
|
720
|
+
function humanize(id) {
|
|
721
|
+
const text3 = id.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim();
|
|
722
|
+
return text3 ? `${text3[0].toUpperCase()}${text3.slice(1)}` : id;
|
|
723
|
+
}
|
|
724
|
+
function applicationDefs(chapter) {
|
|
725
|
+
try {
|
|
726
|
+
return chapter.crm.type("person").fields;
|
|
727
|
+
} catch {
|
|
728
|
+
return {};
|
|
729
|
+
}
|
|
692
730
|
}
|
|
693
|
-
function
|
|
731
|
+
function applicationType(id, def) {
|
|
732
|
+
if (def && ["string", "email", "url", "enum"].includes(def.type)) return def.type;
|
|
733
|
+
return id === "email" ? "email" : id === "linkedin" ? "url" : "string";
|
|
734
|
+
}
|
|
735
|
+
function applicationFlowFields(chapter, application = chapter.application) {
|
|
736
|
+
const defs = applicationDefs(chapter);
|
|
737
|
+
return [...application.required, ...application.optional].map((id, order) => {
|
|
738
|
+
const def = defs[id];
|
|
739
|
+
const conditions = application.conditions[id];
|
|
740
|
+
const type = applicationType(id, def);
|
|
741
|
+
return {
|
|
742
|
+
id,
|
|
743
|
+
label: def?.label ?? APPLICATION_LABELS[id] ?? humanize(id),
|
|
744
|
+
type,
|
|
745
|
+
required: application.required.includes(id),
|
|
746
|
+
order,
|
|
747
|
+
maxLength: application.maxLen[id] ?? application.defaultMaxLen,
|
|
748
|
+
...type === "enum" && def?.options ? { options: [...def.options] } : {},
|
|
749
|
+
...conditions ? { conditions: { ...conditions } } : {},
|
|
750
|
+
...type === "email" ? { emailIdentity: "literal" } : {}
|
|
751
|
+
};
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
function resolveFormationFlowFields(crm, formation) {
|
|
755
|
+
if (!formation.enabled) return [];
|
|
756
|
+
const def = crm.type(formation.type);
|
|
757
|
+
return [...formation.required, ...formation.optional].map((id, order) => {
|
|
758
|
+
const field = def.fields[id];
|
|
759
|
+
return {
|
|
760
|
+
id,
|
|
761
|
+
label: field.label ?? humanize(id),
|
|
762
|
+
type: field.type,
|
|
763
|
+
required: formation.required.includes(id),
|
|
764
|
+
order,
|
|
765
|
+
maxLength: formation.maxLen[id] ?? formation.defaultMaxLen,
|
|
766
|
+
...field.options ? { options: [...field.options] } : {},
|
|
767
|
+
...id === def.emailField || field.type === "email" ? { emailIdentity: "literal" } : {}
|
|
768
|
+
};
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
function formationFlowFields(chapter, formation = chapter.formation) {
|
|
772
|
+
return resolveFormationFlowFields(chapter.crm, formation);
|
|
773
|
+
}
|
|
774
|
+
function chapterFlowFieldContract(chapter, flow, application = chapter.application) {
|
|
694
775
|
return {
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
776
|
+
schemaVersion: CHAPTER_FLOW_FIELDS_SCHEMA_VERSION,
|
|
777
|
+
flow,
|
|
778
|
+
fields: flow === "join" ? applicationFlowFields(chapter, application) : formationFlowFields(chapter)
|
|
698
779
|
};
|
|
699
780
|
}
|
|
700
781
|
|
|
@@ -731,8 +812,8 @@ function validateGiftOfferCatalog(input, tierIds) {
|
|
|
731
812
|
errors.push(`${path} must be an object`);
|
|
732
813
|
continue;
|
|
733
814
|
}
|
|
734
|
-
const
|
|
735
|
-
for (const key of Object.keys(item)) if (!
|
|
815
|
+
const allowed2 = /* @__PURE__ */ new Set(["id", "target", "billingMode", "stripePriceId", "priceCents", "currency", "redemptionLifetimeHours", "activeFrom", "activeUntil", "title", "description", "ctaLabel"]);
|
|
816
|
+
for (const key of Object.keys(item)) if (!allowed2.has(key)) errors.push(`${path}.${key} is not supported`);
|
|
736
817
|
const id = text(item.id, `${path}.id`, errors, 80);
|
|
737
818
|
if (id && !ID2.test(id)) errors.push(`${path}.id must be a lowercase kebab-case id`);
|
|
738
819
|
if (ids.has(id)) errors.push(`${path}.id duplicates "${id}"`);
|
|
@@ -829,8 +910,8 @@ async function clerkListUsers(secretKey, fetchImpl = fetch) {
|
|
|
829
910
|
const data = await clerkGet(`/v1/users?limit=${PAGE}&offset=${offset}`, secretKey, fetchImpl);
|
|
830
911
|
const page = Array.isArray(data) ? data : [];
|
|
831
912
|
for (const u of page) {
|
|
832
|
-
const
|
|
833
|
-
if (
|
|
913
|
+
const record3 = toRecord(u);
|
|
914
|
+
if (record3) out.push(record3);
|
|
834
915
|
}
|
|
835
916
|
if (page.length < PAGE) break;
|
|
836
917
|
}
|
|
@@ -892,34 +973,34 @@ function networkSourceTag(type, hubRecordId, sourceId) {
|
|
|
892
973
|
if (readable && raw.length <= 64) return raw;
|
|
893
974
|
return `network:${typeKey.slice(0, 16)}:${shortHash(`${sourceId ?? ""}\0${type}\0${hubRecordId}`)}`;
|
|
894
975
|
}
|
|
895
|
-
function normalizeSharedRecord(
|
|
896
|
-
if ("input" in
|
|
976
|
+
function normalizeSharedRecord(record3, fallbackSourceId = "legacy-source") {
|
|
977
|
+
if ("input" in record3 && record3.version === 2) {
|
|
897
978
|
return {
|
|
898
979
|
version: 2,
|
|
899
|
-
sourceId:
|
|
900
|
-
sourceRecordId:
|
|
901
|
-
type:
|
|
902
|
-
input:
|
|
980
|
+
sourceId: record3.source.siteId,
|
|
981
|
+
sourceRecordId: record3.source.recordId,
|
|
982
|
+
type: record3.type,
|
|
983
|
+
input: record3.input
|
|
903
984
|
};
|
|
904
985
|
}
|
|
905
|
-
if ("input" in
|
|
986
|
+
if ("input" in record3) {
|
|
906
987
|
return {
|
|
907
988
|
version: 1,
|
|
908
989
|
sourceId: fallbackSourceId,
|
|
909
|
-
sourceRecordId:
|
|
910
|
-
type:
|
|
911
|
-
input:
|
|
990
|
+
sourceRecordId: record3.hubRecordId,
|
|
991
|
+
type: record3.type,
|
|
992
|
+
input: record3.input
|
|
912
993
|
};
|
|
913
994
|
}
|
|
914
|
-
if ("type" in
|
|
915
|
-
const input = { name:
|
|
995
|
+
if ("type" in record3 && record3.type === "company") {
|
|
996
|
+
const input = { name: record3.name };
|
|
916
997
|
for (const key of ["domain", "industry", "location", "linkedin", "notes"]) {
|
|
917
|
-
if (
|
|
998
|
+
if (record3[key]) input[key] = record3[key];
|
|
918
999
|
}
|
|
919
1000
|
return {
|
|
920
1001
|
version: 1,
|
|
921
1002
|
sourceId: fallbackSourceId,
|
|
922
|
-
sourceRecordId:
|
|
1003
|
+
sourceRecordId: record3.hubRecordId,
|
|
923
1004
|
type: "company",
|
|
924
1005
|
input
|
|
925
1006
|
};
|
|
@@ -927,28 +1008,28 @@ function normalizeSharedRecord(record2, fallbackSourceId = "legacy-source") {
|
|
|
927
1008
|
return {
|
|
928
1009
|
version: 1,
|
|
929
1010
|
sourceId: fallbackSourceId,
|
|
930
|
-
sourceRecordId:
|
|
1011
|
+
sourceRecordId: record3.hubRecordId,
|
|
931
1012
|
type: "person",
|
|
932
|
-
input: sharedPersonInput(
|
|
1013
|
+
input: sharedPersonInput(record3)
|
|
933
1014
|
};
|
|
934
1015
|
}
|
|
935
|
-
function sharedRecordFromCrm(crm,
|
|
936
|
-
if (target.fields && !target.fields[
|
|
937
|
-
throw new Error(`${target.name} does not accept "${
|
|
1016
|
+
function sharedRecordFromCrm(crm, record3, target, sourceId) {
|
|
1017
|
+
if (target.fields && !target.fields[record3.type]) {
|
|
1018
|
+
throw new Error(`${target.name} does not accept "${record3.type}" records`);
|
|
938
1019
|
}
|
|
939
|
-
const def = crm.type(
|
|
940
|
-
const fields = target.fields?.[
|
|
1020
|
+
const def = crm.type(record3.type);
|
|
1021
|
+
const fields = target.fields?.[record3.type] ?? DEFAULT_SHARE_FIELDS[record3.type];
|
|
941
1022
|
if (!fields) {
|
|
942
|
-
throw new Error(`${target.name} requires an explicit field allowlist for "${
|
|
1023
|
+
throw new Error(`${target.name} requires an explicit field allowlist for "${record3.type}" records`);
|
|
943
1024
|
}
|
|
944
1025
|
const nameField = def.nameField ?? "name";
|
|
945
1026
|
const input = {};
|
|
946
1027
|
for (const field of /* @__PURE__ */ new Set([nameField, ...fields])) {
|
|
947
|
-
const value =
|
|
1028
|
+
const value = record3.fields?.[field];
|
|
948
1029
|
if (value !== void 0) input[field] = value;
|
|
949
1030
|
}
|
|
950
|
-
if (input[nameField] === void 0) input[nameField] =
|
|
951
|
-
return sourceId ? { version: 2, source: { siteId: sourceId, recordId:
|
|
1031
|
+
if (input[nameField] === void 0) input[nameField] = record3.name;
|
|
1032
|
+
return sourceId ? { version: 2, source: { siteId: sourceId, recordId: record3.id }, type: record3.type, input } : { version: 1, type: record3.type, hubRecordId: record3.id, input };
|
|
952
1033
|
}
|
|
953
1034
|
async function upsertPerson(deps, opts) {
|
|
954
1035
|
const email = opts.email.toLowerCase();
|
|
@@ -962,11 +1043,11 @@ async function upsertPerson(deps, opts) {
|
|
|
962
1043
|
const created = await createRecord(crmDeps3, { type: "person", input: opts.input, mutationId: opts.mutationId });
|
|
963
1044
|
return { recordId: created.id };
|
|
964
1045
|
}
|
|
965
|
-
async function findSharedRecord(deps,
|
|
1046
|
+
async function findSharedRecord(deps, record3, tag) {
|
|
966
1047
|
const structured = await getRecordByOrigin(
|
|
967
1048
|
{ crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId },
|
|
968
|
-
|
|
969
|
-
|
|
1049
|
+
record3.sourceId,
|
|
1050
|
+
record3.sourceRecordId
|
|
970
1051
|
);
|
|
971
1052
|
if (structured) return structured;
|
|
972
1053
|
const mapped = await deps.db.query({ crm_tag: { $: { where: { tag }, limit: 1 } } });
|
|
@@ -975,53 +1056,53 @@ async function findSharedRecord(deps, record2, tag) {
|
|
|
975
1056
|
const found = await deps.db.query({ crm_record: { $: { where: { id: mappedId }, limit: 1 } } });
|
|
976
1057
|
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
977
1058
|
}
|
|
978
|
-
const def = deps.crm.type(
|
|
1059
|
+
const def = deps.crm.type(record3.type);
|
|
979
1060
|
const emailField = def.emailField;
|
|
980
|
-
if (emailField && typeof
|
|
981
|
-
const primaryEmail =
|
|
1061
|
+
if (emailField && typeof record3.input[emailField] === "string") {
|
|
1062
|
+
const primaryEmail = record3.input[emailField].toLowerCase();
|
|
982
1063
|
const found = await deps.db.query({
|
|
983
|
-
crm_record: { $: { where: { type:
|
|
1064
|
+
crm_record: { $: { where: { type: record3.type, primaryEmail }, limit: 1 } }
|
|
984
1065
|
});
|
|
985
1066
|
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
986
1067
|
}
|
|
987
|
-
const domain =
|
|
1068
|
+
const domain = record3.input.domain;
|
|
988
1069
|
const domainSlot = def.fields.domain?.slot;
|
|
989
1070
|
if (typeof domain === "string" && domainSlot) {
|
|
990
1071
|
const found = await deps.db.query({
|
|
991
|
-
crm_record: { $: { where: { type:
|
|
1072
|
+
crm_record: { $: { where: { type: record3.type, [domainSlot]: domain }, limit: 1 } }
|
|
992
1073
|
});
|
|
993
1074
|
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
994
1075
|
}
|
|
995
1076
|
const nameField = def.nameField ?? "name";
|
|
996
|
-
const name =
|
|
997
|
-
if (
|
|
1077
|
+
const name = record3.input[nameField];
|
|
1078
|
+
if (record3.type === "company" && typeof name === "string" && name.trim()) {
|
|
998
1079
|
const found = await deps.db.query({
|
|
999
|
-
crm_record: { $: { where: { type:
|
|
1080
|
+
crm_record: { $: { where: { type: record3.type, name: name.trim() }, limit: 1 } }
|
|
1000
1081
|
});
|
|
1001
1082
|
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
1002
1083
|
}
|
|
1003
1084
|
return void 0;
|
|
1004
1085
|
}
|
|
1005
1086
|
async function projectSharedRecord(deps, shared, options = {}) {
|
|
1006
|
-
const
|
|
1007
|
-
if (!
|
|
1087
|
+
const record3 = normalizeSharedRecord(shared, options.sourceId);
|
|
1088
|
+
if (!record3.type.trim() || !record3.sourceId.trim() || !record3.sourceRecordId.trim()) {
|
|
1008
1089
|
throw new Error("type, source site id, and source record id must be non-empty");
|
|
1009
1090
|
}
|
|
1010
|
-
if (options.sourceId && options.sourceId !==
|
|
1091
|
+
if (options.sourceId && options.sourceId !== record3.sourceId) {
|
|
1011
1092
|
throw new Error("signed sender does not match payload source");
|
|
1012
1093
|
}
|
|
1013
|
-
const tag = networkSourceTag(
|
|
1094
|
+
const tag = networkSourceTag(record3.type, record3.sourceRecordId, record3.version === 2 ? record3.sourceId : void 0);
|
|
1014
1095
|
const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
|
|
1015
|
-
const existing = await findSharedRecord(deps,
|
|
1096
|
+
const existing = await findSharedRecord(deps, record3, tag);
|
|
1016
1097
|
let recordId;
|
|
1017
1098
|
if (existing && typeof existing.id === "string") {
|
|
1018
|
-
await updateRecord(crmDeps3, { id: existing.id, input:
|
|
1099
|
+
await updateRecord(crmDeps3, { id: existing.id, input: record3.input });
|
|
1019
1100
|
recordId = existing.id;
|
|
1020
1101
|
} else {
|
|
1021
|
-
recordId = `network_${shortHash(`${
|
|
1102
|
+
recordId = `network_${shortHash(`${record3.sourceId}\0${record3.type}\0${record3.sourceRecordId}`)}`;
|
|
1022
1103
|
await createRecord({ ...crmDeps3, newId: () => recordId }, {
|
|
1023
|
-
type:
|
|
1024
|
-
input:
|
|
1104
|
+
type: record3.type,
|
|
1105
|
+
input: record3.input,
|
|
1025
1106
|
mutationId: `share-create:${tag}`
|
|
1026
1107
|
});
|
|
1027
1108
|
}
|
|
@@ -1031,10 +1112,10 @@ async function projectSharedRecord(deps, shared, options = {}) {
|
|
|
1031
1112
|
);
|
|
1032
1113
|
await upsertRecordOrigin(crmDeps3, {
|
|
1033
1114
|
recordId,
|
|
1034
|
-
sourceId:
|
|
1035
|
-
sourceRecordId:
|
|
1115
|
+
sourceId: record3.sourceId,
|
|
1116
|
+
sourceRecordId: record3.sourceRecordId,
|
|
1036
1117
|
...options.sourceUrl ? { sourceUrl: options.sourceUrl } : {},
|
|
1037
|
-
payloadVersion:
|
|
1118
|
+
payloadVersion: record3.version
|
|
1038
1119
|
});
|
|
1039
1120
|
return { recordId };
|
|
1040
1121
|
}
|
|
@@ -1483,7 +1564,8 @@ function applicationSummary(app) {
|
|
|
1483
1564
|
meetingLink: app.meetingLink ?? null,
|
|
1484
1565
|
paid: Boolean(app.stripeSubscriptionId) && app.status !== "refunded",
|
|
1485
1566
|
renewalAt: app.renewalAt ?? null,
|
|
1486
|
-
canceled: app.canceled === true
|
|
1567
|
+
canceled: app.canceled === true,
|
|
1568
|
+
tierId: app.tierId ?? null
|
|
1487
1569
|
};
|
|
1488
1570
|
}
|
|
1489
1571
|
function memberApplication(app, meeting, defaultTimezone) {
|
|
@@ -1707,7 +1789,7 @@ async function notifyAdminOfApplication(db, env, chapterId, applicationId, field
|
|
|
1707
1789
|
// src/worker-routes.ts
|
|
1708
1790
|
async function memberSessionApplication(db, chapterId, email) {
|
|
1709
1791
|
let apps = (await db.query({ applications: { $: {
|
|
1710
|
-
where: { emailNormalized:
|
|
1792
|
+
where: { emailNormalized: normalizeChapterEmail(email) },
|
|
1711
1793
|
limit: 20
|
|
1712
1794
|
} } })).applications;
|
|
1713
1795
|
if (!apps?.length) apps = (await db.query({ applications: { $: { where: { email }, limit: 20 } } })).applications;
|
|
@@ -1806,6 +1888,7 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
1806
1888
|
const application = controlledApplication(chapter.application, activeControl?.document);
|
|
1807
1889
|
return json({
|
|
1808
1890
|
...joinConfig(group, paymentsReady, joinConfigTiers(offered), application.conditions),
|
|
1891
|
+
fieldContract: chapterFlowFieldContract(chapter, "join", application),
|
|
1809
1892
|
copy: { ...chapter.copy.join, ...activeControl?.document.application?.copy ?? {} },
|
|
1810
1893
|
...activeControl ? { signupControl: {
|
|
1811
1894
|
revision: activeControl.revision,
|
|
@@ -1957,8 +2040,8 @@ var handleNetworkShared = async (req, url, env, ctx) => {
|
|
|
1957
2040
|
payload,
|
|
1958
2041
|
sourceId ? { sourceId } : {}
|
|
1959
2042
|
);
|
|
1960
|
-
const
|
|
1961
|
-
return json({ recordId, type:
|
|
2043
|
+
const record3 = normalizeSharedRecord(payload, sourceId);
|
|
2044
|
+
return json({ recordId, type: record3.type });
|
|
1962
2045
|
} catch (error) {
|
|
1963
2046
|
return json({
|
|
1964
2047
|
error: error instanceof Error ? error.message : "invalid shared record"
|
|
@@ -1998,9 +2081,9 @@ var MAX_BENEFITS = 24;
|
|
|
1998
2081
|
var MAX_MARKDOWN = 65536;
|
|
1999
2082
|
var MAX_LABEL = 160;
|
|
2000
2083
|
var objectRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
2001
|
-
function rejectUnknownKeys(value,
|
|
2084
|
+
function rejectUnknownKeys(value, allowed2, path, errors) {
|
|
2002
2085
|
for (const key of Object.keys(value)) {
|
|
2003
|
-
if (!
|
|
2086
|
+
if (!allowed2.has(key)) errors.push(`${path}.${key} is not supported`);
|
|
2004
2087
|
}
|
|
2005
2088
|
}
|
|
2006
2089
|
function boundedString(value, path, errors, max, required = false) {
|
|
@@ -2156,8 +2239,8 @@ var ROOT_KEYS2 = /* @__PURE__ */ new Set([
|
|
|
2156
2239
|
]);
|
|
2157
2240
|
var TIER_KEYS2 = /* @__PURE__ */ new Set(["id", "name", "available", "sortOrder", "priceCents", "stripePriceId", "blurb"]);
|
|
2158
2241
|
var recordOf = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
2159
|
-
function unknownKeys(value,
|
|
2160
|
-
for (const key of Object.keys(value)) if (!
|
|
2242
|
+
function unknownKeys(value, allowed2, path, errors) {
|
|
2243
|
+
for (const key of Object.keys(value)) if (!allowed2.has(key)) errors.push(`${path}.${key} is not supported`);
|
|
2161
2244
|
}
|
|
2162
2245
|
function text2(value, path, errors, max = 65536) {
|
|
2163
2246
|
if (typeof value !== "string" || !value.trim()) {
|
|
@@ -2242,10 +2325,10 @@ function policyOf(value, errors) {
|
|
|
2242
2325
|
errors.push("document.policy must be an object");
|
|
2243
2326
|
return void 0;
|
|
2244
2327
|
}
|
|
2245
|
-
const
|
|
2246
|
-
unknownKeys(input,
|
|
2328
|
+
const allowed2 = /* @__PURE__ */ new Set(["disclaimerText", "refundPolicyText", "trustCopy", "commitmentText", "normsText"]);
|
|
2329
|
+
unknownKeys(input, allowed2, "document.policy", errors);
|
|
2247
2330
|
const result = {};
|
|
2248
|
-
for (const key of
|
|
2331
|
+
for (const key of allowed2) {
|
|
2249
2332
|
if (input[key] === void 0 && key !== "disclaimerText" && key !== "refundPolicyText") continue;
|
|
2250
2333
|
result[key] = text2(input[key], `document.policy.${key}`, errors);
|
|
2251
2334
|
}
|
|
@@ -2384,9 +2467,589 @@ var handleSignupControl = async (req, url, env, ctx) => {
|
|
|
2384
2467
|
});
|
|
2385
2468
|
};
|
|
2386
2469
|
|
|
2387
|
-
// src/worker-routes-
|
|
2470
|
+
// src/worker-routes-member-content.ts
|
|
2388
2471
|
import { verifyFederatedRequest as verifyFederatedRequest3 } from "@odla-ai/db";
|
|
2472
|
+
|
|
2473
|
+
// src/member-content-canonical.ts
|
|
2474
|
+
var encoder2 = new TextEncoder();
|
|
2475
|
+
function canonicalValue2(value) {
|
|
2476
|
+
if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
|
|
2477
|
+
return JSON.stringify(value);
|
|
2478
|
+
}
|
|
2479
|
+
if (Array.isArray(value)) return `[${value.map(canonicalValue2).join(",")}]`;
|
|
2480
|
+
if (typeof value !== "object") throw new TypeError("member content contains an unsupported value");
|
|
2481
|
+
const record3 = value;
|
|
2482
|
+
const entries = Object.keys(record3).sort().flatMap((key) => {
|
|
2483
|
+
const item = record3[key];
|
|
2484
|
+
return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue2(item)}`];
|
|
2485
|
+
});
|
|
2486
|
+
return `{${entries.join(",")}}`;
|
|
2487
|
+
}
|
|
2488
|
+
function canonicalMemberContent(value) {
|
|
2489
|
+
return canonicalValue2(value);
|
|
2490
|
+
}
|
|
2491
|
+
async function memberContentDigest(document) {
|
|
2492
|
+
const digest = await crypto.subtle.digest("SHA-256", encoder2.encode(canonicalMemberContent(document)));
|
|
2493
|
+
return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
// src/member-content-validation-helpers.ts
|
|
2497
|
+
var ID3 = /^[a-z][a-z0-9-]{0,79}$/;
|
|
2498
|
+
var SLUG2 = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
2499
|
+
var ROOT_KEYS3 = /* @__PURE__ */ new Set(["schemaVersion", "revision", "generatedAt", "source", "target", "pages", "upsellOverrides"]);
|
|
2500
|
+
var SOURCE_KEYS = /* @__PURE__ */ new Set(["type", "appId"]);
|
|
2501
|
+
var TARGET_KEYS = /* @__PURE__ */ new Set(["chapterId", "environment"]);
|
|
2502
|
+
var PAGE_KEYS = /* @__PURE__ */ new Set(["id", "slug", "title", "summary", "navigation", "audience", "teaserMarkdown", "bodyMarkdown", "upsell"]);
|
|
2503
|
+
var NAV_KEYS = /* @__PURE__ */ new Set(["section", "order", "parentId"]);
|
|
2504
|
+
var AUDIENCE_KEYS = /* @__PURE__ */ new Set(["kind", "tierIds"]);
|
|
2505
|
+
var UPSELL_KEYS = /* @__PURE__ */ new Set(["owner", "offer"]);
|
|
2506
|
+
var OFFER_KEYS = /* @__PURE__ */ new Set(["tierId", "label", "bodyMarkdown", "ctaLabel"]);
|
|
2507
|
+
var OVERRIDE_KEYS = /* @__PURE__ */ new Set(["pageId", "offer"]);
|
|
2508
|
+
var MAX_PAGES = 100;
|
|
2509
|
+
var MAX_MARKDOWN2 = 65536;
|
|
2510
|
+
var MAX_TEXT = 240;
|
|
2511
|
+
var record2 = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
2512
|
+
function unknownKeys2(value, allowed2, path, errors) {
|
|
2513
|
+
for (const key of Object.keys(value)) if (!allowed2.has(key)) errors.push(`${path}.${key} is not supported`);
|
|
2514
|
+
}
|
|
2515
|
+
function textValue(value, path, errors, max = MAX_TEXT, required = false) {
|
|
2516
|
+
if (value === void 0) {
|
|
2517
|
+
if (required) errors.push(`${path} is required`);
|
|
2518
|
+
return void 0;
|
|
2519
|
+
}
|
|
2520
|
+
if (typeof value !== "string") {
|
|
2521
|
+
errors.push(`${path} must be a string`);
|
|
2522
|
+
return void 0;
|
|
2523
|
+
}
|
|
2524
|
+
const valueText = value.trim();
|
|
2525
|
+
if (required && !valueText) errors.push(`${path} is required`);
|
|
2526
|
+
if (valueText.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
|
|
2527
|
+
return valueText;
|
|
2528
|
+
}
|
|
2529
|
+
function offerValue(value, path, errors, tiers, audience) {
|
|
2530
|
+
const raw = record2(value);
|
|
2531
|
+
if (!raw) {
|
|
2532
|
+
errors.push(`${path} must be an object`);
|
|
2533
|
+
return void 0;
|
|
2534
|
+
}
|
|
2535
|
+
unknownKeys2(raw, OFFER_KEYS, path, errors);
|
|
2536
|
+
const tierId = textValue(raw.tierId, `${path}.tierId`, errors, 80, true) ?? "";
|
|
2537
|
+
if (tierId && !ID3.test(tierId)) errors.push(`${path}.tierId must be a lowercase kebab-case id`);
|
|
2538
|
+
if (tiers.size && !tiers.has(tierId)) errors.push(`${path}.tierId must reference a declared tier`);
|
|
2539
|
+
if (audience?.kind === "tiers" && tierId && !audience.tierIds.includes(tierId)) {
|
|
2540
|
+
errors.push(`${path}.tierId must grant access to this page`);
|
|
2541
|
+
}
|
|
2542
|
+
const label = textValue(raw.label, `${path}.label`, errors);
|
|
2543
|
+
const bodyMarkdown = textValue(raw.bodyMarkdown, `${path}.bodyMarkdown`, errors, MAX_MARKDOWN2);
|
|
2544
|
+
const ctaLabel = textValue(raw.ctaLabel, `${path}.ctaLabel`, errors);
|
|
2545
|
+
return {
|
|
2546
|
+
tierId,
|
|
2547
|
+
...label ? { label } : {},
|
|
2548
|
+
...bodyMarkdown ? { bodyMarkdown } : {},
|
|
2549
|
+
...ctaLabel ? { ctaLabel } : {}
|
|
2550
|
+
};
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
// src/member-content-validation.ts
|
|
2554
|
+
function validateMemberContentDocument(input, options = {}) {
|
|
2555
|
+
const errors = [];
|
|
2556
|
+
const root = record2(input);
|
|
2557
|
+
if (!root) return { ok: false, errors: ["document must be an object"] };
|
|
2558
|
+
unknownKeys2(root, ROOT_KEYS3, "document", errors);
|
|
2559
|
+
if (root.schemaVersion !== MEMBER_CONTENT_SCHEMA_VERSION) errors.push("document.schemaVersion must be 1");
|
|
2560
|
+
if (!Number.isInteger(root.revision) || Number(root.revision) < 1) errors.push("document.revision must be a positive integer");
|
|
2561
|
+
if (!Number.isInteger(root.generatedAt) || Number(root.generatedAt) < 0) errors.push("document.generatedAt must be a non-negative integer timestamp");
|
|
2562
|
+
const sourceRaw = record2(root.source);
|
|
2563
|
+
let source = { type: "chapter", appId: "invalid" };
|
|
2564
|
+
if (!sourceRaw) errors.push("document.source must be an object");
|
|
2565
|
+
else {
|
|
2566
|
+
unknownKeys2(sourceRaw, SOURCE_KEYS, "document.source", errors);
|
|
2567
|
+
const type = sourceRaw.type;
|
|
2568
|
+
const appId = textValue(sourceRaw.appId, "document.source.appId", errors, 63, true) ?? "";
|
|
2569
|
+
if (type !== "parent" && type !== "chapter") errors.push("document.source.type must be parent or chapter");
|
|
2570
|
+
if (appId && !ID3.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
|
|
2571
|
+
if (options.sourceType && type !== options.sourceType) errors.push(`document.source.type must be ${options.sourceType}`);
|
|
2572
|
+
if (options.sourceAppId && appId !== options.sourceAppId) errors.push(`document.source.appId must be ${options.sourceAppId}`);
|
|
2573
|
+
if (type === "parent" || type === "chapter") source = { type, appId };
|
|
2574
|
+
}
|
|
2575
|
+
let target;
|
|
2576
|
+
if (root.target !== void 0) {
|
|
2577
|
+
const targetRaw = record2(root.target);
|
|
2578
|
+
if (!targetRaw) errors.push("document.target must be an object");
|
|
2579
|
+
else {
|
|
2580
|
+
unknownKeys2(targetRaw, TARGET_KEYS, "document.target", errors);
|
|
2581
|
+
const chapterId = textValue(targetRaw.chapterId, "document.target.chapterId", errors, 63, true) ?? "";
|
|
2582
|
+
const environment = textValue(targetRaw.environment, "document.target.environment", errors, 80, true) ?? "";
|
|
2583
|
+
target = { chapterId, environment };
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
if (source.type === "parent" && !target) errors.push("document.target is required for parent content");
|
|
2587
|
+
if (source.type === "chapter" && target) errors.push("document.target is not supported for chapter content");
|
|
2588
|
+
if (!Array.isArray(root.pages)) errors.push("document.pages must be an array");
|
|
2589
|
+
const rawPages = Array.isArray(root.pages) ? root.pages : [];
|
|
2590
|
+
if (rawPages.length > MAX_PAGES) errors.push(`document.pages supports at most ${MAX_PAGES} entries`);
|
|
2591
|
+
const tierIds = new Set((options.tiers ?? []).map((tier) => tier.id));
|
|
2592
|
+
const knownParentIds = new Set((options.parentPages ?? []).map((page) => page.id));
|
|
2593
|
+
const ids = /* @__PURE__ */ new Set();
|
|
2594
|
+
const slugs = /* @__PURE__ */ new Set();
|
|
2595
|
+
const pages = [];
|
|
2596
|
+
for (const [index, value] of rawPages.slice(0, MAX_PAGES).entries()) {
|
|
2597
|
+
const path = `document.pages[${index}]`;
|
|
2598
|
+
const raw = record2(value);
|
|
2599
|
+
if (!raw) {
|
|
2600
|
+
errors.push(`${path} must be an object`);
|
|
2601
|
+
continue;
|
|
2602
|
+
}
|
|
2603
|
+
unknownKeys2(raw, PAGE_KEYS, path, errors);
|
|
2604
|
+
const id = textValue(raw.id, `${path}.id`, errors, 80, true) ?? "";
|
|
2605
|
+
const slug = textValue(raw.slug, `${path}.slug`, errors, 120, true) ?? "";
|
|
2606
|
+
const title = textValue(raw.title, `${path}.title`, errors, MAX_TEXT, true) ?? "";
|
|
2607
|
+
if (id && !ID3.test(id)) errors.push(`${path}.id must be a lowercase kebab-case id`);
|
|
2608
|
+
if (slug && !SLUG2.test(slug)) errors.push(`${path}.slug must be a lowercase URL slug`);
|
|
2609
|
+
if (ids.has(id) || knownParentIds.has(id)) errors.push(`${path}.id duplicates page "${id}"`);
|
|
2610
|
+
if (slugs.has(slug) || (options.parentPages ?? []).some((page) => page.slug === slug)) errors.push(`${path}.slug duplicates "${slug}"`);
|
|
2611
|
+
ids.add(id);
|
|
2612
|
+
slugs.add(slug);
|
|
2613
|
+
const navRaw = record2(raw.navigation);
|
|
2614
|
+
let navigation = { section: "General", order: index };
|
|
2615
|
+
if (!navRaw) errors.push(`${path}.navigation must be an object`);
|
|
2616
|
+
else {
|
|
2617
|
+
unknownKeys2(navRaw, NAV_KEYS, `${path}.navigation`, errors);
|
|
2618
|
+
const section = textValue(navRaw.section, `${path}.navigation.section`, errors, 120, true) ?? "";
|
|
2619
|
+
const order = navRaw.order;
|
|
2620
|
+
if (!Number.isInteger(order) || Number(order) < 0 || Number(order) > 1e6) {
|
|
2621
|
+
errors.push(`${path}.navigation.order must be an integer from 0 to 1,000,000`);
|
|
2622
|
+
}
|
|
2623
|
+
const parentId = textValue(navRaw.parentId, `${path}.navigation.parentId`, errors, 80);
|
|
2624
|
+
navigation = { section, order: Number.isInteger(order) ? Number(order) : index, ...parentId ? { parentId } : {} };
|
|
2625
|
+
}
|
|
2626
|
+
const audienceRaw = record2(raw.audience);
|
|
2627
|
+
let audience = { kind: "all-members" };
|
|
2628
|
+
if (!audienceRaw) errors.push(`${path}.audience must be an object`);
|
|
2629
|
+
else {
|
|
2630
|
+
unknownKeys2(audienceRaw, AUDIENCE_KEYS, `${path}.audience`, errors);
|
|
2631
|
+
if (audienceRaw.kind === "all-members") {
|
|
2632
|
+
if (audienceRaw.tierIds !== void 0) errors.push(`${path}.audience.tierIds is not supported for all-members`);
|
|
2633
|
+
} else if (audienceRaw.kind === "tiers") {
|
|
2634
|
+
if (!Array.isArray(audienceRaw.tierIds) || audienceRaw.tierIds.length === 0) {
|
|
2635
|
+
errors.push(`${path}.audience.tierIds must be a non-empty array`);
|
|
2636
|
+
}
|
|
2637
|
+
const values = Array.isArray(audienceRaw.tierIds) ? audienceRaw.tierIds : [];
|
|
2638
|
+
const normalized = [];
|
|
2639
|
+
for (const [tierIndex, value2] of values.entries()) {
|
|
2640
|
+
const tier = textValue(value2, `${path}.audience.tierIds[${tierIndex}]`, errors, 80, true) ?? "";
|
|
2641
|
+
if (normalized.includes(tier)) errors.push(`${path}.audience.tierIds duplicates "${tier}"`);
|
|
2642
|
+
if (tierIds.size && !tierIds.has(tier)) errors.push(`${path}.audience.tierIds references undeclared tier "${tier}"`);
|
|
2643
|
+
normalized.push(tier);
|
|
2644
|
+
}
|
|
2645
|
+
audience = { kind: "tiers", tierIds: normalized };
|
|
2646
|
+
} else errors.push(`${path}.audience.kind must be all-members or tiers`);
|
|
2647
|
+
}
|
|
2648
|
+
const summary = textValue(raw.summary, `${path}.summary`, errors, 2e3);
|
|
2649
|
+
const teaserMarkdown = textValue(raw.teaserMarkdown, `${path}.teaserMarkdown`, errors, MAX_MARKDOWN2);
|
|
2650
|
+
const bodyMarkdown = textValue(raw.bodyMarkdown, `${path}.bodyMarkdown`, errors, MAX_MARKDOWN2, true) ?? "";
|
|
2651
|
+
let upsell;
|
|
2652
|
+
if (raw.upsell !== void 0) {
|
|
2653
|
+
const upsellRaw = record2(raw.upsell);
|
|
2654
|
+
if (!upsellRaw) errors.push(`${path}.upsell must be an object`);
|
|
2655
|
+
else {
|
|
2656
|
+
unknownKeys2(upsellRaw, UPSELL_KEYS, `${path}.upsell`, errors);
|
|
2657
|
+
const owner = upsellRaw.owner;
|
|
2658
|
+
if (owner !== "parent" && owner !== "chapter") errors.push(`${path}.upsell.owner must be parent or chapter`);
|
|
2659
|
+
if (source.type === "chapter" && owner === "parent") errors.push(`${path}.upsell.owner cannot be parent on a chapter page`);
|
|
2660
|
+
if (source.type === "parent" && owner === "parent" && upsellRaw.offer === void 0) errors.push(`${path}.upsell.offer is required when the parent owns the offer`);
|
|
2661
|
+
if (source.type === "parent" && owner === "chapter" && upsellRaw.offer !== void 0) errors.push(`${path}.upsell.offer must be set by the chapter after delegation`);
|
|
2662
|
+
const offer = upsellRaw.offer === void 0 ? void 0 : offerValue(upsellRaw.offer, `${path}.upsell.offer`, errors, tierIds, audience);
|
|
2663
|
+
if (owner === "parent") upsell = { owner, offer: offer ?? { tierId: "" } };
|
|
2664
|
+
else if (owner === "chapter") upsell = { owner, ...offer ? { offer } : {} };
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
pages.push({ id, slug, title, ...summary ? { summary } : {}, navigation, audience, ...teaserMarkdown ? { teaserMarkdown } : {}, bodyMarkdown, ...upsell ? { upsell } : {} });
|
|
2668
|
+
}
|
|
2669
|
+
const allIds = /* @__PURE__ */ new Set([...knownParentIds, ...ids]);
|
|
2670
|
+
for (const [index, page] of pages.entries()) {
|
|
2671
|
+
const parentId = page.navigation.parentId;
|
|
2672
|
+
if (parentId && !allIds.has(parentId)) errors.push(`document.pages[${index}].navigation.parentId references unknown page "${parentId}"`);
|
|
2673
|
+
if (parentId === page.id) errors.push(`document.pages[${index}].navigation.parentId cannot reference itself`);
|
|
2674
|
+
}
|
|
2675
|
+
for (const page of pages) {
|
|
2676
|
+
const seen = /* @__PURE__ */ new Set([page.id]);
|
|
2677
|
+
let parentId = page.navigation.parentId;
|
|
2678
|
+
while (parentId) {
|
|
2679
|
+
if (seen.has(parentId)) {
|
|
2680
|
+
errors.push(`document.pages contains a navigation cycle at "${page.id}"`);
|
|
2681
|
+
break;
|
|
2682
|
+
}
|
|
2683
|
+
seen.add(parentId);
|
|
2684
|
+
parentId = pages.find((candidate) => candidate.id === parentId)?.navigation.parentId;
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
let upsellOverrides;
|
|
2688
|
+
if (root.upsellOverrides !== void 0) {
|
|
2689
|
+
if (source.type !== "chapter") errors.push("document.upsellOverrides is supported only for chapter content");
|
|
2690
|
+
if (!Array.isArray(root.upsellOverrides)) errors.push("document.upsellOverrides must be an array");
|
|
2691
|
+
const values = Array.isArray(root.upsellOverrides) ? root.upsellOverrides : [];
|
|
2692
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2693
|
+
upsellOverrides = [];
|
|
2694
|
+
for (const [index, value] of values.entries()) {
|
|
2695
|
+
const path = `document.upsellOverrides[${index}]`;
|
|
2696
|
+
const raw = record2(value);
|
|
2697
|
+
if (!raw) {
|
|
2698
|
+
errors.push(`${path} must be an object`);
|
|
2699
|
+
continue;
|
|
2700
|
+
}
|
|
2701
|
+
unknownKeys2(raw, OVERRIDE_KEYS, path, errors);
|
|
2702
|
+
const pageId = textValue(raw.pageId, `${path}.pageId`, errors, 80, true) ?? "";
|
|
2703
|
+
if (seen.has(pageId)) errors.push(`${path}.pageId duplicates "${pageId}"`);
|
|
2704
|
+
seen.add(pageId);
|
|
2705
|
+
const parentPage = (options.parentPages ?? []).find((page) => page.id === pageId);
|
|
2706
|
+
if (!parentPage) errors.push(`${path}.pageId must reference a parent page`);
|
|
2707
|
+
else if (parentPage.upsell?.owner !== "chapter") errors.push(`${path}.pageId does not delegate upsell authority to the chapter`);
|
|
2708
|
+
const offer = offerValue(raw.offer, `${path}.offer`, errors, tierIds, parentPage?.audience);
|
|
2709
|
+
if (offer) upsellOverrides.push({ pageId, offer });
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
if (errors.length) return { ok: false, errors };
|
|
2713
|
+
return { ok: true, value: {
|
|
2714
|
+
schemaVersion: MEMBER_CONTENT_SCHEMA_VERSION,
|
|
2715
|
+
revision: Number(root.revision),
|
|
2716
|
+
generatedAt: Number(root.generatedAt),
|
|
2717
|
+
source,
|
|
2718
|
+
...target ? { target } : {},
|
|
2719
|
+
pages,
|
|
2720
|
+
...upsellOverrides ? { upsellOverrides } : {}
|
|
2721
|
+
} };
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2724
|
+
// src/member-content.ts
|
|
2725
|
+
var MEMBER_CONTENT_SCHEMA_VERSION = 1;
|
|
2726
|
+
var MEMBER_CONTENT_PATH = "/api/member-content";
|
|
2727
|
+
var MEMBER_CONTENT_CONTROL_PATH = "/api/member-content-control";
|
|
2728
|
+
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);
|
|
2729
|
+
function composeMemberContent(parent, chapter) {
|
|
2730
|
+
const pages = [];
|
|
2731
|
+
const parentPages = /* @__PURE__ */ new Map();
|
|
2732
|
+
for (const page of parent?.pages ?? []) {
|
|
2733
|
+
const effective = { ...page, source: parent.source };
|
|
2734
|
+
parentPages.set(page.id, effective);
|
|
2735
|
+
pages.push(effective);
|
|
2736
|
+
}
|
|
2737
|
+
for (const override of chapter?.upsellOverrides ?? []) {
|
|
2738
|
+
const page = parentPages.get(override.pageId);
|
|
2739
|
+
if (page?.upsell?.owner === "chapter") page.upsell = { owner: "chapter", offer: override.offer };
|
|
2740
|
+
}
|
|
2741
|
+
for (const page of chapter?.pages ?? []) {
|
|
2742
|
+
if (parentPages.has(page.id) || pages.some((candidate) => candidate.slug === page.slug)) {
|
|
2743
|
+
throw new TypeError(`Chapter member page "${page.id}" conflicts with parent content`);
|
|
2744
|
+
}
|
|
2745
|
+
pages.push({ ...page, source: chapter.source });
|
|
2746
|
+
}
|
|
2747
|
+
return {
|
|
2748
|
+
pages: pages.sort(pageOrder),
|
|
2749
|
+
parentRevision: parent?.revision ?? null,
|
|
2750
|
+
chapterRevision: chapter?.revision ?? null
|
|
2751
|
+
};
|
|
2752
|
+
}
|
|
2753
|
+
var allowed = (page, viewer) => viewer.admin || page.audience.kind === "all-members" || viewer.tierId !== null && page.audience.tierIds.includes(viewer.tierId);
|
|
2754
|
+
function projectMemberContent(content, viewer, tiers) {
|
|
2755
|
+
const authority = new Map(tiers.filter((tier) => tier.active !== false).map((tier) => [tier.id, tier]));
|
|
2756
|
+
return {
|
|
2757
|
+
viewer: { ...viewer },
|
|
2758
|
+
revisions: { parent: content.parentRevision, chapter: content.chapterRevision },
|
|
2759
|
+
pages: content.pages.map((page) => {
|
|
2760
|
+
const canRead = allowed(page, viewer);
|
|
2761
|
+
const offer = !canRead ? page.upsell?.offer : void 0;
|
|
2762
|
+
const tier = offer ? authority.get(offer.tierId) : void 0;
|
|
2763
|
+
const upsell = offer && tier && typeof tier.name === "string" && typeof tier.priceCents === "number" ? { ...offer, tierName: tier.name, priceCents: tier.priceCents } : void 0;
|
|
2764
|
+
return {
|
|
2765
|
+
id: page.id,
|
|
2766
|
+
slug: page.slug,
|
|
2767
|
+
title: page.title,
|
|
2768
|
+
...page.summary ? { summary: page.summary } : {},
|
|
2769
|
+
navigation: page.navigation,
|
|
2770
|
+
audience: page.audience,
|
|
2771
|
+
source: page.source,
|
|
2772
|
+
access: canRead ? "allowed" : "locked",
|
|
2773
|
+
...page.teaserMarkdown ? { teaserMarkdown: page.teaserMarkdown } : {},
|
|
2774
|
+
...canRead ? { bodyMarkdown: page.bodyMarkdown } : {},
|
|
2775
|
+
...upsell ? { upsell } : {}
|
|
2776
|
+
};
|
|
2777
|
+
})
|
|
2778
|
+
};
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
// src/member-content-store.ts
|
|
2782
|
+
var control = (row) => row && row.document && typeof row.document === "object" ? row : null;
|
|
2783
|
+
var local = (row) => row && row.document && typeof row.document === "object" ? row : null;
|
|
2784
|
+
async function exists(db, ns, where) {
|
|
2785
|
+
if (db.aggregate) return Number((await db.aggregate(ns, { count: true }, { where })).count ?? 0) > 0;
|
|
2786
|
+
const rows4 = (await db.query({ [ns]: {} }))[ns] ?? [];
|
|
2787
|
+
return rows4.some((row) => Object.entries(where).every(([key, value]) => row[key] === value));
|
|
2788
|
+
}
|
|
2789
|
+
async function loadActiveMemberContentControl(db, targetId, environment) {
|
|
2790
|
+
const where = { targetId, environment, active: true };
|
|
2791
|
+
if (!await exists(db, "memberContentControls", where)) return null;
|
|
2792
|
+
const rows4 = (await db.query({ memberContentControls: { $: {
|
|
2793
|
+
where,
|
|
2794
|
+
order: { revision: "desc" },
|
|
2795
|
+
limit: 1
|
|
2796
|
+
} } })).memberContentControls;
|
|
2797
|
+
return control(Array.isArray(rows4) ? [...rows4].sort((a, b) => Number(b.revision) - Number(a.revision))[0] : void 0);
|
|
2798
|
+
}
|
|
2799
|
+
async function loadLatestMemberContentControl(db, targetId, environment) {
|
|
2800
|
+
const where = { targetId, environment };
|
|
2801
|
+
if (!await exists(db, "memberContentControls", where)) return null;
|
|
2802
|
+
const rows4 = (await db.query({ memberContentControls: { $: {
|
|
2803
|
+
where,
|
|
2804
|
+
order: { revision: "desc" },
|
|
2805
|
+
limit: 1
|
|
2806
|
+
} } })).memberContentControls;
|
|
2807
|
+
return control(Array.isArray(rows4) ? [...rows4].sort((a, b) => Number(b.revision) - Number(a.revision))[0] : void 0);
|
|
2808
|
+
}
|
|
2809
|
+
async function loadLocalMemberContent(db, chapterId, environment) {
|
|
2810
|
+
const id = `member-content-local:${environment}`;
|
|
2811
|
+
const rows4 = (await db.query({ memberContentLocal: { $: { where: { id }, limit: 1 } } })).memberContentLocal;
|
|
2812
|
+
return local(Array.isArray(rows4) ? rows4[0] : void 0);
|
|
2813
|
+
}
|
|
2814
|
+
async function materializeMemberContentControl(db, envelope, previous, receivedAt) {
|
|
2815
|
+
const { document, digest } = envelope;
|
|
2816
|
+
const target = document.target;
|
|
2817
|
+
const id = `member-content-control:${target.environment}:${document.revision}`;
|
|
2818
|
+
const ops = [];
|
|
2819
|
+
if (previous?.active) {
|
|
2820
|
+
ops.push({ t: "update", ns: "memberContentControls", id: previous.id, attrs: { active: false } });
|
|
2821
|
+
}
|
|
2822
|
+
ops.push({ t: "update", ns: "memberContentControls", id, attrs: {
|
|
2823
|
+
id,
|
|
2824
|
+
sourceId: document.source.appId,
|
|
2825
|
+
targetId: target.chapterId,
|
|
2826
|
+
environment: target.environment,
|
|
2827
|
+
revision: document.revision,
|
|
2828
|
+
digest,
|
|
2829
|
+
document,
|
|
2830
|
+
active: true,
|
|
2831
|
+
receivedAt
|
|
2832
|
+
} });
|
|
2833
|
+
await db.transact(ops, { mutationId: `member-content-control:${target.environment}:${document.revision}:${digest}` });
|
|
2834
|
+
}
|
|
2835
|
+
async function saveLocalMemberContent(db, document, environment, expectedRevision, updatedAt, updatedBy) {
|
|
2836
|
+
const id = `member-content-local:${environment}`;
|
|
2837
|
+
const current = await loadLocalMemberContent(db, document.source.appId, environment);
|
|
2838
|
+
if ((current?.revision ?? 0) !== expectedRevision) throw new Error("member_content_revision_conflict");
|
|
2839
|
+
const row = {
|
|
2840
|
+
id,
|
|
2841
|
+
chapterId: document.source.appId,
|
|
2842
|
+
environment,
|
|
2843
|
+
revision: document.revision,
|
|
2844
|
+
document,
|
|
2845
|
+
updatedAt,
|
|
2846
|
+
...updatedBy ? { updatedBy } : {}
|
|
2847
|
+
};
|
|
2848
|
+
await db.transact([{ t: "update", ns: "memberContentLocal", id, attrs: row }], {
|
|
2849
|
+
mutationId: `member-content-local:${environment}:${document.revision}`,
|
|
2850
|
+
guards: current ? [{ ns: "memberContentLocal", id, exists: true, equals: { revision: expectedRevision } }] : [{ ns: "memberContentLocal", id, exists: false }]
|
|
2851
|
+
});
|
|
2852
|
+
return row;
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2855
|
+
// src/worker-routes-member-content.ts
|
|
2856
|
+
var ADMIN_PATH = "/api/admin/member-content";
|
|
2857
|
+
var BODY_CAP = 1e6;
|
|
2389
2858
|
var reject2 = (reason, status, errors) => json({ ok: false, reason, ...errors ? { errors } : {} }, status);
|
|
2859
|
+
async function groupAndTiers(db, chapterId) {
|
|
2860
|
+
const groups = (await db.query({ groups: { $: { where: { id: chapterId }, limit: 1 } } })).groups;
|
|
2861
|
+
const group = Array.isArray(groups) ? groups[0] : void 0;
|
|
2862
|
+
if (!group) return { group: null, tiers: [] };
|
|
2863
|
+
return { group, tiers: await loadTiers(db, group) };
|
|
2864
|
+
}
|
|
2865
|
+
async function memberTierId(db, chapterId, email) {
|
|
2866
|
+
if (!email) return null;
|
|
2867
|
+
let rows4 = (await db.query({ applications: { $: {
|
|
2868
|
+
where: { emailNormalized: normalizeAdmissionEmail(email) },
|
|
2869
|
+
limit: 20
|
|
2870
|
+
} } })).applications;
|
|
2871
|
+
if (!rows4?.length) rows4 = (await db.query({ applications: { $: { where: { email }, limit: 20 } } })).applications;
|
|
2872
|
+
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;
|
|
2873
|
+
return typeof latest?.tierId === "string" && latest.tierId ? latest.tierId : null;
|
|
2874
|
+
}
|
|
2875
|
+
async function activeDocuments(db, chapterId, environment) {
|
|
2876
|
+
const [parent, chapter] = await Promise.all([
|
|
2877
|
+
loadActiveMemberContentControl(db, chapterId, environment),
|
|
2878
|
+
loadLocalMemberContent(db, chapterId, environment)
|
|
2879
|
+
]);
|
|
2880
|
+
return { parent, chapter };
|
|
2881
|
+
}
|
|
2882
|
+
var handleMemberContentControl = async (req, url, env, ctx) => {
|
|
2883
|
+
if (req.method !== "POST" || url.pathname !== MEMBER_CONTENT_CONTROL_PATH) return null;
|
|
2884
|
+
const settings = ctx.chapter.memberContent?.parent;
|
|
2885
|
+
if (!settings) return json({ error: "not found" }, 404);
|
|
2886
|
+
const db = ctx.makeDb(env);
|
|
2887
|
+
const secret = await getVaultSecret(db, settings.secretName);
|
|
2888
|
+
if (!secret) return json({ error: "unauthorized" }, 401);
|
|
2889
|
+
const verified = await verifyFederatedRequest3(req, { secret, senders: [settings.sourceId] });
|
|
2890
|
+
if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
|
|
2891
|
+
let input;
|
|
2892
|
+
try {
|
|
2893
|
+
input = JSON.parse(verified.body);
|
|
2894
|
+
} catch {
|
|
2895
|
+
return reject2("invalid_document", 400, ["request body must be JSON"]);
|
|
2896
|
+
}
|
|
2897
|
+
const digest = typeof input.digest === "string" ? input.digest : "";
|
|
2898
|
+
const initial = validateMemberContentDocument(input.document, {
|
|
2899
|
+
sourceType: "parent"
|
|
2900
|
+
});
|
|
2901
|
+
if (!initial.ok) return reject2("invalid_document", 400, initial.errors);
|
|
2902
|
+
const document = initial.value;
|
|
2903
|
+
if (document.source.appId !== settings.sourceId) return reject2("wrong_source", 403);
|
|
2904
|
+
if (document.target?.chapterId !== ctx.chapter.id) return reject2("wrong_target", 409);
|
|
2905
|
+
if (document.target.environment !== env.ODLA_ENV) return reject2("wrong_environment", 409);
|
|
2906
|
+
const { tiers } = await groupAndTiers(db, ctx.chapter.id);
|
|
2907
|
+
const checked = validateMemberContentDocument(document, {
|
|
2908
|
+
sourceType: "parent",
|
|
2909
|
+
sourceAppId: settings.sourceId,
|
|
2910
|
+
tiers
|
|
2911
|
+
});
|
|
2912
|
+
if (!checked.ok) return reject2("invalid_document", 400, checked.errors);
|
|
2913
|
+
const local2 = await loadLocalMemberContent(db, ctx.chapter.id, env.ODLA_ENV);
|
|
2914
|
+
if (local2) {
|
|
2915
|
+
const localCheck = validateMemberContentDocument(local2.document, {
|
|
2916
|
+
sourceType: "chapter",
|
|
2917
|
+
sourceAppId: ctx.chapter.id,
|
|
2918
|
+
tiers,
|
|
2919
|
+
parentPages: document.pages
|
|
2920
|
+
});
|
|
2921
|
+
if (!localCheck.ok) {
|
|
2922
|
+
return reject2("invalid_document", 409, localCheck.errors.map((error) => `existing chapter content: ${error}`));
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
if (!digest || await memberContentDigest(document) !== digest) return reject2("invalid_digest", 400);
|
|
2926
|
+
const latest = await loadLatestMemberContentControl(db, ctx.chapter.id, env.ODLA_ENV);
|
|
2927
|
+
if (latest && document.revision < latest.revision) return reject2("stale_revision", 409);
|
|
2928
|
+
if (latest && document.revision === latest.revision) {
|
|
2929
|
+
if (latest.digest !== digest) return reject2("conflicting_revision", 409);
|
|
2930
|
+
return json({
|
|
2931
|
+
ok: true,
|
|
2932
|
+
target: document.target,
|
|
2933
|
+
revision: latest.revision,
|
|
2934
|
+
digest: latest.digest,
|
|
2935
|
+
status: "replayed",
|
|
2936
|
+
acknowledgedAt: latest.receivedAt
|
|
2937
|
+
});
|
|
2938
|
+
}
|
|
2939
|
+
const receivedAt = Date.now();
|
|
2940
|
+
try {
|
|
2941
|
+
await materializeMemberContentControl(db, { digest, document }, latest, receivedAt);
|
|
2942
|
+
} catch {
|
|
2943
|
+
return reject2("materialization_failed", 503);
|
|
2944
|
+
}
|
|
2945
|
+
return json({
|
|
2946
|
+
ok: true,
|
|
2947
|
+
target: document.target,
|
|
2948
|
+
revision: document.revision,
|
|
2949
|
+
digest,
|
|
2950
|
+
status: "accepted",
|
|
2951
|
+
acknowledgedAt: receivedAt
|
|
2952
|
+
});
|
|
2953
|
+
};
|
|
2954
|
+
var handleMemberContent = async (req, url, env, ctx) => {
|
|
2955
|
+
if (req.method !== "GET" || url.pathname !== MEMBER_CONTENT_PATH) return null;
|
|
2956
|
+
if (!ctx.chapter.memberContent) return json({ error: "not found" }, 404);
|
|
2957
|
+
const user = await ctx.verifyUser(req, env);
|
|
2958
|
+
if (!user) return json({ error: "unauthorized" }, 401);
|
|
2959
|
+
const db = ctx.makeDb(env);
|
|
2960
|
+
const role = await ctx.roleFor(db, user);
|
|
2961
|
+
const admin = await ctx.isAdmin(db, user);
|
|
2962
|
+
const roleIndex = ctx.chapter.auth.ladder.indexOf(role);
|
|
2963
|
+
const memberIndex = Math.max(0, ctx.chapter.auth.ladder.length - 2);
|
|
2964
|
+
if (!admin && roleIndex < memberIndex) return json({ error: "member access required" }, 403);
|
|
2965
|
+
const [{ parent, chapter }, { group, tiers }, tierId] = await Promise.all([
|
|
2966
|
+
activeDocuments(db, ctx.chapter.id, env.ODLA_ENV),
|
|
2967
|
+
groupAndTiers(db, ctx.chapter.id),
|
|
2968
|
+
memberTierId(db, ctx.chapter.id, user.email)
|
|
2969
|
+
]);
|
|
2970
|
+
const effective = composeMemberContent(parent?.document, chapter?.document);
|
|
2971
|
+
let offerable = tiers;
|
|
2972
|
+
if (group) {
|
|
2973
|
+
const stripeSecret = await getVaultSecret(db, "stripe_secret_key");
|
|
2974
|
+
offerable = await loadOfferableTiers(db, group, Boolean(stripeSecret));
|
|
2975
|
+
}
|
|
2976
|
+
return json(projectMemberContent(effective, { tierId, admin }, offerable));
|
|
2977
|
+
};
|
|
2978
|
+
var handleAdminMemberContent = async (req, url, env, ctx) => {
|
|
2979
|
+
if (url.pathname !== ADMIN_PATH) return null;
|
|
2980
|
+
if (!ctx.chapter.memberContent) return json({ error: "not found" }, 404);
|
|
2981
|
+
const user = await ctx.verifyUser(req, env);
|
|
2982
|
+
if (!user) return json({ error: "unauthorized" }, 401);
|
|
2983
|
+
const db = ctx.makeDb(env);
|
|
2984
|
+
if (!await ctx.isAdmin(db, user)) return json({ error: "forbidden" }, 403);
|
|
2985
|
+
const [{ parent, chapter }, { tiers }] = await Promise.all([
|
|
2986
|
+
activeDocuments(db, ctx.chapter.id, env.ODLA_ENV),
|
|
2987
|
+
groupAndTiers(db, ctx.chapter.id)
|
|
2988
|
+
]);
|
|
2989
|
+
if (req.method === "GET") {
|
|
2990
|
+
const effective = composeMemberContent(parent?.document, chapter?.document);
|
|
2991
|
+
return json({
|
|
2992
|
+
parent: parent ? { revision: parent.revision, digest: parent.digest, document: parent.document } : null,
|
|
2993
|
+
chapter: chapter ? { revision: chapter.revision, document: chapter.document } : null,
|
|
2994
|
+
effective,
|
|
2995
|
+
tiers: tiers.map(({ id, name, priceCents, active }) => ({ id, name, priceCents, active })),
|
|
2996
|
+
localAuthoring: ctx.chapter.memberContent.localAuthoring
|
|
2997
|
+
});
|
|
2998
|
+
}
|
|
2999
|
+
if (req.method !== "PUT") return json({ error: "method not allowed" }, 405);
|
|
3000
|
+
if (!ctx.chapter.memberContent.localAuthoring) return json({ error: "local authoring is disabled" }, 403);
|
|
3001
|
+
const raw = await req.text();
|
|
3002
|
+
if (raw.length > BODY_CAP) return json({ error: "request body too large" }, 413);
|
|
3003
|
+
let input;
|
|
3004
|
+
try {
|
|
3005
|
+
input = JSON.parse(raw);
|
|
3006
|
+
} catch {
|
|
3007
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
3008
|
+
}
|
|
3009
|
+
const expectedRevision = Number(input.expectedRevision);
|
|
3010
|
+
if (!Number.isInteger(expectedRevision) || expectedRevision < 0) {
|
|
3011
|
+
return json({ error: "expectedRevision must be a non-negative integer" }, 400);
|
|
3012
|
+
}
|
|
3013
|
+
const now = Date.now();
|
|
3014
|
+
const documentInput = withLocalAuthority(input.document, expectedRevision + 1, now, ctx.chapter.id);
|
|
3015
|
+
const checked = validateMemberContentDocument(documentInput, {
|
|
3016
|
+
sourceType: "chapter",
|
|
3017
|
+
sourceAppId: ctx.chapter.id,
|
|
3018
|
+
tiers,
|
|
3019
|
+
parentPages: parent?.document.pages ?? []
|
|
3020
|
+
});
|
|
3021
|
+
if (!checked.ok) return json({ error: "invalid member content", errors: checked.errors }, 400);
|
|
3022
|
+
try {
|
|
3023
|
+
const saved = await saveLocalMemberContent(
|
|
3024
|
+
db,
|
|
3025
|
+
checked.value,
|
|
3026
|
+
env.ODLA_ENV,
|
|
3027
|
+
expectedRevision,
|
|
3028
|
+
now,
|
|
3029
|
+
user.email ?? user.userId
|
|
3030
|
+
);
|
|
3031
|
+
return json({ ok: true, revision: saved.revision, document: saved.document });
|
|
3032
|
+
} catch (error) {
|
|
3033
|
+
if (error instanceof Error && error.message === "member_content_revision_conflict") {
|
|
3034
|
+
return json({ error: "member content changed; reload before saving" }, 409);
|
|
3035
|
+
}
|
|
3036
|
+
return json({ error: "member content could not be saved" }, 503);
|
|
3037
|
+
}
|
|
3038
|
+
};
|
|
3039
|
+
function withLocalAuthority(value, revision, generatedAt, appId) {
|
|
3040
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
|
3041
|
+
return {
|
|
3042
|
+
...value,
|
|
3043
|
+
schemaVersion: 1,
|
|
3044
|
+
revision,
|
|
3045
|
+
generatedAt,
|
|
3046
|
+
source: { type: "chapter", appId }
|
|
3047
|
+
};
|
|
3048
|
+
}
|
|
3049
|
+
|
|
3050
|
+
// src/worker-routes-admission-grants.ts
|
|
3051
|
+
import { verifyFederatedRequest as verifyFederatedRequest4 } from "@odla-ai/db";
|
|
3052
|
+
var reject3 = (reason, status, errors) => json({ ok: false, reason, ...errors ? { errors } : {} }, status);
|
|
2390
3053
|
var handleAdmissionGrants = async (req, url, env, ctx) => {
|
|
2391
3054
|
if (req.method !== "POST" || url.pathname !== ADMISSION_GRANTS_PATH) return null;
|
|
2392
3055
|
const authority = ctx.chapter.signupControl;
|
|
@@ -2394,26 +3057,26 @@ var handleAdmissionGrants = async (req, url, env, ctx) => {
|
|
|
2394
3057
|
const db = ctx.makeDb(env);
|
|
2395
3058
|
const secret = await getVaultSecret(db, authority.secretName);
|
|
2396
3059
|
if (!secret) return json({ error: "unauthorized" }, 401);
|
|
2397
|
-
const verified = await
|
|
3060
|
+
const verified = await verifyFederatedRequest4(req, { secret, senders: [authority.sourceId] });
|
|
2398
3061
|
if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
|
|
2399
3062
|
let input;
|
|
2400
3063
|
try {
|
|
2401
3064
|
input = JSON.parse(verified.body);
|
|
2402
3065
|
} catch {
|
|
2403
|
-
return
|
|
3066
|
+
return reject3("invalid_document", 400, ["request body must be JSON"]);
|
|
2404
3067
|
}
|
|
2405
3068
|
const digest = typeof input.digest === "string" ? input.digest : "";
|
|
2406
3069
|
const checked = validateAdmissionGrantDocument(input.document);
|
|
2407
|
-
if (!checked.ok) return
|
|
3070
|
+
if (!checked.ok) return reject3("invalid_document", 400, checked.errors);
|
|
2408
3071
|
const document = checked.value;
|
|
2409
|
-
if (document.source.appId !== authority.sourceId) return
|
|
2410
|
-
if (document.target.chapterId !== ctx.chapter.id) return
|
|
2411
|
-
if (document.target.environment !== env.ODLA_ENV) return
|
|
2412
|
-
if (!digest || await signupControlDigest(document) !== digest) return
|
|
3072
|
+
if (document.source.appId !== authority.sourceId) return reject3("wrong_source", 403);
|
|
3073
|
+
if (document.target.chapterId !== ctx.chapter.id) return reject3("wrong_target", 409);
|
|
3074
|
+
if (document.target.environment !== env.ODLA_ENV) return reject3("wrong_environment", 409);
|
|
3075
|
+
if (!digest || await signupControlDigest(document) !== digest) return reject3("invalid_digest", 400);
|
|
2413
3076
|
const latest = await loadLatestAdmissionRevision(db, authority.sourceId, ctx.chapter.id, env.ODLA_ENV);
|
|
2414
|
-
if (latest && document.revision < latest.revision) return
|
|
3077
|
+
if (latest && document.revision < latest.revision) return reject3("stale_revision", 409);
|
|
2415
3078
|
if (latest && document.revision === latest.revision) {
|
|
2416
|
-
if (latest.digest !== digest) return
|
|
3079
|
+
if (latest.digest !== digest) return reject3("conflicting_revision", 409);
|
|
2417
3080
|
return json({
|
|
2418
3081
|
ok: true,
|
|
2419
3082
|
target: { chapterId: ctx.chapter.id, environment: env.ODLA_ENV },
|
|
@@ -2427,7 +3090,7 @@ var handleAdmissionGrants = async (req, url, env, ctx) => {
|
|
|
2427
3090
|
try {
|
|
2428
3091
|
await materializeAdmissionGrants(db, { digest, document }, latest, receivedAt);
|
|
2429
3092
|
} catch {
|
|
2430
|
-
return
|
|
3093
|
+
return reject3("materialization_failed", 503);
|
|
2431
3094
|
}
|
|
2432
3095
|
return json({
|
|
2433
3096
|
ok: true,
|
|
@@ -2469,7 +3132,7 @@ var handleAdminAdmissionGrants = async (req, url, env, ctx) => {
|
|
|
2469
3132
|
return json({ error: "invalid JSON body" }, 400);
|
|
2470
3133
|
}
|
|
2471
3134
|
const email = typeof body.email === "string" ? body.email.trim() : "";
|
|
2472
|
-
const emailNormalized =
|
|
3135
|
+
const emailNormalized = normalizeChapterEmail(email);
|
|
2473
3136
|
if (!isValidEmail(emailNormalized)) return json({ error: "email must be valid" }, 400);
|
|
2474
3137
|
const decision = body.decision ?? "waive_interview";
|
|
2475
3138
|
if (decision !== "waive_interview" && decision !== "require_interview") {
|
|
@@ -2971,7 +3634,7 @@ async function rotateGiftClaim(db, claim, now) {
|
|
|
2971
3634
|
async function redeemGiftClaim(db, claim, beneficiary, now) {
|
|
2972
3635
|
if (claim.status !== "pending" || Number(claim.codeExpiresAt) <= now) return null;
|
|
2973
3636
|
const chapterId = String(claim.chapterId);
|
|
2974
|
-
const emailNormalized =
|
|
3637
|
+
const emailNormalized = normalizeChapterEmail(beneficiary.email);
|
|
2975
3638
|
const target = claim.target;
|
|
2976
3639
|
const ops = [];
|
|
2977
3640
|
let result;
|
|
@@ -3255,8 +3918,8 @@ async function startSubscription(req, env, ctx) {
|
|
|
3255
3918
|
const secretKey = await getVaultSecret(db, "stripe_secret_key");
|
|
3256
3919
|
if (!group || !secretKey) return json({ error: "payments not configured" }, 503);
|
|
3257
3920
|
if (typeof app.giftOfferId === "string") {
|
|
3258
|
-
const
|
|
3259
|
-
const offer = findGiftOffer(
|
|
3921
|
+
const control2 = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
|
|
3922
|
+
const offer = findGiftOffer(control2?.document.giftOffers, app.giftOfferId, Date.now());
|
|
3260
3923
|
if (!offer || typeof group.stripePublishableKey !== "string" || !group.stripePublishableKey) {
|
|
3261
3924
|
return json({ error: "gift offer is unavailable" }, 409);
|
|
3262
3925
|
}
|
|
@@ -3268,7 +3931,7 @@ async function startSubscription(req, env, ctx) {
|
|
|
3268
3931
|
offer,
|
|
3269
3932
|
purchaserType: "application",
|
|
3270
3933
|
purchaserEmail: String(app.email ?? ""),
|
|
3271
|
-
purchaserEmailNormalized:
|
|
3934
|
+
purchaserEmailNormalized: normalizeChapterEmail(String(app.email ?? "")),
|
|
3272
3935
|
purchaserApplicationId: applicationId,
|
|
3273
3936
|
stripeSecret: secretKey,
|
|
3274
3937
|
publishableKey: group.stripePublishableKey,
|
|
@@ -3415,14 +4078,14 @@ var handleGifts = async (req, url, env, ctx) => {
|
|
|
3415
4078
|
if (req.method === "GET" && url.pathname === "/api/gifts") {
|
|
3416
4079
|
const user = await userFor(req, env, ctx);
|
|
3417
4080
|
if (!user) return json({ error: "unauthorized" }, 401);
|
|
3418
|
-
const
|
|
4081
|
+
const control2 = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
|
|
3419
4082
|
const found = await db.query({ giftClaims: { $: { where: {
|
|
3420
|
-
purchaserEmailNormalized:
|
|
4083
|
+
purchaserEmailNormalized: normalizeChapterEmail(user.email),
|
|
3421
4084
|
chapterId: ctx.chapter.id
|
|
3422
4085
|
}, limit: 200 } } });
|
|
3423
4086
|
const group = first3((await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups);
|
|
3424
4087
|
return json({
|
|
3425
|
-
offers: publicGiftOffers(
|
|
4088
|
+
offers: publicGiftOffers(control2?.document.giftOffers, Date.now()),
|
|
3426
4089
|
claims: buyerGiftClaims(rows2(found.giftClaims).sort((a, b) => Number(b.createdAt) - Number(a.createdAt)), Date.now()),
|
|
3427
4090
|
refundPolicyText: typeof group?.refundPolicyText === "string" ? group.refundPolicyText : ""
|
|
3428
4091
|
});
|
|
@@ -3438,13 +4101,13 @@ var handleGifts = async (req, url, env, ctx) => {
|
|
|
3438
4101
|
}
|
|
3439
4102
|
const purchaseId = typeof body.purchaseId === "string" ? body.purchaseId : "";
|
|
3440
4103
|
if (!/^[A-Za-z0-9_-]{16,128}$/.test(purchaseId)) return json({ error: "purchaseId is invalid" }, 400);
|
|
3441
|
-
const
|
|
3442
|
-
const offer = findGiftOffer(
|
|
4104
|
+
const control2 = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
|
|
4105
|
+
const offer = findGiftOffer(control2?.document.giftOffers, typeof body.offerId === "string" ? body.offerId : "", Date.now());
|
|
3443
4106
|
if (!offer) return json({ error: "gift offer is unavailable" }, 409);
|
|
3444
4107
|
const group = first3((await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups);
|
|
3445
4108
|
const stripeSecret = await getVaultSecret(db, "stripe_secret_key");
|
|
3446
4109
|
if (!group || !stripeSecret || typeof group.stripePublishableKey !== "string") return json({ error: "payments not configured" }, 503);
|
|
3447
|
-
const emailNormalized =
|
|
4110
|
+
const emailNormalized = normalizeChapterEmail(user.email);
|
|
3448
4111
|
const applications = rows2((await db.query({ applications: { $: { where: { emailNormalized }, limit: 20 } } })).applications);
|
|
3449
4112
|
const app = applications.sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0];
|
|
3450
4113
|
try {
|
|
@@ -3486,7 +4149,7 @@ var handleGifts = async (req, url, env, ctx) => {
|
|
|
3486
4149
|
return invalid();
|
|
3487
4150
|
}
|
|
3488
4151
|
}
|
|
3489
|
-
const owns = user ? claim.purchaserEmailNormalized ===
|
|
4152
|
+
const owns = user ? claim.purchaserEmailNormalized === normalizeChapterEmail(user.email) : claim.purchaserApplicationId === applicationId;
|
|
3490
4153
|
if (!owns) return invalid();
|
|
3491
4154
|
const rotated = await rotateGiftClaim(db, claim, Date.now());
|
|
3492
4155
|
return rotated ? json(rotated) : invalid();
|
|
@@ -4360,7 +5023,7 @@ var handleAdminComms = async (req, url, env, ctx) => {
|
|
|
4360
5023
|
|
|
4361
5024
|
// src/worker-routes-network.ts
|
|
4362
5025
|
import { getRecord, typeSummary } from "@odla-ai/crm";
|
|
4363
|
-
import { signFederatedRequest as signFederatedRequest2, verifyFederatedRequest as
|
|
5026
|
+
import { signFederatedRequest as signFederatedRequest2, verifyFederatedRequest as verifyFederatedRequest5 } from "@odla-ai/db";
|
|
4364
5027
|
|
|
4365
5028
|
// src/worker-network-fetch.ts
|
|
4366
5029
|
function targetFetcher(env, target) {
|
|
@@ -4379,7 +5042,7 @@ function fetchNetworkTarget(env, target, destination, init) {
|
|
|
4379
5042
|
// src/worker-network-push.ts
|
|
4380
5043
|
import { recordDeliveryAttempt } from "@odla-ai/crm";
|
|
4381
5044
|
import { signFederatedRequest } from "@odla-ai/db";
|
|
4382
|
-
async function pushNetworkRecord(db, env, ctx, target,
|
|
5045
|
+
async function pushNetworkRecord(db, env, ctx, target, record3) {
|
|
4383
5046
|
const secret = await getVaultSecret(db, target.secretName);
|
|
4384
5047
|
const crmDeps3 = {
|
|
4385
5048
|
crm: ctx.chapter.crm,
|
|
@@ -4390,7 +5053,7 @@ async function pushNetworkRecord(db, env, ctx, target, record2) {
|
|
|
4390
5053
|
if (!secret) {
|
|
4391
5054
|
const error = `vault secret "${target.secretName}" is missing`;
|
|
4392
5055
|
await recordDeliveryAttempt(crmDeps3, {
|
|
4393
|
-
recordId:
|
|
5056
|
+
recordId: record3.id,
|
|
4394
5057
|
targetId: target.id,
|
|
4395
5058
|
status: "failed",
|
|
4396
5059
|
payloadVersion: 2,
|
|
@@ -4400,7 +5063,7 @@ async function pushNetworkRecord(db, env, ctx, target, record2) {
|
|
|
4400
5063
|
}
|
|
4401
5064
|
let payload;
|
|
4402
5065
|
try {
|
|
4403
|
-
payload = sharedRecordFromCrm(ctx.chapter.crm,
|
|
5066
|
+
payload = sharedRecordFromCrm(ctx.chapter.crm, record3, target, ctx.chapter.id);
|
|
4404
5067
|
} catch (err) {
|
|
4405
5068
|
return {
|
|
4406
5069
|
id: target.id,
|
|
@@ -4429,7 +5092,7 @@ async function pushNetworkRecord(db, env, ctx, target, record2) {
|
|
|
4429
5092
|
if (!res.ok) {
|
|
4430
5093
|
const error = responseBody.error ?? "follower rejected the record";
|
|
4431
5094
|
await recordDeliveryAttempt(crmDeps3, {
|
|
4432
|
-
recordId:
|
|
5095
|
+
recordId: record3.id,
|
|
4433
5096
|
targetId: target.id,
|
|
4434
5097
|
status: "failed",
|
|
4435
5098
|
payloadVersion: 2,
|
|
@@ -4444,7 +5107,7 @@ async function pushNetworkRecord(db, env, ctx, target, record2) {
|
|
|
4444
5107
|
};
|
|
4445
5108
|
}
|
|
4446
5109
|
await recordDeliveryAttempt(crmDeps3, {
|
|
4447
|
-
recordId:
|
|
5110
|
+
recordId: record3.id,
|
|
4448
5111
|
targetId: target.id,
|
|
4449
5112
|
status: "delivered",
|
|
4450
5113
|
payloadVersion: 2,
|
|
@@ -4460,7 +5123,7 @@ async function pushNetworkRecord(db, env, ctx, target, record2) {
|
|
|
4460
5123
|
} catch (err) {
|
|
4461
5124
|
const error = err instanceof Error ? err.message : "delivery failed";
|
|
4462
5125
|
await recordDeliveryAttempt(crmDeps3, {
|
|
4463
|
-
recordId:
|
|
5126
|
+
recordId: record3.id,
|
|
4464
5127
|
targetId: target.id,
|
|
4465
5128
|
status: "failed",
|
|
4466
5129
|
payloadVersion: 2,
|
|
@@ -4497,7 +5160,7 @@ var handleNetworkSnapshot = async (req, url, env, ctx) => {
|
|
|
4497
5160
|
const db = ctx.makeDb(env);
|
|
4498
5161
|
const secret = await getVaultSecret(db, "network_share_secret");
|
|
4499
5162
|
if (!secret) return json({ error: "unauthorized" }, 401);
|
|
4500
|
-
const verified = await
|
|
5163
|
+
const verified = await verifyFederatedRequest5(req, { secret });
|
|
4501
5164
|
if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
|
|
4502
5165
|
const types = await Promise.all(
|
|
4503
5166
|
Object.keys(ctx.chapter.crm.config.types).map((type) => typeSummary({ crm: ctx.chapter.crm, db }, type))
|
|
@@ -4598,17 +5261,17 @@ var handleAdminNetworkPush = async (req, url, env, ctx) => {
|
|
|
4598
5261
|
if (requested.size !== body.targetIds.length) return json({ error: "targetIds must contain unique strings" }, 400);
|
|
4599
5262
|
const targets = ctx.chapter.network.targets.filter((target) => requested.has(target.id));
|
|
4600
5263
|
if (targets.length !== requested.size) return json({ error: "one or more targetIds are not configured" }, 400);
|
|
4601
|
-
const
|
|
4602
|
-
if (!
|
|
5264
|
+
const record3 = await getRecord({ crm: ctx.chapter.crm, db: got.db }, body.recordId);
|
|
5265
|
+
if (!record3) return json({ error: "record not found" }, 404);
|
|
4603
5266
|
const results = await Promise.all(
|
|
4604
|
-
targets.map((target) => pushNetworkRecord(got.db, env, ctx, target,
|
|
5267
|
+
targets.map((target) => pushNetworkRecord(got.db, env, ctx, target, record3))
|
|
4605
5268
|
);
|
|
4606
5269
|
return json({ ok: results.every((result) => result.ok), results });
|
|
4607
5270
|
};
|
|
4608
5271
|
|
|
4609
5272
|
// src/worker-routes-network-records.ts
|
|
4610
5273
|
import { listRecords } from "@odla-ai/crm";
|
|
4611
|
-
import { signFederatedRequest as signFederatedRequest3, verifyFederatedRequest as
|
|
5274
|
+
import { signFederatedRequest as signFederatedRequest3, verifyFederatedRequest as verifyFederatedRequest6 } from "@odla-ai/db";
|
|
4612
5275
|
var IDENTIFIER = /^[a-z][a-zA-Z0-9_]*$/;
|
|
4613
5276
|
var MAX_PAGE = 50;
|
|
4614
5277
|
var MAX_OFFSET = 1e4;
|
|
@@ -4626,18 +5289,18 @@ function integer(value, fallback, min, max) {
|
|
|
4626
5289
|
const parsed = Number(value);
|
|
4627
5290
|
return Number.isSafeInteger(parsed) && parsed >= min && parsed <= max ? parsed : null;
|
|
4628
5291
|
}
|
|
4629
|
-
function projectRecord(
|
|
5292
|
+
function projectRecord(record3, allowed2) {
|
|
4630
5293
|
const fields = Object.fromEntries(
|
|
4631
|
-
|
|
5294
|
+
allowed2.flatMap((name) => Object.prototype.hasOwnProperty.call(record3.fields, name) ? [[name, record3.fields[name]]] : [])
|
|
4632
5295
|
);
|
|
4633
5296
|
return {
|
|
4634
|
-
id:
|
|
4635
|
-
type:
|
|
4636
|
-
name:
|
|
4637
|
-
...
|
|
5297
|
+
id: record3.id,
|
|
5298
|
+
type: record3.type,
|
|
5299
|
+
name: record3.name,
|
|
5300
|
+
...record3.stage ? { stage: record3.stage } : {},
|
|
4638
5301
|
fields,
|
|
4639
|
-
createdAt:
|
|
4640
|
-
updatedAt:
|
|
5302
|
+
createdAt: record3.createdAt,
|
|
5303
|
+
updatedAt: record3.updatedAt
|
|
4641
5304
|
};
|
|
4642
5305
|
}
|
|
4643
5306
|
var handleNetworkRecords = async (req, url, env, ctx) => {
|
|
@@ -4646,15 +5309,15 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
|
|
|
4646
5309
|
const secret = await getVaultSecret(db, "network_share_secret");
|
|
4647
5310
|
const readers = ctx.chapter.network.readers;
|
|
4648
5311
|
if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
|
|
4649
|
-
const verified = await
|
|
5312
|
+
const verified = await verifyFederatedRequest6(req, {
|
|
4650
5313
|
secret,
|
|
4651
5314
|
senders: readers.map((reader2) => reader2.id)
|
|
4652
5315
|
});
|
|
4653
5316
|
if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
|
|
4654
5317
|
const reader = readers.find((candidate) => candidate.id === verified.sender);
|
|
4655
5318
|
const type = url.searchParams.get("type") ?? "";
|
|
4656
|
-
const
|
|
4657
|
-
if (!IDENTIFIER.test(type) || !
|
|
5319
|
+
const allowed2 = reader?.fields[type];
|
|
5320
|
+
if (!IDENTIFIER.test(type) || !allowed2) return json({ error: "record type is not shared with this reader" }, 403);
|
|
4658
5321
|
const limit = integer(url.searchParams.get("limit"), 25, 1, MAX_PAGE);
|
|
4659
5322
|
const offset = integer(url.searchParams.get("offset"), 0, 0, MAX_OFFSET);
|
|
4660
5323
|
const search = (url.searchParams.get("search") ?? "").trim();
|
|
@@ -4675,7 +5338,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
|
|
|
4675
5338
|
version: 1,
|
|
4676
5339
|
site: { id: ctx.chapter.id, name: ctx.chapter.name },
|
|
4677
5340
|
type,
|
|
4678
|
-
records: page.records.map((
|
|
5341
|
+
records: page.records.map((record3) => projectRecord(record3, allowed2)),
|
|
4679
5342
|
total: page.total,
|
|
4680
5343
|
limit: page.limit,
|
|
4681
5344
|
offset: page.offset
|
|
@@ -4830,7 +5493,7 @@ import {
|
|
|
4830
5493
|
CRM_NS,
|
|
4831
5494
|
getRecord as getRecord2
|
|
4832
5495
|
} from "@odla-ai/crm";
|
|
4833
|
-
import { signFederatedRequest as signFederatedRequest4, verifyFederatedRequest as
|
|
5496
|
+
import { signFederatedRequest as signFederatedRequest4, verifyFederatedRequest as verifyFederatedRequest7 } from "@odla-ai/db";
|
|
4834
5497
|
var IDENTIFIER3 = /^[a-z][a-zA-Z0-9_]*$/;
|
|
4835
5498
|
var RECORD_ID2 = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
|
|
4836
5499
|
var MUTATION_ID = /^[A-Za-z0-9_-]{16,128}$/;
|
|
@@ -4889,7 +5552,7 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
4889
5552
|
const secret = await getVaultSecret(db, "network_share_secret");
|
|
4890
5553
|
const readers = ctx.chapter.network.readers;
|
|
4891
5554
|
if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
|
|
4892
|
-
const verified = await
|
|
5555
|
+
const verified = await verifyFederatedRequest7(req, {
|
|
4893
5556
|
secret,
|
|
4894
5557
|
senders: readers.map((reader2) => reader2.id)
|
|
4895
5558
|
});
|
|
@@ -4900,8 +5563,8 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
4900
5563
|
if (!IDENTIFIER3.test(recordType) || !RECORD_ID2.test(recordId) || !reader?.sharedNotes.includes(recordType)) {
|
|
4901
5564
|
return json({ error: "shared notes are not enabled for this reader and record type" }, 403);
|
|
4902
5565
|
}
|
|
4903
|
-
const
|
|
4904
|
-
if (!
|
|
5566
|
+
const record3 = await getRecord2({ crm: ctx.chapter.crm, db }, recordId);
|
|
5567
|
+
if (!record3 || record3.type !== recordType) return json({ error: "record not found" }, 404);
|
|
4905
5568
|
if (req.method === "GET") {
|
|
4906
5569
|
return json({
|
|
4907
5570
|
version: 1,
|
|
@@ -5014,31 +5677,17 @@ var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
5014
5677
|
|
|
5015
5678
|
// src/worker-routes-formation.ts
|
|
5016
5679
|
import { createRecord as createRecord3 } from "@odla-ai/crm";
|
|
5017
|
-
|
|
5018
|
-
// src/formation.ts
|
|
5019
|
-
function formationFields(crm, formation) {
|
|
5020
|
-
const def = crm.type(formation.type);
|
|
5021
|
-
return [...formation.required, ...formation.optional].map((id) => {
|
|
5022
|
-
const field = def.fields[id];
|
|
5023
|
-
return {
|
|
5024
|
-
id,
|
|
5025
|
-
label: field.label ?? id,
|
|
5026
|
-
type: field.type,
|
|
5027
|
-
required: formation.required.includes(id),
|
|
5028
|
-
...field.options ? { options: field.options } : {}
|
|
5029
|
-
};
|
|
5030
|
-
});
|
|
5031
|
-
}
|
|
5032
|
-
|
|
5033
|
-
// src/worker-routes-formation.ts
|
|
5034
5680
|
var SUBMISSION = /^[A-Za-z0-9_-]{8,128}$/;
|
|
5035
5681
|
var handleFormation = async (req, url, env, ctx) => {
|
|
5036
5682
|
const formation = ctx.chapter.formation;
|
|
5037
5683
|
if (!formation.enabled) return null;
|
|
5038
5684
|
if (req.method === "GET" && url.pathname === "/api/formation/config") {
|
|
5685
|
+
const fieldContract = chapterFlowFieldContract(ctx.chapter, "formation");
|
|
5039
5686
|
return json({
|
|
5040
5687
|
type: formation.type,
|
|
5041
|
-
|
|
5688
|
+
fieldContract,
|
|
5689
|
+
// Backward compatibility for hosts that consumed the original array.
|
|
5690
|
+
fields: fieldContract.fields
|
|
5042
5691
|
});
|
|
5043
5692
|
}
|
|
5044
5693
|
if (req.method !== "POST" || url.pathname !== "/api/formation/applications") return null;
|
|
@@ -5055,15 +5704,15 @@ var handleFormation = async (req, url, env, ctx) => {
|
|
|
5055
5704
|
if (submissionId && !SUBMISSION.test(submissionId)) {
|
|
5056
5705
|
return json({ error: "submissionId must be an 8\u2013128 character base64url value" }, 400);
|
|
5057
5706
|
}
|
|
5058
|
-
const
|
|
5059
|
-
const unknown = Object.keys(body).filter((field) => field !== "submissionId" && !
|
|
5707
|
+
const allowed2 = /* @__PURE__ */ new Set([...formation.required, ...formation.optional]);
|
|
5708
|
+
const unknown = Object.keys(body).filter((field) => field !== "submissionId" && !allowed2.has(field));
|
|
5060
5709
|
if (unknown.length) return json({ error: `field "${unknown[0]}" is not accepted` }, 400);
|
|
5061
5710
|
for (const field of formation.required) {
|
|
5062
5711
|
const value = body[field];
|
|
5063
5712
|
if (typeof value !== "string" || value.trim() === "") return json({ error: `${field} is required` }, 400);
|
|
5064
5713
|
}
|
|
5065
5714
|
const input = {};
|
|
5066
|
-
for (const field of
|
|
5715
|
+
for (const field of allowed2) {
|
|
5067
5716
|
const value = body[field];
|
|
5068
5717
|
if (value === void 0) continue;
|
|
5069
5718
|
const cap = formation.maxLen[field] ?? formation.defaultMaxLen;
|
|
@@ -5131,12 +5780,37 @@ async function deliverSignupControl(options) {
|
|
|
5131
5780
|
return result;
|
|
5132
5781
|
}
|
|
5133
5782
|
|
|
5134
|
-
// src/
|
|
5783
|
+
// src/member-content-delivery.ts
|
|
5135
5784
|
import { signFederatedRequest as signFederatedRequest6 } from "@odla-ai/db";
|
|
5785
|
+
async function deliverMemberContent(options) {
|
|
5786
|
+
const destination = new URL(MEMBER_CONTENT_CONTROL_PATH, options.targetUrl);
|
|
5787
|
+
const body = JSON.stringify(options.envelope);
|
|
5788
|
+
const signed = await signFederatedRequest6({
|
|
5789
|
+
secret: options.secret,
|
|
5790
|
+
sender: options.sender,
|
|
5791
|
+
method: "POST",
|
|
5792
|
+
url: destination,
|
|
5793
|
+
body
|
|
5794
|
+
});
|
|
5795
|
+
const response = await (options.fetch ?? globalThis.fetch)(destination, {
|
|
5796
|
+
method: "POST",
|
|
5797
|
+
headers: { ...signed, "content-type": "application/json" },
|
|
5798
|
+
body
|
|
5799
|
+
});
|
|
5800
|
+
const result = await response.json();
|
|
5801
|
+
if (!response.ok || !("ok" in result) || result.ok !== true) {
|
|
5802
|
+
const failure = result;
|
|
5803
|
+
throw new Error(`member content rejected (${response.status}): ${failure.reason ?? failure.error ?? "unknown"}`);
|
|
5804
|
+
}
|
|
5805
|
+
return result;
|
|
5806
|
+
}
|
|
5807
|
+
|
|
5808
|
+
// src/admission-grants-delivery.ts
|
|
5809
|
+
import { signFederatedRequest as signFederatedRequest7 } from "@odla-ai/db";
|
|
5136
5810
|
async function deliverAdmissionGrants(options) {
|
|
5137
5811
|
const destination = new URL(ADMISSION_GRANTS_PATH, options.targetUrl);
|
|
5138
5812
|
const body = JSON.stringify(options.envelope);
|
|
5139
|
-
const signed = await
|
|
5813
|
+
const signed = await signFederatedRequest7({
|
|
5140
5814
|
secret: options.secret,
|
|
5141
5815
|
sender: options.sender,
|
|
5142
5816
|
method: "POST",
|
|
@@ -5295,12 +5969,14 @@ var BUILTIN_ROUTES = [
|
|
|
5295
5969
|
handleCrm,
|
|
5296
5970
|
handleNetworkShared,
|
|
5297
5971
|
handleSignupControl,
|
|
5972
|
+
handleMemberContentControl,
|
|
5298
5973
|
handleAdmissionGrants,
|
|
5299
5974
|
handleNetworkSnapshot,
|
|
5300
5975
|
handleNetworkRecords,
|
|
5301
5976
|
handleNetworkSharedNotes,
|
|
5302
5977
|
handleFormation,
|
|
5303
5978
|
handleMember,
|
|
5979
|
+
handleMemberContent,
|
|
5304
5980
|
handleGifts,
|
|
5305
5981
|
handleSchedule,
|
|
5306
5982
|
handlePayments,
|
|
@@ -5316,6 +5992,7 @@ var BUILTIN_ROUTES = [
|
|
|
5316
5992
|
// Aggregation
|
|
5317
5993
|
handleAdminDashboard,
|
|
5318
5994
|
handleAdminBilling,
|
|
5995
|
+
handleAdminMemberContent,
|
|
5319
5996
|
// Lifecycle actions
|
|
5320
5997
|
handleAdminMeetingReschedule,
|
|
5321
5998
|
handleAdminMeetingCancel,
|
|
@@ -5357,6 +6034,7 @@ export {
|
|
|
5357
6034
|
chapterWorker,
|
|
5358
6035
|
createWorkerContext,
|
|
5359
6036
|
deliverAdmissionGrants,
|
|
6037
|
+
deliverMemberContent,
|
|
5360
6038
|
deliverSignupControl,
|
|
5361
6039
|
renderActiveSignupControlHtml,
|
|
5362
6040
|
withActiveSignupControlTheme
|