@odla-ai/chapter 0.42.0 → 0.42.1
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 +20 -4
- package/dist/{copy-context-DuJkH4U9.d.ts → copy-context-BNUzbL5g.d.ts} +18 -0
- package/dist/index.cjs +60 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +65 -1
- package/dist/index.d.ts +65 -1
- package/dist/index.js +60 -10
- package/dist/index.js.map +1 -1
- package/dist/ui/admin/index.d.ts +2 -2
- package/dist/ui/index.d.ts +1 -1
- package/dist/ui/member/index.d.ts +2 -2
- package/dist/worker/index.cjs +299 -50
- package/dist/worker/index.cjs.map +1 -1
- package/dist/worker/index.d.cts +18 -0
- package/dist/worker/index.d.ts +18 -0
- package/dist/worker/index.js +292 -43
- package/dist/worker/index.js.map +1 -1
- package/package.json +2 -2
- package/runbooks/greenfield.md +3 -0
package/dist/worker/index.d.cts
CHANGED
|
@@ -185,6 +185,13 @@ interface ChapterNetworkTarget {
|
|
|
185
185
|
* this follower. Omitted by default: a target never gains write permission
|
|
186
186
|
* merely because its records are browsable. */
|
|
187
187
|
sharedNotes?: readonly string[];
|
|
188
|
+
/** Per-record-type upper bound on profile fields this leader permits its
|
|
189
|
+
* admins to edit remotely. The follower must independently delegate the
|
|
190
|
+
* same field; readable fields never become writable implicitly. */
|
|
191
|
+
editableFields?: Record<string, readonly string[]>;
|
|
192
|
+
/** Record types on which this leader permits remote lifecycle transitions.
|
|
193
|
+
* The follower must independently opt in and remains pipeline authority. */
|
|
194
|
+
stageTransitions?: readonly string[];
|
|
188
195
|
}
|
|
189
196
|
/** One signed peer allowed to browse a privacy-bounded projection of this
|
|
190
197
|
* site's CRM. The follower owns this allowlist; a leader target declaration
|
|
@@ -199,6 +206,13 @@ interface ChapterNetworkReader {
|
|
|
199
206
|
* Omitted by default: browse permission never silently grants write access.
|
|
200
207
|
* Shared notes land in the follower CRM activity feed. */
|
|
201
208
|
sharedNotes?: readonly string[];
|
|
209
|
+
/** Exact local CRM fields this reader may edit remotely, by record type.
|
|
210
|
+
* Every field must also be readable so the peer can perform an optimistic
|
|
211
|
+
* edit against the value and revision it actually reviewed. */
|
|
212
|
+
editableFields?: Record<string, readonly string[]>;
|
|
213
|
+
/** Local CRM record types on which this reader may request a lifecycle
|
|
214
|
+
* transition. Chapter still enforces the local pipeline and gates. */
|
|
215
|
+
stageTransitions?: readonly string[];
|
|
202
216
|
}
|
|
203
217
|
/** Leader/follower network configuration. A follower needs no code config to
|
|
204
218
|
* receive pushed records. It must explicitly declare `readers` before any peer
|
|
@@ -241,12 +255,16 @@ interface ResolvedNetworkTarget {
|
|
|
241
255
|
secretName: string;
|
|
242
256
|
fields?: Record<string, readonly string[]>;
|
|
243
257
|
sharedNotes: readonly string[];
|
|
258
|
+
editableFields: Record<string, readonly string[]>;
|
|
259
|
+
stageTransitions: readonly string[];
|
|
244
260
|
}
|
|
245
261
|
/** A validated follower-owned record projection for one signed reader. */
|
|
246
262
|
interface ResolvedNetworkReader {
|
|
247
263
|
id: string;
|
|
248
264
|
fields: Record<string, readonly string[]>;
|
|
249
265
|
sharedNotes: readonly string[];
|
|
266
|
+
editableFields: Record<string, readonly string[]>;
|
|
267
|
+
stageTransitions: readonly string[];
|
|
250
268
|
}
|
|
251
269
|
/** Fully-resolved leader network configuration. */
|
|
252
270
|
interface ResolvedNetwork {
|
package/dist/worker/index.d.ts
CHANGED
|
@@ -185,6 +185,13 @@ interface ChapterNetworkTarget {
|
|
|
185
185
|
* this follower. Omitted by default: a target never gains write permission
|
|
186
186
|
* merely because its records are browsable. */
|
|
187
187
|
sharedNotes?: readonly string[];
|
|
188
|
+
/** Per-record-type upper bound on profile fields this leader permits its
|
|
189
|
+
* admins to edit remotely. The follower must independently delegate the
|
|
190
|
+
* same field; readable fields never become writable implicitly. */
|
|
191
|
+
editableFields?: Record<string, readonly string[]>;
|
|
192
|
+
/** Record types on which this leader permits remote lifecycle transitions.
|
|
193
|
+
* The follower must independently opt in and remains pipeline authority. */
|
|
194
|
+
stageTransitions?: readonly string[];
|
|
188
195
|
}
|
|
189
196
|
/** One signed peer allowed to browse a privacy-bounded projection of this
|
|
190
197
|
* site's CRM. The follower owns this allowlist; a leader target declaration
|
|
@@ -199,6 +206,13 @@ interface ChapterNetworkReader {
|
|
|
199
206
|
* Omitted by default: browse permission never silently grants write access.
|
|
200
207
|
* Shared notes land in the follower CRM activity feed. */
|
|
201
208
|
sharedNotes?: readonly string[];
|
|
209
|
+
/** Exact local CRM fields this reader may edit remotely, by record type.
|
|
210
|
+
* Every field must also be readable so the peer can perform an optimistic
|
|
211
|
+
* edit against the value and revision it actually reviewed. */
|
|
212
|
+
editableFields?: Record<string, readonly string[]>;
|
|
213
|
+
/** Local CRM record types on which this reader may request a lifecycle
|
|
214
|
+
* transition. Chapter still enforces the local pipeline and gates. */
|
|
215
|
+
stageTransitions?: readonly string[];
|
|
202
216
|
}
|
|
203
217
|
/** Leader/follower network configuration. A follower needs no code config to
|
|
204
218
|
* receive pushed records. It must explicitly declare `readers` before any peer
|
|
@@ -241,12 +255,16 @@ interface ResolvedNetworkTarget {
|
|
|
241
255
|
secretName: string;
|
|
242
256
|
fields?: Record<string, readonly string[]>;
|
|
243
257
|
sharedNotes: readonly string[];
|
|
258
|
+
editableFields: Record<string, readonly string[]>;
|
|
259
|
+
stageTransitions: readonly string[];
|
|
244
260
|
}
|
|
245
261
|
/** A validated follower-owned record projection for one signed reader. */
|
|
246
262
|
interface ResolvedNetworkReader {
|
|
247
263
|
id: string;
|
|
248
264
|
fields: Record<string, readonly string[]>;
|
|
249
265
|
sharedNotes: readonly string[];
|
|
266
|
+
editableFields: Record<string, readonly string[]>;
|
|
267
|
+
stageTransitions: readonly string[];
|
|
250
268
|
}
|
|
251
269
|
/** Fully-resolved leader network configuration. */
|
|
252
270
|
interface ResolvedNetwork {
|
package/dist/worker/index.js
CHANGED
|
@@ -5348,12 +5348,22 @@ var handleAdminNetworkTargets = async (req, url, env, ctx) => {
|
|
|
5348
5348
|
const got = await gate5(req, env, ctx);
|
|
5349
5349
|
if ("response" in got) return got.response;
|
|
5350
5350
|
return json({
|
|
5351
|
-
targets: ctx.chapter.network.targets.map(({
|
|
5351
|
+
targets: ctx.chapter.network.targets.map(({
|
|
5352
|
+
id,
|
|
5353
|
+
name,
|
|
5354
|
+
url: targetUrl,
|
|
5355
|
+
fields,
|
|
5356
|
+
sharedNotes,
|
|
5357
|
+
editableFields,
|
|
5358
|
+
stageTransitions
|
|
5359
|
+
}) => ({
|
|
5352
5360
|
id,
|
|
5353
5361
|
name,
|
|
5354
5362
|
url: targetUrl,
|
|
5355
5363
|
types: Object.keys(fields ?? DEFAULT_SHARE_FIELDS),
|
|
5356
|
-
sharedNotes
|
|
5364
|
+
sharedNotes,
|
|
5365
|
+
editableFields,
|
|
5366
|
+
stageTransitions
|
|
5357
5367
|
}))
|
|
5358
5368
|
});
|
|
5359
5369
|
};
|
|
@@ -5471,19 +5481,20 @@ var handleAdminNetworkPush = async (req, url, env, ctx) => {
|
|
|
5471
5481
|
return json({ ok: results.every((result) => result.ok), results });
|
|
5472
5482
|
};
|
|
5473
5483
|
|
|
5474
|
-
// src/worker-routes-network-records.ts
|
|
5475
|
-
import { listRecords } from "@odla-ai/crm";
|
|
5476
|
-
import {
|
|
5484
|
+
// src/worker-routes-network-records-follower.ts
|
|
5485
|
+
import { CRM_NS, CrmInputError, getRecord as getRecord2, listRecords, setStage as setStage2, updateRecord as updateRecord3 } from "@odla-ai/crm";
|
|
5486
|
+
import { verifyFederatedRequest as verifyFederatedRequest6 } from "@odla-ai/db";
|
|
5487
|
+
|
|
5488
|
+
// src/worker-routes-network-records-common.ts
|
|
5477
5489
|
var IDENTIFIER = /^[a-z][a-zA-Z0-9_]*$/;
|
|
5478
5490
|
var MAX_PAGE = 50;
|
|
5479
5491
|
var MAX_OFFSET = 1e4;
|
|
5480
5492
|
var MAX_SEARCH = 200;
|
|
5481
|
-
|
|
5482
|
-
|
|
5483
|
-
|
|
5484
|
-
|
|
5485
|
-
|
|
5486
|
-
return { db, user };
|
|
5493
|
+
var MAX_BODY = 32768;
|
|
5494
|
+
var RECORD_ID = /^[a-zA-Z0-9][a-zA-Z0-9:_-]{0,191}$/;
|
|
5495
|
+
var MUTATION_ID = /^[a-zA-Z0-9][a-zA-Z0-9:._-]{7,127}$/;
|
|
5496
|
+
function bodyTooLarge(body) {
|
|
5497
|
+
return new TextEncoder().encode(body).byteLength > MAX_BODY;
|
|
5487
5498
|
}
|
|
5488
5499
|
function integer(value, fallback, min, max) {
|
|
5489
5500
|
if (value == null || value === "") return fallback;
|
|
@@ -5491,10 +5502,22 @@ function integer(value, fallback, min, max) {
|
|
|
5491
5502
|
const parsed = Number(value);
|
|
5492
5503
|
return Number.isSafeInteger(parsed) && parsed >= min && parsed <= max ? parsed : null;
|
|
5493
5504
|
}
|
|
5494
|
-
function projectRecord(record4, allowed2) {
|
|
5505
|
+
function projectRecord(record4, allowed2, editable, stagesEnabled, crm) {
|
|
5495
5506
|
const fields = Object.fromEntries(
|
|
5496
5507
|
allowed2.flatMap((name) => Object.prototype.hasOwnProperty.call(record4.fields, name) ? [[name, record4.fields[name]]] : [])
|
|
5497
5508
|
);
|
|
5509
|
+
const def = crm.type(record4.type);
|
|
5510
|
+
const editFields = editable.map((id) => {
|
|
5511
|
+
const field = def.fields[id];
|
|
5512
|
+
return {
|
|
5513
|
+
id,
|
|
5514
|
+
label: field.label ?? id,
|
|
5515
|
+
type: field.type,
|
|
5516
|
+
required: field.required ?? false,
|
|
5517
|
+
...field.options ? { options: field.options } : {}
|
|
5518
|
+
};
|
|
5519
|
+
});
|
|
5520
|
+
const allowedStages = stagesEnabled ? (def.pipeline?.stages ?? []).flatMap((stage) => crm.canTransition(record4.type, record4, stage.id) ? [] : [{ id: stage.id, label: stage.label ?? stage.id }]) : [];
|
|
5498
5521
|
return {
|
|
5499
5522
|
id: record4.id,
|
|
5500
5523
|
type: record4.type,
|
|
@@ -5502,11 +5525,66 @@ function projectRecord(record4, allowed2) {
|
|
|
5502
5525
|
...record4.stage ? { stage: record4.stage } : {},
|
|
5503
5526
|
fields,
|
|
5504
5527
|
createdAt: record4.createdAt,
|
|
5505
|
-
updatedAt: record4.updatedAt
|
|
5528
|
+
updatedAt: record4.updatedAt,
|
|
5529
|
+
revision: record4.updatedAt,
|
|
5530
|
+
...editFields.length || stagesEnabled ? { edit: { fields: editFields, allowedStages } } : {}
|
|
5506
5531
|
};
|
|
5507
5532
|
}
|
|
5533
|
+
function readerRecord(record4, reader, crm) {
|
|
5534
|
+
return projectRecord(
|
|
5535
|
+
record4,
|
|
5536
|
+
reader.fields[record4.type] ?? [],
|
|
5537
|
+
reader.editableFields[record4.type] ?? [],
|
|
5538
|
+
reader.stageTransitions.includes(record4.type),
|
|
5539
|
+
crm
|
|
5540
|
+
);
|
|
5541
|
+
}
|
|
5542
|
+
function restrictedRecord(record4, target) {
|
|
5543
|
+
if (!record4.edit) return record4;
|
|
5544
|
+
const fields = record4.edit.fields.filter((field) => target.editableFields[record4.type]?.includes(field.id));
|
|
5545
|
+
const allowedStages = target.stageTransitions.includes(record4.type) ? record4.edit.allowedStages : [];
|
|
5546
|
+
const edit = fields.length || target.stageTransitions.includes(record4.type) ? { fields, allowedStages } : void 0;
|
|
5547
|
+
return { ...record4, ...edit ? { edit } : { edit: void 0 } };
|
|
5548
|
+
}
|
|
5549
|
+
|
|
5550
|
+
// src/worker-routes-network-records-follower.ts
|
|
5551
|
+
var NETWORK_MUTATION_META = "chapterNetworkMutation";
|
|
5552
|
+
function mutationFingerprint(input) {
|
|
5553
|
+
const fields = input.fields ? Object.fromEntries(Object.entries(input.fields).sort(([a], [b]) => a.localeCompare(b))) : void 0;
|
|
5554
|
+
return JSON.stringify({
|
|
5555
|
+
recordId: input.recordId,
|
|
5556
|
+
type: input.type,
|
|
5557
|
+
expectedRevision: input.expectedRevision,
|
|
5558
|
+
expectedStage: input.expectedStage,
|
|
5559
|
+
mutationId: input.mutationId,
|
|
5560
|
+
fields,
|
|
5561
|
+
stage: input.stage,
|
|
5562
|
+
actor: input.actor
|
|
5563
|
+
});
|
|
5564
|
+
}
|
|
5565
|
+
function mutationError(error) {
|
|
5566
|
+
if (error?.code === "transact_guard_failed") {
|
|
5567
|
+
return json({ error: "record changed since it was reviewed", code: "stale_revision" }, 409);
|
|
5568
|
+
}
|
|
5569
|
+
if (error instanceof CrmInputError) {
|
|
5570
|
+
return json({ error: error.message, code: "invalid_transition_or_fields", ...error.fields ? { fields: error.fields } : {} }, 422);
|
|
5571
|
+
}
|
|
5572
|
+
return json({ error: "follower mutation failed", code: "mutation_failed" }, 500);
|
|
5573
|
+
}
|
|
5574
|
+
async function priorMutation(db, id) {
|
|
5575
|
+
const result = await db.query({
|
|
5576
|
+
[CRM_NS.activity]: { $: { where: { id }, limit: 1 } }
|
|
5577
|
+
});
|
|
5578
|
+
const row = (result[CRM_NS.activity] ?? [])[0];
|
|
5579
|
+
const meta = row?.meta;
|
|
5580
|
+
const receipt = meta?.[NETWORK_MUTATION_META];
|
|
5581
|
+
return typeof receipt?.fingerprint === "string" ? { fingerprint: receipt.fingerprint } : null;
|
|
5582
|
+
}
|
|
5508
5583
|
var handleNetworkRecords = async (req, url, env, ctx) => {
|
|
5509
|
-
if (req.method !== "GET"
|
|
5584
|
+
if (url.pathname !== "/api/network/records" || req.method !== "GET" && req.method !== "PATCH") return null;
|
|
5585
|
+
if (req.method === "PATCH" && Number(req.headers.get("content-length") ?? 0) > MAX_BODY) {
|
|
5586
|
+
return json({ error: "mutation body is too large", code: "body_too_large" }, 413);
|
|
5587
|
+
}
|
|
5510
5588
|
const db = ctx.makeDb(env);
|
|
5511
5589
|
const secret = await getVaultSecret(db, "network_share_secret");
|
|
5512
5590
|
const readers = ctx.chapter.network.readers;
|
|
@@ -5517,8 +5595,105 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
|
|
|
5517
5595
|
});
|
|
5518
5596
|
if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
|
|
5519
5597
|
const reader = readers.find((candidate) => candidate.id === verified.sender);
|
|
5598
|
+
if (!reader) return json({ error: "unauthorized" }, 401);
|
|
5599
|
+
if (req.method === "PATCH") {
|
|
5600
|
+
if (bodyTooLarge(verified.body)) {
|
|
5601
|
+
return json({ error: "mutation body is too large", code: "body_too_large" }, 413);
|
|
5602
|
+
}
|
|
5603
|
+
let input;
|
|
5604
|
+
try {
|
|
5605
|
+
input = JSON.parse(verified.body);
|
|
5606
|
+
} catch {
|
|
5607
|
+
return json({ error: "invalid JSON body", code: "invalid_json" }, 422);
|
|
5608
|
+
}
|
|
5609
|
+
const fields = input?.fields;
|
|
5610
|
+
const hasFields = Boolean(fields && typeof fields === "object" && !Array.isArray(fields));
|
|
5611
|
+
const hasStage = typeof input?.stage === "string";
|
|
5612
|
+
const actor = input?.actor;
|
|
5613
|
+
if (!input || !IDENTIFIER.test(input.type) || !RECORD_ID.test(input.recordId) || !Number.isSafeInteger(input.expectedRevision) || input.expectedRevision < 0 || !MUTATION_ID.test(input.mutationId) || !actor || typeof actor.id !== "string" || !RECORD_ID.test(actor.id) || hasFields === hasStage) {
|
|
5614
|
+
return json({ error: "invalid network mutation", code: "invalid_mutation" }, 422);
|
|
5615
|
+
}
|
|
5616
|
+
const editable = reader.editableFields[input.type];
|
|
5617
|
+
if (hasFields) {
|
|
5618
|
+
const names = Object.keys(fields);
|
|
5619
|
+
if (!editable || names.length === 0 || names.length > 32 || names.some((name) => !editable.includes(name))) {
|
|
5620
|
+
return json({ error: "profile fields are not delegated for this reader", code: "field_forbidden" }, 403);
|
|
5621
|
+
}
|
|
5622
|
+
} else if (!reader.stageTransitions.includes(input.type)) {
|
|
5623
|
+
return json({ error: "stage transitions are not delegated for this reader", code: "stage_forbidden" }, 403);
|
|
5624
|
+
}
|
|
5625
|
+
const activityId = `network-edit:${verified.sender}:${input.mutationId}`;
|
|
5626
|
+
const fingerprint = mutationFingerprint(input);
|
|
5627
|
+
const prior = await priorMutation(db, activityId);
|
|
5628
|
+
if (prior) {
|
|
5629
|
+
if (prior.fingerprint !== fingerprint) {
|
|
5630
|
+
return json({ error: "mutationId was already used for different input", code: "mutation_conflict" }, 409);
|
|
5631
|
+
}
|
|
5632
|
+
const replayed = await getRecord2({ crm: ctx.chapter.crm, db }, input.recordId);
|
|
5633
|
+
if (!replayed || replayed.type !== input.type) return json({ error: "record not found", code: "not_found" }, 404);
|
|
5634
|
+
const result = {
|
|
5635
|
+
version: 1,
|
|
5636
|
+
site: { id: ctx.chapter.id, name: ctx.chapter.name },
|
|
5637
|
+
record: readerRecord(replayed, reader, ctx.chapter.crm),
|
|
5638
|
+
duplicate: true
|
|
5639
|
+
};
|
|
5640
|
+
return json(result);
|
|
5641
|
+
}
|
|
5642
|
+
const record4 = await getRecord2({ crm: ctx.chapter.crm, db }, input.recordId);
|
|
5643
|
+
if (!record4 || record4.type !== input.type) return json({ error: "record not found", code: "not_found" }, 404);
|
|
5644
|
+
if (record4.updatedAt !== input.expectedRevision) {
|
|
5645
|
+
return json({ error: "record changed since it was reviewed", code: "stale_revision" }, 409);
|
|
5646
|
+
}
|
|
5647
|
+
if (hasStage && (input.expectedStage ?? null) !== (record4.stage ?? null)) {
|
|
5648
|
+
return json({ error: "stage changed since it was reviewed", code: "stale_stage" }, 409);
|
|
5649
|
+
}
|
|
5650
|
+
const actorEmail = typeof actor.email === "string" && actor.email.length <= 320 ? actor.email : void 0;
|
|
5651
|
+
const auditMeta = {
|
|
5652
|
+
[NETWORK_MUTATION_META]: {
|
|
5653
|
+
version: 1,
|
|
5654
|
+
sourceId: verified.sender,
|
|
5655
|
+
actorId: actor.id,
|
|
5656
|
+
...actorEmail ? { actorEmail } : {},
|
|
5657
|
+
mutationId: input.mutationId,
|
|
5658
|
+
fingerprint
|
|
5659
|
+
}
|
|
5660
|
+
};
|
|
5661
|
+
try {
|
|
5662
|
+
const deps = { crm: ctx.chapter.crm, db };
|
|
5663
|
+
const updated = hasFields ? await updateRecord3(deps, {
|
|
5664
|
+
id: record4.id,
|
|
5665
|
+
input: fields,
|
|
5666
|
+
mutationId: activityId,
|
|
5667
|
+
expectedUpdatedAt: input.expectedRevision,
|
|
5668
|
+
audit: {
|
|
5669
|
+
id: activityId,
|
|
5670
|
+
body: `Profile edited remotely by ${verified.sender}`,
|
|
5671
|
+
authorId: `network:${verified.sender}`,
|
|
5672
|
+
meta: auditMeta
|
|
5673
|
+
}
|
|
5674
|
+
}) : await setStage2(deps, {
|
|
5675
|
+
id: record4.id,
|
|
5676
|
+
to: input.stage,
|
|
5677
|
+
authorId: `network:${verified.sender}`,
|
|
5678
|
+
mutationId: activityId,
|
|
5679
|
+
expectedUpdatedAt: input.expectedRevision,
|
|
5680
|
+
expectedStage: input.expectedStage ?? null,
|
|
5681
|
+
activityId,
|
|
5682
|
+
activityMeta: auditMeta
|
|
5683
|
+
});
|
|
5684
|
+
const result = {
|
|
5685
|
+
version: 1,
|
|
5686
|
+
site: { id: ctx.chapter.id, name: ctx.chapter.name },
|
|
5687
|
+
record: readerRecord(updated.record, reader, ctx.chapter.crm),
|
|
5688
|
+
duplicate: updated.duplicate
|
|
5689
|
+
};
|
|
5690
|
+
return json(result);
|
|
5691
|
+
} catch (error) {
|
|
5692
|
+
return mutationError(error);
|
|
5693
|
+
}
|
|
5694
|
+
}
|
|
5520
5695
|
const type = url.searchParams.get("type") ?? "";
|
|
5521
|
-
const allowed2 = reader
|
|
5696
|
+
const allowed2 = reader.fields[type];
|
|
5522
5697
|
if (!IDENTIFIER.test(type) || !allowed2) return json({ error: "record type is not shared with this reader" }, 403);
|
|
5523
5698
|
const limit = integer(url.searchParams.get("limit"), 25, 1, MAX_PAGE);
|
|
5524
5699
|
const offset = integer(url.searchParams.get("offset"), 0, 0, MAX_OFFSET);
|
|
@@ -5540,13 +5715,23 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
|
|
|
5540
5715
|
version: 1,
|
|
5541
5716
|
site: { id: ctx.chapter.id, name: ctx.chapter.name },
|
|
5542
5717
|
type,
|
|
5543
|
-
records: page.records.map((record4) =>
|
|
5718
|
+
records: page.records.map((record4) => readerRecord(record4, reader, ctx.chapter.crm)),
|
|
5544
5719
|
total: page.total,
|
|
5545
5720
|
limit: page.limit,
|
|
5546
5721
|
offset: page.offset
|
|
5547
5722
|
};
|
|
5548
5723
|
return json(response);
|
|
5549
5724
|
};
|
|
5725
|
+
|
|
5726
|
+
// src/worker-routes-network-records-admin.ts
|
|
5727
|
+
import { signFederatedRequest as signFederatedRequest3 } from "@odla-ai/db";
|
|
5728
|
+
async function gate6(req, env, ctx) {
|
|
5729
|
+
const db = ctx.makeDb(env);
|
|
5730
|
+
const user = await ctx.verifyUser(req, env);
|
|
5731
|
+
if (!user) return { response: json({ error: "unauthorized" }, 401) };
|
|
5732
|
+
if (!await ctx.isAdmin(db, user)) return { response: json({ error: "forbidden" }, 403) };
|
|
5733
|
+
return { db, user };
|
|
5734
|
+
}
|
|
5550
5735
|
function targetFor(ctx, id) {
|
|
5551
5736
|
return ctx.chapter.network.targets.find((target) => target.id === id);
|
|
5552
5737
|
}
|
|
@@ -5556,12 +5741,7 @@ async function readTargetRecords(db, env, ctx, target, query) {
|
|
|
5556
5741
|
const destination = new URL("/api/network/records", target.url);
|
|
5557
5742
|
destination.search = query.toString();
|
|
5558
5743
|
try {
|
|
5559
|
-
const headers = await signFederatedRequest3({
|
|
5560
|
-
secret,
|
|
5561
|
-
sender: ctx.chapter.id,
|
|
5562
|
-
method: "GET",
|
|
5563
|
-
url: destination
|
|
5564
|
-
});
|
|
5744
|
+
const headers = await signFederatedRequest3({ secret, sender: ctx.chapter.id, method: "GET", url: destination });
|
|
5565
5745
|
const response = await fetchNetworkTarget(env, target, destination, {
|
|
5566
5746
|
headers,
|
|
5567
5747
|
signal: AbortSignal.timeout(1e4)
|
|
@@ -5574,15 +5754,90 @@ async function readTargetRecords(db, env, ctx, target, query) {
|
|
|
5574
5754
|
error: upstream ?? (response.ok ? "invalid follower record response" : `follower returned ${response.status}`)
|
|
5575
5755
|
};
|
|
5576
5756
|
}
|
|
5577
|
-
return {
|
|
5757
|
+
return {
|
|
5758
|
+
ok: true,
|
|
5759
|
+
page: { ...body, records: body.records.map((record4) => restrictedRecord(record4, target)) }
|
|
5760
|
+
};
|
|
5578
5761
|
} catch (error) {
|
|
5579
5762
|
return { ok: false, error: error instanceof Error ? error.message : "record read failed" };
|
|
5580
5763
|
}
|
|
5581
5764
|
}
|
|
5765
|
+
async function mutateTargetRecord(db, env, ctx, target, input) {
|
|
5766
|
+
const fields = input.fields;
|
|
5767
|
+
if (fields) {
|
|
5768
|
+
const allowed2 = target.editableFields[input.type];
|
|
5769
|
+
if (!allowed2 || Object.keys(fields).some((name) => !allowed2.includes(name))) {
|
|
5770
|
+
return json({ error: "profile fields are not enabled for this target", code: "field_forbidden" }, 403);
|
|
5771
|
+
}
|
|
5772
|
+
} else if (!target.stageTransitions.includes(input.type)) {
|
|
5773
|
+
return json({ error: "stage transitions are not enabled for this target", code: "stage_forbidden" }, 403);
|
|
5774
|
+
}
|
|
5775
|
+
const secret = await getVaultSecret(db, target.secretName);
|
|
5776
|
+
if (!secret) return json({ error: "edge secret is missing", code: "edge_unavailable" }, 502);
|
|
5777
|
+
const destination = new URL("/api/network/records", target.url);
|
|
5778
|
+
const body = JSON.stringify(input);
|
|
5779
|
+
try {
|
|
5780
|
+
const headers = await signFederatedRequest3({
|
|
5781
|
+
secret,
|
|
5782
|
+
sender: ctx.chapter.id,
|
|
5783
|
+
method: "PATCH",
|
|
5784
|
+
url: destination,
|
|
5785
|
+
body
|
|
5786
|
+
});
|
|
5787
|
+
const response = await fetchNetworkTarget(env, target, destination, {
|
|
5788
|
+
method: "PATCH",
|
|
5789
|
+
headers: { ...headers, "content-type": "application/json" },
|
|
5790
|
+
body,
|
|
5791
|
+
signal: AbortSignal.timeout(1e4)
|
|
5792
|
+
});
|
|
5793
|
+
const payload = await response.json().catch(() => null);
|
|
5794
|
+
const site = payload && "site" in payload ? payload.site : void 0;
|
|
5795
|
+
if (!response.ok || !payload || !("record" in payload) || site && site.id !== target.id) {
|
|
5796
|
+
const status = [401, 403, 404, 409, 422].includes(response.status) ? response.status : 502;
|
|
5797
|
+
return json({
|
|
5798
|
+
error: payload && "error" in payload && typeof payload.error === "string" ? payload.error : `follower returned ${response.status}`,
|
|
5799
|
+
...payload && "code" in payload && typeof payload.code === "string" ? { code: payload.code } : {}
|
|
5800
|
+
}, status);
|
|
5801
|
+
}
|
|
5802
|
+
return json({ ...payload, record: restrictedRecord(payload.record, target) });
|
|
5803
|
+
} catch {
|
|
5804
|
+
return json({ error: "follower mutation request failed", code: "follower_unavailable" }, 502);
|
|
5805
|
+
}
|
|
5806
|
+
}
|
|
5582
5807
|
var handleAdminNetworkRecords = async (req, url, env, ctx) => {
|
|
5583
|
-
if (req.method !== "GET"
|
|
5808
|
+
if (url.pathname !== "/api/admin/network/records" || req.method !== "GET" && req.method !== "PATCH") return null;
|
|
5809
|
+
if (req.method === "PATCH" && Number(req.headers.get("content-length") ?? 0) > MAX_BODY) {
|
|
5810
|
+
return json({ error: "mutation body is too large", code: "body_too_large" }, 413);
|
|
5811
|
+
}
|
|
5584
5812
|
const got = await gate6(req, env, ctx);
|
|
5585
5813
|
if ("response" in got) return got.response;
|
|
5814
|
+
if (req.method === "PATCH") {
|
|
5815
|
+
let input;
|
|
5816
|
+
try {
|
|
5817
|
+
const body = await req.text();
|
|
5818
|
+
if (bodyTooLarge(body)) {
|
|
5819
|
+
return json({ error: "mutation body is too large", code: "body_too_large" }, 413);
|
|
5820
|
+
}
|
|
5821
|
+
input = JSON.parse(body);
|
|
5822
|
+
} catch {
|
|
5823
|
+
return json({ error: "invalid JSON body", code: "invalid_json" }, 422);
|
|
5824
|
+
}
|
|
5825
|
+
const target2 = targetFor(ctx, input?.targetId);
|
|
5826
|
+
const hasFields = Boolean(input?.fields && typeof input.fields === "object" && !Array.isArray(input.fields));
|
|
5827
|
+
const hasStage = typeof input?.stage === "string";
|
|
5828
|
+
if (!target2 || !IDENTIFIER.test(input.type) || !RECORD_ID.test(input.recordId) || !Number.isSafeInteger(input.expectedRevision) || input.expectedRevision < 0 || !MUTATION_ID.test(input.mutationId) || hasFields === hasStage) {
|
|
5829
|
+
return json({ error: "invalid network mutation", code: "invalid_mutation" }, 422);
|
|
5830
|
+
}
|
|
5831
|
+
return mutateTargetRecord(got.db, env, ctx, target2, {
|
|
5832
|
+
type: input.type,
|
|
5833
|
+
recordId: input.recordId,
|
|
5834
|
+
expectedRevision: input.expectedRevision,
|
|
5835
|
+
mutationId: input.mutationId,
|
|
5836
|
+
...input.expectedStage !== void 0 ? { expectedStage: input.expectedStage } : {},
|
|
5837
|
+
...hasFields ? { fields: input.fields } : { stage: input.stage },
|
|
5838
|
+
actor: { id: got.user.userId, ...got.user.email ? { email: got.user.email } : {} }
|
|
5839
|
+
});
|
|
5840
|
+
}
|
|
5586
5841
|
const targetId = url.searchParams.get("targetId") ?? "";
|
|
5587
5842
|
const type = url.searchParams.get("type") ?? "";
|
|
5588
5843
|
const target = targetFor(ctx, targetId);
|
|
@@ -5595,19 +5850,13 @@ var handleAdminNetworkRecords = async (req, url, env, ctx) => {
|
|
|
5595
5850
|
}
|
|
5596
5851
|
const query = new URLSearchParams({ type, limit: String(limit), offset: String(offset) });
|
|
5597
5852
|
if (search) query.set("search", search);
|
|
5598
|
-
const result = await readTargetRecords(
|
|
5599
|
-
got.db,
|
|
5600
|
-
env,
|
|
5601
|
-
ctx,
|
|
5602
|
-
target,
|
|
5603
|
-
query
|
|
5604
|
-
);
|
|
5853
|
+
const result = await readTargetRecords(got.db, env, ctx, target, query);
|
|
5605
5854
|
return result.ok ? json(result.page) : json({ error: result.error }, 502);
|
|
5606
5855
|
};
|
|
5607
5856
|
|
|
5608
5857
|
// src/worker-routes-network-notes.ts
|
|
5609
5858
|
var IDENTIFIER2 = /^[a-z][a-zA-Z0-9_]*$/;
|
|
5610
|
-
var
|
|
5859
|
+
var RECORD_ID2 = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
|
|
5611
5860
|
var MAX_NOTE2 = 4e3;
|
|
5612
5861
|
async function gate7(req, env, ctx) {
|
|
5613
5862
|
const db = ctx.makeDb(env);
|
|
@@ -5621,7 +5870,7 @@ function noteIdentity(url, ctx) {
|
|
|
5621
5870
|
const recordType = url.searchParams.get("type") ?? "";
|
|
5622
5871
|
const recordId = url.searchParams.get("recordId") ?? "";
|
|
5623
5872
|
const configured = ctx.chapter.network.targets.some((target) => target.id === targetId);
|
|
5624
|
-
if (!configured || !IDENTIFIER2.test(recordType) || !
|
|
5873
|
+
if (!configured || !IDENTIFIER2.test(recordType) || !RECORD_ID2.test(recordId)) return null;
|
|
5625
5874
|
return {
|
|
5626
5875
|
targetId,
|
|
5627
5876
|
recordType,
|
|
@@ -5692,13 +5941,13 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
|
|
|
5692
5941
|
// src/worker-routes-network-shared-notes.ts
|
|
5693
5942
|
import {
|
|
5694
5943
|
addActivity,
|
|
5695
|
-
CRM_NS,
|
|
5696
|
-
getRecord as
|
|
5944
|
+
CRM_NS as CRM_NS2,
|
|
5945
|
+
getRecord as getRecord3
|
|
5697
5946
|
} from "@odla-ai/crm";
|
|
5698
5947
|
import { signFederatedRequest as signFederatedRequest4, verifyFederatedRequest as verifyFederatedRequest7 } from "@odla-ai/db";
|
|
5699
5948
|
var IDENTIFIER3 = /^[a-z][a-zA-Z0-9_]*$/;
|
|
5700
|
-
var
|
|
5701
|
-
var
|
|
5949
|
+
var RECORD_ID3 = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
|
|
5950
|
+
var MUTATION_ID2 = /^[A-Za-z0-9_-]{16,128}$/;
|
|
5702
5951
|
var MAX_NOTE3 = 4e3;
|
|
5703
5952
|
var NETWORK_META = "chapterNetwork";
|
|
5704
5953
|
async function gate8(req, env, ctx) {
|
|
@@ -5724,7 +5973,7 @@ function sharedNote(activity, sourceId) {
|
|
|
5724
5973
|
}
|
|
5725
5974
|
async function listSharedNotes(db, sourceId, recordId) {
|
|
5726
5975
|
const result = await db.query({
|
|
5727
|
-
[
|
|
5976
|
+
[CRM_NS2.activity]: {
|
|
5728
5977
|
$: {
|
|
5729
5978
|
where: {
|
|
5730
5979
|
and: [
|
|
@@ -5737,7 +5986,7 @@ async function listSharedNotes(db, sourceId, recordId) {
|
|
|
5737
5986
|
}
|
|
5738
5987
|
}
|
|
5739
5988
|
});
|
|
5740
|
-
return (result[
|
|
5989
|
+
return (result[CRM_NS2.activity] ?? []).flatMap((activity) => {
|
|
5741
5990
|
const note = sharedNote(activity, sourceId);
|
|
5742
5991
|
return note ? [note] : [];
|
|
5743
5992
|
});
|
|
@@ -5762,10 +6011,10 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
5762
6011
|
const reader = readers.find((candidate) => candidate.id === verified.sender);
|
|
5763
6012
|
const recordType = url.searchParams.get("type") ?? "";
|
|
5764
6013
|
const recordId = url.searchParams.get("recordId") ?? "";
|
|
5765
|
-
if (!IDENTIFIER3.test(recordType) || !
|
|
6014
|
+
if (!IDENTIFIER3.test(recordType) || !RECORD_ID3.test(recordId) || !reader?.sharedNotes.includes(recordType)) {
|
|
5766
6015
|
return json({ error: "shared notes are not enabled for this reader and record type" }, 403);
|
|
5767
6016
|
}
|
|
5768
|
-
const record4 = await
|
|
6017
|
+
const record4 = await getRecord3({ crm: ctx.chapter.crm, db }, recordId);
|
|
5769
6018
|
if (!record4 || record4.type !== recordType) return json({ error: "record not found" }, 404);
|
|
5770
6019
|
if (req.method === "GET") {
|
|
5771
6020
|
return json({
|
|
@@ -5782,7 +6031,7 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
5782
6031
|
}
|
|
5783
6032
|
const body = typeof input.body === "string" ? input.body.trim() : "";
|
|
5784
6033
|
const mutationId = typeof input.mutationId === "string" ? input.mutationId : "";
|
|
5785
|
-
if (!body || body.length > MAX_NOTE3 || !
|
|
6034
|
+
if (!body || body.length > MAX_NOTE3 || !MUTATION_ID2.test(mutationId)) {
|
|
5786
6035
|
return json({ error: `body must be 1\u2013${MAX_NOTE3} characters and mutationId must be valid` }, 400);
|
|
5787
6036
|
}
|
|
5788
6037
|
const createdAt = Date.now();
|
|
@@ -5831,7 +6080,7 @@ var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
5831
6080
|
const recordType = url.searchParams.get("type") ?? "";
|
|
5832
6081
|
const recordId = url.searchParams.get("recordId") ?? "";
|
|
5833
6082
|
const target = targetFor2(ctx, targetId);
|
|
5834
|
-
if (!target || !target.sharedNotes.includes(recordType) || !IDENTIFIER3.test(recordType) || !
|
|
6083
|
+
if (!target || !target.sharedNotes.includes(recordType) || !IDENTIFIER3.test(recordType) || !RECORD_ID3.test(recordId)) {
|
|
5835
6084
|
return json({ error: "configured targetId, type, and recordId are required" }, 400);
|
|
5836
6085
|
}
|
|
5837
6086
|
let body = "";
|