@odla-ai/chapter 0.19.0 → 0.20.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 +87 -21
- package/dist/{chunk-GWY2ZTN7.js → chunk-LME2IHY5.js} +2 -50
- package/dist/chunk-LME2IHY5.js.map +1 -0
- package/dist/chunk-MKXHZMAP.js +51 -0
- package/dist/chunk-MKXHZMAP.js.map +1 -0
- package/dist/{chunk-D3QMK3ED.js → chunk-UIWFEESI.js} +611 -470
- package/dist/chunk-UIWFEESI.js.map +1 -0
- package/dist/index.cjs +176 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +83 -9
- package/dist/index.d.ts +83 -9
- package/dist/index.js +176 -11
- package/dist/index.js.map +1 -1
- package/dist/types-D2fGiNG8.d.ts +366 -0
- package/dist/ui/admin/index.d.ts +37 -3
- package/dist/ui/admin/index.js +10 -1
- package/dist/ui/index.d.ts +2 -1
- package/dist/ui/index.js +13 -3
- package/dist/ui/member/index.d.ts +2 -25
- package/dist/ui/member/index.js +4 -2
- package/dist/worker/index.cjs +225 -24
- package/dist/worker/index.cjs.map +1 -1
- package/dist/worker/index.d.cts +45 -0
- package/dist/worker/index.d.ts +45 -0
- package/dist/worker/index.js +225 -24
- package/dist/worker/index.js.map +1 -1
- package/package.json +2 -2
- package/dist/chunk-D3QMK3ED.js.map +0 -1
- package/dist/chunk-GWY2ZTN7.js.map +0 -1
package/dist/worker/index.d.cts
CHANGED
|
@@ -47,6 +47,9 @@ interface ChapterBrand {
|
|
|
47
47
|
body?: string;
|
|
48
48
|
numeral?: string;
|
|
49
49
|
};
|
|
50
|
+
/** Short glyph used in compact admin chrome. Defaults to the first three
|
|
51
|
+
* letters of the chapter name. */
|
|
52
|
+
badge?: string;
|
|
50
53
|
wordmark?: string;
|
|
51
54
|
tagline?: string;
|
|
52
55
|
/** Header/footer nav sections for the web-component chrome: label → entries. */
|
|
@@ -56,6 +59,40 @@ interface ChapterBrand {
|
|
|
56
59
|
}>>;
|
|
57
60
|
logos?: string;
|
|
58
61
|
}
|
|
62
|
+
/** One follower site a leader can push CRM records to. The secret VALUE never
|
|
63
|
+
* lives in config: `secretName` names the leader tenant's vaulted copy of the
|
|
64
|
+
* follower's `network_share_secret`. */
|
|
65
|
+
interface ChapterNetworkTarget {
|
|
66
|
+
/** Stable lowercase slug used by the UI, audit tags, and default secret name. */
|
|
67
|
+
id: string;
|
|
68
|
+
/** Human-facing site name. Defaults to `id`. */
|
|
69
|
+
name?: string;
|
|
70
|
+
/** Absolute follower origin, e.g. `https://silver.example.com`. */
|
|
71
|
+
url: string;
|
|
72
|
+
/** Leader-vault key holding this follower's share secret. Defaults to
|
|
73
|
+
* `network_share_<id-with-underscores>`. */
|
|
74
|
+
secretName?: string;
|
|
75
|
+
/** Per-record-type field allowlist. If present, omitted types cannot be sent
|
|
76
|
+
* to this target. Without it, package-safe person/company defaults apply. */
|
|
77
|
+
fields?: Record<string, readonly string[]>;
|
|
78
|
+
}
|
|
79
|
+
/** Leader/follower network configuration. Followers need no code config to
|
|
80
|
+
* receive records; they only vault `network_share_secret`. */
|
|
81
|
+
interface ChapterNetwork {
|
|
82
|
+
targets?: readonly ChapterNetworkTarget[];
|
|
83
|
+
}
|
|
84
|
+
/** A validated network target carried on the resolved chapter engine. */
|
|
85
|
+
interface ResolvedNetworkTarget {
|
|
86
|
+
id: string;
|
|
87
|
+
name: string;
|
|
88
|
+
url: string;
|
|
89
|
+
secretName: string;
|
|
90
|
+
fields?: Record<string, readonly string[]>;
|
|
91
|
+
}
|
|
92
|
+
/** Fully-resolved leader network configuration. */
|
|
93
|
+
interface ResolvedNetwork {
|
|
94
|
+
targets: readonly ResolvedNetworkTarget[];
|
|
95
|
+
}
|
|
59
96
|
/** Membership pricing for the group row (chapter mode). */
|
|
60
97
|
interface ChapterPrices {
|
|
61
98
|
standardCents: number;
|
|
@@ -211,6 +248,9 @@ interface ChapterConfig {
|
|
|
211
248
|
/** A `defineCrm()` config or a resolved `Crm`. Omit for the per-mode default. */
|
|
212
249
|
crm?: CrmConfig | Crm;
|
|
213
250
|
brand?: ChapterBrand;
|
|
251
|
+
/** Leader → follower delivery targets. Secret values stay in the leader
|
|
252
|
+
* tenant vault; see {@link ChapterNetworkTarget}. */
|
|
253
|
+
network?: ChapterNetwork;
|
|
214
254
|
thesis?: unknown;
|
|
215
255
|
/** Required in `chapter` mode. */
|
|
216
256
|
prices?: ChapterPrices;
|
|
@@ -297,6 +337,11 @@ interface Chapter {
|
|
|
297
337
|
name: string;
|
|
298
338
|
url?: string;
|
|
299
339
|
mode: ChapterMode;
|
|
340
|
+
/** Resolved site identity. `wordmark` always falls back to `name`, so the
|
|
341
|
+
* admin/member UI never needs a second brand declaration. */
|
|
342
|
+
brand: ChapterBrand;
|
|
343
|
+
/** Validated follower targets for leader-driven record pushes. */
|
|
344
|
+
network: ResolvedNetwork;
|
|
300
345
|
/** Resolved CRM engine (from `defineCrm`). */
|
|
301
346
|
crm: Crm;
|
|
302
347
|
/** Resolved auth policy (source, claim, ladder, super-admin tier). */
|
package/dist/worker/index.d.ts
CHANGED
|
@@ -47,6 +47,9 @@ interface ChapterBrand {
|
|
|
47
47
|
body?: string;
|
|
48
48
|
numeral?: string;
|
|
49
49
|
};
|
|
50
|
+
/** Short glyph used in compact admin chrome. Defaults to the first three
|
|
51
|
+
* letters of the chapter name. */
|
|
52
|
+
badge?: string;
|
|
50
53
|
wordmark?: string;
|
|
51
54
|
tagline?: string;
|
|
52
55
|
/** Header/footer nav sections for the web-component chrome: label → entries. */
|
|
@@ -56,6 +59,40 @@ interface ChapterBrand {
|
|
|
56
59
|
}>>;
|
|
57
60
|
logos?: string;
|
|
58
61
|
}
|
|
62
|
+
/** One follower site a leader can push CRM records to. The secret VALUE never
|
|
63
|
+
* lives in config: `secretName` names the leader tenant's vaulted copy of the
|
|
64
|
+
* follower's `network_share_secret`. */
|
|
65
|
+
interface ChapterNetworkTarget {
|
|
66
|
+
/** Stable lowercase slug used by the UI, audit tags, and default secret name. */
|
|
67
|
+
id: string;
|
|
68
|
+
/** Human-facing site name. Defaults to `id`. */
|
|
69
|
+
name?: string;
|
|
70
|
+
/** Absolute follower origin, e.g. `https://silver.example.com`. */
|
|
71
|
+
url: string;
|
|
72
|
+
/** Leader-vault key holding this follower's share secret. Defaults to
|
|
73
|
+
* `network_share_<id-with-underscores>`. */
|
|
74
|
+
secretName?: string;
|
|
75
|
+
/** Per-record-type field allowlist. If present, omitted types cannot be sent
|
|
76
|
+
* to this target. Without it, package-safe person/company defaults apply. */
|
|
77
|
+
fields?: Record<string, readonly string[]>;
|
|
78
|
+
}
|
|
79
|
+
/** Leader/follower network configuration. Followers need no code config to
|
|
80
|
+
* receive records; they only vault `network_share_secret`. */
|
|
81
|
+
interface ChapterNetwork {
|
|
82
|
+
targets?: readonly ChapterNetworkTarget[];
|
|
83
|
+
}
|
|
84
|
+
/** A validated network target carried on the resolved chapter engine. */
|
|
85
|
+
interface ResolvedNetworkTarget {
|
|
86
|
+
id: string;
|
|
87
|
+
name: string;
|
|
88
|
+
url: string;
|
|
89
|
+
secretName: string;
|
|
90
|
+
fields?: Record<string, readonly string[]>;
|
|
91
|
+
}
|
|
92
|
+
/** Fully-resolved leader network configuration. */
|
|
93
|
+
interface ResolvedNetwork {
|
|
94
|
+
targets: readonly ResolvedNetworkTarget[];
|
|
95
|
+
}
|
|
59
96
|
/** Membership pricing for the group row (chapter mode). */
|
|
60
97
|
interface ChapterPrices {
|
|
61
98
|
standardCents: number;
|
|
@@ -211,6 +248,9 @@ interface ChapterConfig {
|
|
|
211
248
|
/** A `defineCrm()` config or a resolved `Crm`. Omit for the per-mode default. */
|
|
212
249
|
crm?: CrmConfig | Crm;
|
|
213
250
|
brand?: ChapterBrand;
|
|
251
|
+
/** Leader → follower delivery targets. Secret values stay in the leader
|
|
252
|
+
* tenant vault; see {@link ChapterNetworkTarget}. */
|
|
253
|
+
network?: ChapterNetwork;
|
|
214
254
|
thesis?: unknown;
|
|
215
255
|
/** Required in `chapter` mode. */
|
|
216
256
|
prices?: ChapterPrices;
|
|
@@ -297,6 +337,11 @@ interface Chapter {
|
|
|
297
337
|
name: string;
|
|
298
338
|
url?: string;
|
|
299
339
|
mode: ChapterMode;
|
|
340
|
+
/** Resolved site identity. `wordmark` always falls back to `name`, so the
|
|
341
|
+
* admin/member UI never needs a second brand declaration. */
|
|
342
|
+
brand: ChapterBrand;
|
|
343
|
+
/** Validated follower targets for leader-driven record pushes. */
|
|
344
|
+
network: ResolvedNetwork;
|
|
300
345
|
/** Resolved CRM engine (from `defineCrm`). */
|
|
301
346
|
crm: Crm;
|
|
302
347
|
/** Resolved auth policy (source, claim, ladder, super-admin tier). */
|
package/dist/worker/index.js
CHANGED
|
@@ -247,6 +247,10 @@ function joinConfig(group, paymentsReady) {
|
|
|
247
247
|
|
|
248
248
|
// src/network.ts
|
|
249
249
|
import { createRecord, updateRecord } from "@odla-ai/crm";
|
|
250
|
+
var DEFAULT_SHARE_FIELDS = {
|
|
251
|
+
person: ["name", "email", "firstName", "lastName", "phone", "linkedin"],
|
|
252
|
+
company: ["name", "domain", "industry", "location", "linkedin", "notes"]
|
|
253
|
+
};
|
|
250
254
|
function sharedPersonInput(person) {
|
|
251
255
|
const email = person.email.toLowerCase();
|
|
252
256
|
const fullName = [person.firstName, person.lastName].filter(Boolean).join(" ").trim();
|
|
@@ -258,6 +262,52 @@ function sharedPersonInput(person) {
|
|
|
258
262
|
if (person.linkedin) input.linkedin = person.linkedin;
|
|
259
263
|
return input;
|
|
260
264
|
}
|
|
265
|
+
function shortHash(value) {
|
|
266
|
+
let a = 2166136261;
|
|
267
|
+
let b = 2654435769;
|
|
268
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
269
|
+
const n = value.charCodeAt(i);
|
|
270
|
+
a = Math.imul(a ^ n, 16777619);
|
|
271
|
+
b = Math.imul(b ^ n, 2246822507);
|
|
272
|
+
}
|
|
273
|
+
return `${(a >>> 0).toString(36)}${(b >>> 0).toString(36)}`;
|
|
274
|
+
}
|
|
275
|
+
function networkSourceTag(type, hubRecordId) {
|
|
276
|
+
const typeKey = type.toLowerCase();
|
|
277
|
+
const readable = /^[a-z0-9_-]+$/.test(hubRecordId);
|
|
278
|
+
const raw = `network:${typeKey}:${hubRecordId}`;
|
|
279
|
+
if (readable && raw.length <= 64) return raw;
|
|
280
|
+
return `network:${typeKey.slice(0, 20)}:${shortHash(`${type}\0${hubRecordId}`)}`;
|
|
281
|
+
}
|
|
282
|
+
function normalizeSharedRecord(record) {
|
|
283
|
+
if ("input" in record) return { version: 1, type: record.type, hubRecordId: record.hubRecordId, input: record.input };
|
|
284
|
+
if ("type" in record && record.type === "company") {
|
|
285
|
+
const input = { name: record.name };
|
|
286
|
+
for (const key of ["domain", "industry", "location", "linkedin", "notes"]) {
|
|
287
|
+
if (record[key]) input[key] = record[key];
|
|
288
|
+
}
|
|
289
|
+
return { version: 1, type: "company", hubRecordId: record.hubRecordId, input };
|
|
290
|
+
}
|
|
291
|
+
return { version: 1, type: "person", hubRecordId: record.hubRecordId, input: sharedPersonInput(record) };
|
|
292
|
+
}
|
|
293
|
+
function sharedRecordFromCrm(crm, record, target) {
|
|
294
|
+
if (target.fields && !target.fields[record.type]) {
|
|
295
|
+
throw new Error(`${target.name} does not accept "${record.type}" records`);
|
|
296
|
+
}
|
|
297
|
+
const def = crm.type(record.type);
|
|
298
|
+
const fields = target.fields?.[record.type] ?? DEFAULT_SHARE_FIELDS[record.type];
|
|
299
|
+
if (!fields) {
|
|
300
|
+
throw new Error(`${target.name} requires an explicit field allowlist for "${record.type}" records`);
|
|
301
|
+
}
|
|
302
|
+
const nameField = def.nameField ?? "name";
|
|
303
|
+
const input = {};
|
|
304
|
+
for (const field of /* @__PURE__ */ new Set([nameField, ...fields])) {
|
|
305
|
+
const value = record.fields?.[field];
|
|
306
|
+
if (value !== void 0) input[field] = value;
|
|
307
|
+
}
|
|
308
|
+
if (input[nameField] === void 0) input[nameField] = record.name;
|
|
309
|
+
return { version: 1, type: record.type, hubRecordId: record.id, input };
|
|
310
|
+
}
|
|
261
311
|
async function upsertPerson(deps, opts) {
|
|
262
312
|
const email = opts.email.toLowerCase();
|
|
263
313
|
const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
|
|
@@ -270,8 +320,65 @@ async function upsertPerson(deps, opts) {
|
|
|
270
320
|
const created = await createRecord(crmDeps3, { type: "person", input: opts.input, mutationId: opts.mutationId });
|
|
271
321
|
return { recordId: created.id };
|
|
272
322
|
}
|
|
273
|
-
async function
|
|
274
|
-
|
|
323
|
+
async function findSharedRecord(deps, record, tag) {
|
|
324
|
+
const mapped = await deps.db.query({ crm_tag: { $: { where: { tag }, limit: 1 } } });
|
|
325
|
+
const mappedId = mapped.crm_tag?.[0]?.recordId;
|
|
326
|
+
if (typeof mappedId === "string") {
|
|
327
|
+
const found = await deps.db.query({ crm_record: { $: { where: { id: mappedId }, limit: 1 } } });
|
|
328
|
+
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
329
|
+
}
|
|
330
|
+
const def = deps.crm.type(record.type);
|
|
331
|
+
const emailField = def.emailField;
|
|
332
|
+
if (emailField && typeof record.input[emailField] === "string") {
|
|
333
|
+
const primaryEmail = record.input[emailField].toLowerCase();
|
|
334
|
+
const found = await deps.db.query({
|
|
335
|
+
crm_record: { $: { where: { type: record.type, primaryEmail }, limit: 1 } }
|
|
336
|
+
});
|
|
337
|
+
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
338
|
+
}
|
|
339
|
+
const domain = record.input.domain;
|
|
340
|
+
const domainSlot = def.fields.domain?.slot;
|
|
341
|
+
if (typeof domain === "string" && domainSlot) {
|
|
342
|
+
const found = await deps.db.query({
|
|
343
|
+
crm_record: { $: { where: { type: record.type, [domainSlot]: domain }, limit: 1 } }
|
|
344
|
+
});
|
|
345
|
+
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
346
|
+
}
|
|
347
|
+
const nameField = def.nameField ?? "name";
|
|
348
|
+
const name = record.input[nameField];
|
|
349
|
+
if (record.type === "company" && typeof name === "string" && name.trim()) {
|
|
350
|
+
const found = await deps.db.query({
|
|
351
|
+
crm_record: { $: { where: { type: record.type, name: name.trim() }, limit: 1 } }
|
|
352
|
+
});
|
|
353
|
+
if (found.crm_record?.[0]) return found.crm_record[0];
|
|
354
|
+
}
|
|
355
|
+
return void 0;
|
|
356
|
+
}
|
|
357
|
+
async function projectSharedRecord(deps, shared) {
|
|
358
|
+
const record = normalizeSharedRecord(shared);
|
|
359
|
+
if (!record.type.trim() || !record.hubRecordId.trim()) {
|
|
360
|
+
throw new Error("type and hubRecordId must be non-empty");
|
|
361
|
+
}
|
|
362
|
+
const tag = networkSourceTag(record.type, record.hubRecordId);
|
|
363
|
+
const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
|
|
364
|
+
const existing = await findSharedRecord(deps, record, tag);
|
|
365
|
+
let recordId;
|
|
366
|
+
if (existing && typeof existing.id === "string") {
|
|
367
|
+
await updateRecord(crmDeps3, { id: existing.id, input: record.input });
|
|
368
|
+
recordId = existing.id;
|
|
369
|
+
} else {
|
|
370
|
+
recordId = `network_${shortHash(`${record.type}\0${record.hubRecordId}`)}`;
|
|
371
|
+
await createRecord({ ...crmDeps3, newId: () => recordId }, {
|
|
372
|
+
type: record.type,
|
|
373
|
+
input: record.input,
|
|
374
|
+
mutationId: `share-create:${tag}`
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
await deps.db.transact(
|
|
378
|
+
[{ t: "update", ns: "crm_tag", id: tag, attrs: { key: `${recordId}:${tag}`, recordId, tag, createdAt: deps.now() } }],
|
|
379
|
+
{ mutationId: `share-map:${tag}:${recordId}` }
|
|
380
|
+
);
|
|
381
|
+
return { recordId };
|
|
275
382
|
}
|
|
276
383
|
async function projectApplicant(deps, applicant) {
|
|
277
384
|
const base = sharedPersonInput({
|
|
@@ -649,20 +756,35 @@ var handleNetworkShared = async (req, url, env, ctx) => {
|
|
|
649
756
|
if (!secret || provided.length !== secret.length || provided !== secret) {
|
|
650
757
|
return json({ error: "unauthorized" }, 401);
|
|
651
758
|
}
|
|
652
|
-
let
|
|
759
|
+
let payload;
|
|
653
760
|
try {
|
|
654
|
-
|
|
761
|
+
payload = JSON.parse(await req.text());
|
|
655
762
|
} catch {
|
|
656
763
|
return json({ error: "invalid JSON body" }, 400);
|
|
657
764
|
}
|
|
658
|
-
if (typeof
|
|
659
|
-
return json({ error: "
|
|
765
|
+
if (typeof payload.hubRecordId !== "string" || !payload.hubRecordId.trim()) {
|
|
766
|
+
return json({ error: "hubRecordId is required" }, 400);
|
|
767
|
+
}
|
|
768
|
+
if ("input" in payload) {
|
|
769
|
+
if (payload.version !== 1 || typeof payload.type !== "string" || !payload.type.trim() || !payload.input || typeof payload.input !== "object" || Array.isArray(payload.input)) {
|
|
770
|
+
return json({ error: "version 1, type, and input are required" }, 400);
|
|
771
|
+
}
|
|
772
|
+
} else if (payload.type !== "company" && typeof payload.email !== "string") {
|
|
773
|
+
return json({ error: "legacy person shares require email" }, 400);
|
|
774
|
+
} else if (payload.type === "company" && typeof payload.name !== "string") {
|
|
775
|
+
return json({ error: "business shares require name" }, 400);
|
|
776
|
+
}
|
|
777
|
+
try {
|
|
778
|
+
const record = normalizeSharedRecord(payload);
|
|
779
|
+
const { recordId } = await projectSharedRecord(
|
|
780
|
+
{ crm: ctx.chapter.crm, db, now: () => Date.now(), newId: () => crypto.randomUUID() },
|
|
781
|
+
record
|
|
782
|
+
);
|
|
783
|
+
return json({ recordId, type: record.type });
|
|
784
|
+
} catch (err) {
|
|
785
|
+
const message = err instanceof Error ? err.message : "invalid shared record";
|
|
786
|
+
return json({ error: message }, 400);
|
|
660
787
|
}
|
|
661
|
-
const { recordId } = await projectSharedRecord(
|
|
662
|
-
{ crm: ctx.chapter.crm, db, now: () => Date.now(), newId: () => crypto.randomUUID() },
|
|
663
|
-
person
|
|
664
|
-
);
|
|
665
|
-
return json({ recordId });
|
|
666
788
|
};
|
|
667
789
|
var handleMember = async (req, url, env, ctx) => {
|
|
668
790
|
const chapter = ctx.chapter;
|
|
@@ -1495,16 +1617,16 @@ var crmDeps = (db, ctx) => ({
|
|
|
1495
1617
|
});
|
|
1496
1618
|
var handleAdminCrmSync = async (req, url, env, ctx) => {
|
|
1497
1619
|
if (req.method !== "POST" || url.pathname !== "/api/admin/crm/sync") return null;
|
|
1498
|
-
const
|
|
1499
|
-
if (
|
|
1500
|
-
const result = await backfillCrm(crmDeps(
|
|
1620
|
+
const gate5 = await adminGate(req, env, ctx);
|
|
1621
|
+
if (gate5 instanceof Response) return gate5;
|
|
1622
|
+
const result = await backfillCrm(crmDeps(gate5.db, ctx));
|
|
1501
1623
|
return json({ ok: true, ...result });
|
|
1502
1624
|
};
|
|
1503
1625
|
var handleAdminPeople = async (req, url, env, ctx) => {
|
|
1504
1626
|
if (req.method !== "GET" || url.pathname !== "/api/admin/people") return null;
|
|
1505
|
-
const
|
|
1506
|
-
if (
|
|
1507
|
-
const { db } =
|
|
1627
|
+
const gate5 = await adminGate(req, env, ctx);
|
|
1628
|
+
if (gate5 instanceof Response) return gate5;
|
|
1629
|
+
const { db } = gate5;
|
|
1508
1630
|
const sk = await getVaultSecret(db, "clerk_secret_key");
|
|
1509
1631
|
const [appsRes, usersRes, roleList] = await Promise.all([
|
|
1510
1632
|
db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 200 } } }),
|
|
@@ -1544,9 +1666,9 @@ var handleAdminPeople = async (req, url, env, ctx) => {
|
|
|
1544
1666
|
};
|
|
1545
1667
|
var handleAdminPeopleAccess = async (req, url, env, ctx) => {
|
|
1546
1668
|
if (req.method !== "GET" || url.pathname !== "/api/admin/people/access") return null;
|
|
1547
|
-
const
|
|
1548
|
-
if (
|
|
1549
|
-
const { db } =
|
|
1669
|
+
const gate5 = await adminGate(req, env, ctx);
|
|
1670
|
+
if (gate5 instanceof Response) return gate5;
|
|
1671
|
+
const { db } = gate5;
|
|
1550
1672
|
const targetId = url.searchParams.get("userId") ?? "";
|
|
1551
1673
|
if (!targetId.startsWith("user_")) return json({ error: "invalid userId" }, 400);
|
|
1552
1674
|
const sk = await getVaultSecret(db, "clerk_secret_key");
|
|
@@ -1557,9 +1679,9 @@ var handleAdminPeopleAccess = async (req, url, env, ctx) => {
|
|
|
1557
1679
|
};
|
|
1558
1680
|
var handleAdminPeopleRole = async (req, url, env, ctx) => {
|
|
1559
1681
|
if (req.method !== "POST" || url.pathname !== "/api/admin/people/role") return null;
|
|
1560
|
-
const
|
|
1561
|
-
if (
|
|
1562
|
-
const { db, actor } =
|
|
1682
|
+
const gate5 = await adminGate(req, env, ctx);
|
|
1683
|
+
if (gate5 instanceof Response) return gate5;
|
|
1684
|
+
const { db, actor } = gate5;
|
|
1563
1685
|
let body;
|
|
1564
1686
|
try {
|
|
1565
1687
|
body = await req.json();
|
|
@@ -2098,6 +2220,82 @@ var handleAdminComms = async (req, url, env, ctx) => {
|
|
|
2098
2220
|
return json({ items });
|
|
2099
2221
|
};
|
|
2100
2222
|
|
|
2223
|
+
// src/worker-routes-network.ts
|
|
2224
|
+
import { addTag, getRecord } from "@odla-ai/crm";
|
|
2225
|
+
async function gate4(req, env, ctx) {
|
|
2226
|
+
const db = ctx.makeDb(env);
|
|
2227
|
+
const user = await ctx.verifyUser(req, env);
|
|
2228
|
+
if (!user) return { response: json({ error: "unauthorized" }, 401) };
|
|
2229
|
+
if (!await ctx.isAdmin(db, user)) return { response: json({ error: "forbidden" }, 403) };
|
|
2230
|
+
return { db, user };
|
|
2231
|
+
}
|
|
2232
|
+
var handleAdminNetworkTargets = async (req, url, env, ctx) => {
|
|
2233
|
+
if (req.method !== "GET" || url.pathname !== "/api/admin/network/targets") return null;
|
|
2234
|
+
const got = await gate4(req, env, ctx);
|
|
2235
|
+
if ("response" in got) return got.response;
|
|
2236
|
+
return json({
|
|
2237
|
+
targets: ctx.chapter.network.targets.map(({ id, name, url: targetUrl, fields }) => ({
|
|
2238
|
+
id,
|
|
2239
|
+
name,
|
|
2240
|
+
url: targetUrl,
|
|
2241
|
+
types: Object.keys(fields ?? DEFAULT_SHARE_FIELDS)
|
|
2242
|
+
}))
|
|
2243
|
+
});
|
|
2244
|
+
};
|
|
2245
|
+
async function pushOne(db, ctx, target, record) {
|
|
2246
|
+
const secret = await getVaultSecret(db, target.secretName);
|
|
2247
|
+
if (!secret) return { id: target.id, name: target.name, ok: false, error: `vault secret "${target.secretName}" is missing` };
|
|
2248
|
+
let payload;
|
|
2249
|
+
try {
|
|
2250
|
+
payload = sharedRecordFromCrm(ctx.chapter.crm, record, target);
|
|
2251
|
+
} catch (err) {
|
|
2252
|
+
return { id: target.id, name: target.name, ok: false, error: err instanceof Error ? err.message : "record is not shareable" };
|
|
2253
|
+
}
|
|
2254
|
+
try {
|
|
2255
|
+
const res = await fetch(new URL("/api/network/shared", target.url), {
|
|
2256
|
+
method: "POST",
|
|
2257
|
+
headers: { authorization: `Bearer ${secret}`, "content-type": "application/json" },
|
|
2258
|
+
body: JSON.stringify(payload),
|
|
2259
|
+
signal: AbortSignal.timeout(1e4)
|
|
2260
|
+
});
|
|
2261
|
+
const body = await res.json().catch(() => ({}));
|
|
2262
|
+
if (!res.ok) {
|
|
2263
|
+
return { id: target.id, name: target.name, ok: false, status: res.status, error: body.error ?? "follower rejected the record" };
|
|
2264
|
+
}
|
|
2265
|
+
await addTag(
|
|
2266
|
+
{ crm: ctx.chapter.crm, db },
|
|
2267
|
+
{ recordId: record.id, tag: `shared:${target.id}`, mutationId: `network-delivered:${record.id}:${target.id}` }
|
|
2268
|
+
);
|
|
2269
|
+
return { id: target.id, name: target.name, ok: true, status: res.status, recordId: body.recordId };
|
|
2270
|
+
} catch (err) {
|
|
2271
|
+
return { id: target.id, name: target.name, ok: false, error: err instanceof Error ? err.message : "delivery failed" };
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
var handleAdminNetworkPush = async (req, url, env, ctx) => {
|
|
2275
|
+
if (req.method !== "POST" || url.pathname !== "/api/admin/network/push") return null;
|
|
2276
|
+
const got = await gate4(req, env, ctx);
|
|
2277
|
+
if ("response" in got) return got.response;
|
|
2278
|
+
let body;
|
|
2279
|
+
try {
|
|
2280
|
+
body = await req.json();
|
|
2281
|
+
} catch {
|
|
2282
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
2283
|
+
}
|
|
2284
|
+
if (typeof body.recordId !== "string" || !Array.isArray(body.targetIds) || body.targetIds.length === 0) {
|
|
2285
|
+
return json({ error: "recordId and a non-empty targetIds array are required" }, 400);
|
|
2286
|
+
}
|
|
2287
|
+
const requested = new Set(body.targetIds.filter((id) => typeof id === "string"));
|
|
2288
|
+
if (requested.size !== body.targetIds.length) return json({ error: "targetIds must contain unique strings" }, 400);
|
|
2289
|
+
const targets = ctx.chapter.network.targets.filter((target) => requested.has(target.id));
|
|
2290
|
+
if (targets.length !== requested.size) return json({ error: "one or more targetIds are not configured" }, 400);
|
|
2291
|
+
const record = await getRecord({ crm: ctx.chapter.crm, db: got.db }, body.recordId);
|
|
2292
|
+
if (!record) return json({ error: "record not found" }, 404);
|
|
2293
|
+
const results = await Promise.all(
|
|
2294
|
+
targets.map((target) => pushOne(got.db, ctx, target, record))
|
|
2295
|
+
);
|
|
2296
|
+
return json({ ok: results.every((result) => result.ok), results });
|
|
2297
|
+
};
|
|
2298
|
+
|
|
2101
2299
|
// src/worker.ts
|
|
2102
2300
|
var BUILTIN_ROUTES = [
|
|
2103
2301
|
handleHealth,
|
|
@@ -2128,7 +2326,10 @@ var BUILTIN_ROUTES = [
|
|
|
2128
2326
|
handleAdminGroupEmail,
|
|
2129
2327
|
handleAdminEmailLog,
|
|
2130
2328
|
handleAdminEmailTest,
|
|
2131
|
-
handleAdminComms
|
|
2329
|
+
handleAdminComms,
|
|
2330
|
+
// Leader → follower record delivery
|
|
2331
|
+
handleAdminNetworkTargets,
|
|
2332
|
+
handleAdminNetworkPush
|
|
2132
2333
|
];
|
|
2133
2334
|
function chapterWorker(options) {
|
|
2134
2335
|
const ctx = createWorkerContext(options);
|