@odla-ai/chapter 0.41.1 → 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 +38 -12
- package/dist/{chunk-IAWBE5PX.js → chunk-JXFYOUDL.js} +8 -2
- package/dist/chunk-JXFYOUDL.js.map +1 -0
- package/dist/{chunk-O22RRPBT.js → chunk-MMDWXTJU.js} +2 -2
- package/dist/{chunk-PCRYLPMQ.js → chunk-S7CND3R5.js} +373 -327
- package/dist/chunk-S7CND3R5.js.map +1 -0
- package/dist/{copy-context-B14OOiYq.d.ts → copy-context-BNUzbL5g.d.ts} +19 -1
- package/dist/index.cjs +67 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +66 -2
- package/dist/index.d.ts +66 -2
- package/dist/index.js +67 -11
- package/dist/index.js.map +1 -1
- package/dist/ui/admin/index.d.ts +2 -2
- package/dist/ui/admin/index.js +2 -2
- package/dist/ui/index.d.ts +2 -2
- package/dist/ui/index.js +5 -3
- package/dist/ui/member/index.d.ts +37 -9
- package/dist/ui/member/index.js +4 -2
- package/dist/worker/index.cjs +368 -62
- package/dist/worker/index.cjs.map +1 -1
- package/dist/worker/index.d.cts +19 -1
- package/dist/worker/index.d.ts +19 -1
- package/dist/worker/index.js +361 -55
- package/dist/worker/index.js.map +1 -1
- package/package.json +2 -2
- package/runbooks/greenfield.md +3 -0
- package/dist/chunk-IAWBE5PX.js.map +0 -1
- package/dist/chunk-PCRYLPMQ.js.map +0 -1
- /package/dist/{chunk-O22RRPBT.js.map → chunk-MMDWXTJU.js.map} +0 -0
package/dist/worker/index.js
CHANGED
|
@@ -1981,7 +1981,12 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
1981
1981
|
const group = Array.isArray(groups) ? groups[0] : void 0;
|
|
1982
1982
|
if (!group) return json({ error: "not found" }, 404);
|
|
1983
1983
|
const state = await continueJoin(db, ctx, env, url.origin, app, group);
|
|
1984
|
-
if (state) return json({
|
|
1984
|
+
if (state) return json({
|
|
1985
|
+
state,
|
|
1986
|
+
applicationId,
|
|
1987
|
+
...state === "gift" ? { claimId: app.giftClaimId } : {},
|
|
1988
|
+
...state === "payment" && typeof app.giftOfferId === "string" ? { giftPurchase: true } : {}
|
|
1989
|
+
});
|
|
1985
1990
|
return json({ error: `application cannot resume from status "${String(app.status ?? "")}"` }, 409);
|
|
1986
1991
|
}
|
|
1987
1992
|
if (req.method === "POST" && url.pathname === "/api/applications") {
|
|
@@ -2030,6 +2035,7 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
2030
2035
|
duplicate: result.duplicate,
|
|
2031
2036
|
status: result.status,
|
|
2032
2037
|
...state ? { state } : {},
|
|
2038
|
+
...state === "payment" && requestedGiftId ? { giftPurchase: true } : {},
|
|
2033
2039
|
// Echoed so a site can see (and assert in an integration test) whether its
|
|
2034
2040
|
// join page actually posted the ack. Absent consent is otherwise invisible.
|
|
2035
2041
|
disclaimerAckAt: result.disclaimerAckAt
|
|
@@ -3728,9 +3734,15 @@ async function redeemGiftClaim(db, claim, beneficiary, now) {
|
|
|
3728
3734
|
const target = claim.target;
|
|
3729
3735
|
const ops = [];
|
|
3730
3736
|
let result;
|
|
3737
|
+
let applicationGuard;
|
|
3731
3738
|
if (target.type === "tier") {
|
|
3732
|
-
const apps = (await db.query({ applications: { $: { where: { emailNormalized }, limit: 20 } } })).applications;
|
|
3733
|
-
const existing = apps?.filter((row) => row.groupId === chapterId).sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0];
|
|
3739
|
+
const apps = beneficiary.applicationId ? (await db.query({ applications: { $: { where: { id: beneficiary.applicationId }, limit: 1 } } })).applications : (await db.query({ applications: { $: { where: { emailNormalized }, limit: 20 } } })).applications;
|
|
3740
|
+
const existing = beneficiary.applicationId ? apps?.[0] : apps?.filter((row) => row.groupId === chapterId).sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0];
|
|
3741
|
+
if (beneficiary.applicationId && (!existing || String(existing.groupId ?? "") !== chapterId || String(existing.emailNormalized ?? "") !== emailNormalized || String(existing.tierId ?? "") !== target.tierId || String(existing.status ?? "") !== "submitted" || typeof existing.stripeSubscriptionId === "string" || typeof existing.giftClaimId === "string" || typeof existing.clerkUserId === "string" && existing.clerkUserId !== beneficiary.userId)) return null;
|
|
3742
|
+
if (beneficiary.applicationId) applicationGuard = {
|
|
3743
|
+
tierId: target.tierId,
|
|
3744
|
+
...typeof existing?.clerkUserId === "string" ? { clerkUserId: existing.clerkUserId } : {}
|
|
3745
|
+
};
|
|
3734
3746
|
const id = existing ? String(existing.id) : `gift-application:${String(claim.id)}`;
|
|
3735
3747
|
const status = existing && existing.status !== "submitted" ? String(existing.status) : "paid_pending_vetting";
|
|
3736
3748
|
ops.push({ t: "update", ns: "applications", id, attrs: {
|
|
@@ -3747,6 +3759,7 @@ async function redeemGiftClaim(db, claim, beneficiary, now) {
|
|
|
3747
3759
|
} });
|
|
3748
3760
|
result = { kind: "application", id };
|
|
3749
3761
|
} else {
|
|
3762
|
+
if (beneficiary.applicationId) return null;
|
|
3750
3763
|
const id = `gift-entitlement:${String(claim.id)}`;
|
|
3751
3764
|
ops.push({ t: "update", ns: "giftEntitlements", id, attrs: {
|
|
3752
3765
|
id,
|
|
@@ -3770,13 +3783,27 @@ async function redeemGiftClaim(db, claim, beneficiary, now) {
|
|
|
3770
3783
|
attemptCount: Number(claim.attemptCount ?? 0) + 1
|
|
3771
3784
|
} });
|
|
3772
3785
|
try {
|
|
3786
|
+
const guards = [{ ns: "giftClaims", id: String(claim.id), exists: true, equals: {
|
|
3787
|
+
status: "pending",
|
|
3788
|
+
codeVersion: Number(claim.codeVersion),
|
|
3789
|
+
codeHash: String(claim.codeHash)
|
|
3790
|
+
} }];
|
|
3791
|
+
if (beneficiary.applicationId) guards.push({
|
|
3792
|
+
ns: "applications",
|
|
3793
|
+
id: beneficiary.applicationId,
|
|
3794
|
+
exists: true,
|
|
3795
|
+
equals: {
|
|
3796
|
+
status: "submitted",
|
|
3797
|
+
groupId: chapterId,
|
|
3798
|
+
emailNormalized,
|
|
3799
|
+
tierId: applicationGuard.tierId,
|
|
3800
|
+
...applicationGuard?.clerkUserId ? { clerkUserId: applicationGuard.clerkUserId } : {}
|
|
3801
|
+
},
|
|
3802
|
+
absent: ["stripeSubscriptionId", "giftClaimId"]
|
|
3803
|
+
});
|
|
3773
3804
|
await db.transact(ops, {
|
|
3774
3805
|
mutationId: `gift-redeem:${String(claim.id)}:${beneficiary.userId}`,
|
|
3775
|
-
guards
|
|
3776
|
-
status: "pending",
|
|
3777
|
-
codeVersion: Number(claim.codeVersion),
|
|
3778
|
-
codeHash: String(claim.codeHash)
|
|
3779
|
-
} }]
|
|
3806
|
+
guards
|
|
3780
3807
|
});
|
|
3781
3808
|
return result;
|
|
3782
3809
|
} catch {
|
|
@@ -4291,15 +4318,35 @@ var handleGifts = async (req, url, env, ctx) => {
|
|
|
4291
4318
|
return json({ error: "too many attempts" }, 429);
|
|
4292
4319
|
}
|
|
4293
4320
|
let code = "";
|
|
4321
|
+
let applicationId = "";
|
|
4294
4322
|
try {
|
|
4295
|
-
|
|
4323
|
+
const body = JSON.parse(await req.text());
|
|
4324
|
+
code = String(body.code ?? "").trim();
|
|
4325
|
+
applicationId = typeof body.applicationId === "string" ? body.applicationId : "";
|
|
4296
4326
|
} catch {
|
|
4297
4327
|
return invalid();
|
|
4298
4328
|
}
|
|
4299
|
-
if (!/^gift1_[A-Za-z0-9_-]{43}$/.test(code)) return invalid();
|
|
4329
|
+
if (!/^gift1_[A-Za-z0-9_-]{43}$/.test(code) || !applicationId) return invalid();
|
|
4330
|
+
const app = first3((await db.query({ applications: { $: { where: { id: applicationId }, limit: 1 } } })).applications);
|
|
4331
|
+
const emailNormalized = normalizeChapterEmail(user.email);
|
|
4332
|
+
if (!app || String(app.groupId ?? "") !== ctx.chapter.id || String(app.emailNormalized ?? "") !== emailNormalized || typeof app.clerkUserId === "string" && app.clerkUserId !== user.userId) return invalid();
|
|
4300
4333
|
const codeHash = await hashGiftCode(ctx.chapter.id, code);
|
|
4301
4334
|
const claim = first3((await db.query({ giftClaims: { $: { where: { codeHash }, limit: 1 } } })).giftClaims);
|
|
4302
4335
|
if (!claim || claim.chapterId !== ctx.chapter.id) return invalid();
|
|
4336
|
+
const sameRedemption = (candidate) => candidate.status === "redeemed" && candidate.applicationId === applicationId && candidate.beneficiaryEmailNormalized === emailNormalized && candidate.beneficiaryUserId === user.userId;
|
|
4337
|
+
const success = async () => {
|
|
4338
|
+
const current = first3((await db.query({ applications: { $: { where: { id: applicationId }, limit: 1 } } })).applications);
|
|
4339
|
+
const group2 = first3((await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups);
|
|
4340
|
+
const state = current && group2 ? await continueJoin(db, ctx, env, url.origin, current, group2) : null;
|
|
4341
|
+
return json({
|
|
4342
|
+
ok: true,
|
|
4343
|
+
kind: "application",
|
|
4344
|
+
id: applicationId,
|
|
4345
|
+
applicationId,
|
|
4346
|
+
...state ? { state } : {}
|
|
4347
|
+
});
|
|
4348
|
+
};
|
|
4349
|
+
if (sameRedemption(claim)) return success();
|
|
4303
4350
|
if (claim.status !== "pending" || Number(claim.codeExpiresAt) <= now) {
|
|
4304
4351
|
await db.transact([{ t: "update", ns: "giftClaims", id: String(claim.id), attrs: {
|
|
4305
4352
|
attemptCount: Number(claim.attemptCount ?? 0) + 1,
|
|
@@ -4307,8 +4354,18 @@ var handleGifts = async (req, url, env, ctx) => {
|
|
|
4307
4354
|
} }]).catch(() => void 0);
|
|
4308
4355
|
return invalid();
|
|
4309
4356
|
}
|
|
4310
|
-
const
|
|
4311
|
-
|
|
4357
|
+
const target = claim.target;
|
|
4358
|
+
const group = first3((await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups);
|
|
4359
|
+
const tier = group ? findTier(await loadTiers(db, group), typeof app.tierId === "string" ? app.tierId : null) : void 0;
|
|
4360
|
+
if (String(app.status ?? "") !== "submitted" || typeof app.stripeSubscriptionId === "string" || typeof app.giftClaimId === "string" || !tier || tierIsFree(tier) || target?.type !== "tier" || target.tierId !== app.tierId) return invalid();
|
|
4361
|
+
const redeemed = await redeemGiftClaim(db, claim, {
|
|
4362
|
+
email: user.email,
|
|
4363
|
+
userId: user.userId,
|
|
4364
|
+
applicationId
|
|
4365
|
+
}, now);
|
|
4366
|
+
if (redeemed) return success();
|
|
4367
|
+
const raced = await loadClaim(db, String(claim.id));
|
|
4368
|
+
return raced && sameRedemption(raced) ? success() : invalid();
|
|
4312
4369
|
}
|
|
4313
4370
|
return null;
|
|
4314
4371
|
};
|
|
@@ -5291,12 +5348,22 @@ var handleAdminNetworkTargets = async (req, url, env, ctx) => {
|
|
|
5291
5348
|
const got = await gate5(req, env, ctx);
|
|
5292
5349
|
if ("response" in got) return got.response;
|
|
5293
5350
|
return json({
|
|
5294
|
-
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
|
+
}) => ({
|
|
5295
5360
|
id,
|
|
5296
5361
|
name,
|
|
5297
5362
|
url: targetUrl,
|
|
5298
5363
|
types: Object.keys(fields ?? DEFAULT_SHARE_FIELDS),
|
|
5299
|
-
sharedNotes
|
|
5364
|
+
sharedNotes,
|
|
5365
|
+
editableFields,
|
|
5366
|
+
stageTransitions
|
|
5300
5367
|
}))
|
|
5301
5368
|
});
|
|
5302
5369
|
};
|
|
@@ -5414,19 +5481,20 @@ var handleAdminNetworkPush = async (req, url, env, ctx) => {
|
|
|
5414
5481
|
return json({ ok: results.every((result) => result.ok), results });
|
|
5415
5482
|
};
|
|
5416
5483
|
|
|
5417
|
-
// src/worker-routes-network-records.ts
|
|
5418
|
-
import { listRecords } from "@odla-ai/crm";
|
|
5419
|
-
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
|
|
5420
5489
|
var IDENTIFIER = /^[a-z][a-zA-Z0-9_]*$/;
|
|
5421
5490
|
var MAX_PAGE = 50;
|
|
5422
5491
|
var MAX_OFFSET = 1e4;
|
|
5423
5492
|
var MAX_SEARCH = 200;
|
|
5424
|
-
|
|
5425
|
-
|
|
5426
|
-
|
|
5427
|
-
|
|
5428
|
-
|
|
5429
|
-
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;
|
|
5430
5498
|
}
|
|
5431
5499
|
function integer(value, fallback, min, max) {
|
|
5432
5500
|
if (value == null || value === "") return fallback;
|
|
@@ -5434,10 +5502,22 @@ function integer(value, fallback, min, max) {
|
|
|
5434
5502
|
const parsed = Number(value);
|
|
5435
5503
|
return Number.isSafeInteger(parsed) && parsed >= min && parsed <= max ? parsed : null;
|
|
5436
5504
|
}
|
|
5437
|
-
function projectRecord(record4, allowed2) {
|
|
5505
|
+
function projectRecord(record4, allowed2, editable, stagesEnabled, crm) {
|
|
5438
5506
|
const fields = Object.fromEntries(
|
|
5439
5507
|
allowed2.flatMap((name) => Object.prototype.hasOwnProperty.call(record4.fields, name) ? [[name, record4.fields[name]]] : [])
|
|
5440
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 }]) : [];
|
|
5441
5521
|
return {
|
|
5442
5522
|
id: record4.id,
|
|
5443
5523
|
type: record4.type,
|
|
@@ -5445,11 +5525,66 @@ function projectRecord(record4, allowed2) {
|
|
|
5445
5525
|
...record4.stage ? { stage: record4.stage } : {},
|
|
5446
5526
|
fields,
|
|
5447
5527
|
createdAt: record4.createdAt,
|
|
5448
|
-
updatedAt: record4.updatedAt
|
|
5528
|
+
updatedAt: record4.updatedAt,
|
|
5529
|
+
revision: record4.updatedAt,
|
|
5530
|
+
...editFields.length || stagesEnabled ? { edit: { fields: editFields, allowedStages } } : {}
|
|
5449
5531
|
};
|
|
5450
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
|
+
}
|
|
5451
5583
|
var handleNetworkRecords = async (req, url, env, ctx) => {
|
|
5452
|
-
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
|
+
}
|
|
5453
5588
|
const db = ctx.makeDb(env);
|
|
5454
5589
|
const secret = await getVaultSecret(db, "network_share_secret");
|
|
5455
5590
|
const readers = ctx.chapter.network.readers;
|
|
@@ -5460,8 +5595,105 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
|
|
|
5460
5595
|
});
|
|
5461
5596
|
if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
|
|
5462
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
|
+
}
|
|
5463
5695
|
const type = url.searchParams.get("type") ?? "";
|
|
5464
|
-
const allowed2 = reader
|
|
5696
|
+
const allowed2 = reader.fields[type];
|
|
5465
5697
|
if (!IDENTIFIER.test(type) || !allowed2) return json({ error: "record type is not shared with this reader" }, 403);
|
|
5466
5698
|
const limit = integer(url.searchParams.get("limit"), 25, 1, MAX_PAGE);
|
|
5467
5699
|
const offset = integer(url.searchParams.get("offset"), 0, 0, MAX_OFFSET);
|
|
@@ -5483,13 +5715,23 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
|
|
|
5483
5715
|
version: 1,
|
|
5484
5716
|
site: { id: ctx.chapter.id, name: ctx.chapter.name },
|
|
5485
5717
|
type,
|
|
5486
|
-
records: page.records.map((record4) =>
|
|
5718
|
+
records: page.records.map((record4) => readerRecord(record4, reader, ctx.chapter.crm)),
|
|
5487
5719
|
total: page.total,
|
|
5488
5720
|
limit: page.limit,
|
|
5489
5721
|
offset: page.offset
|
|
5490
5722
|
};
|
|
5491
5723
|
return json(response);
|
|
5492
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
|
+
}
|
|
5493
5735
|
function targetFor(ctx, id) {
|
|
5494
5736
|
return ctx.chapter.network.targets.find((target) => target.id === id);
|
|
5495
5737
|
}
|
|
@@ -5499,12 +5741,7 @@ async function readTargetRecords(db, env, ctx, target, query) {
|
|
|
5499
5741
|
const destination = new URL("/api/network/records", target.url);
|
|
5500
5742
|
destination.search = query.toString();
|
|
5501
5743
|
try {
|
|
5502
|
-
const headers = await signFederatedRequest3({
|
|
5503
|
-
secret,
|
|
5504
|
-
sender: ctx.chapter.id,
|
|
5505
|
-
method: "GET",
|
|
5506
|
-
url: destination
|
|
5507
|
-
});
|
|
5744
|
+
const headers = await signFederatedRequest3({ secret, sender: ctx.chapter.id, method: "GET", url: destination });
|
|
5508
5745
|
const response = await fetchNetworkTarget(env, target, destination, {
|
|
5509
5746
|
headers,
|
|
5510
5747
|
signal: AbortSignal.timeout(1e4)
|
|
@@ -5517,15 +5754,90 @@ async function readTargetRecords(db, env, ctx, target, query) {
|
|
|
5517
5754
|
error: upstream ?? (response.ok ? "invalid follower record response" : `follower returned ${response.status}`)
|
|
5518
5755
|
};
|
|
5519
5756
|
}
|
|
5520
|
-
return {
|
|
5757
|
+
return {
|
|
5758
|
+
ok: true,
|
|
5759
|
+
page: { ...body, records: body.records.map((record4) => restrictedRecord(record4, target)) }
|
|
5760
|
+
};
|
|
5521
5761
|
} catch (error) {
|
|
5522
5762
|
return { ok: false, error: error instanceof Error ? error.message : "record read failed" };
|
|
5523
5763
|
}
|
|
5524
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
|
+
}
|
|
5525
5807
|
var handleAdminNetworkRecords = async (req, url, env, ctx) => {
|
|
5526
|
-
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
|
+
}
|
|
5527
5812
|
const got = await gate6(req, env, ctx);
|
|
5528
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
|
+
}
|
|
5529
5841
|
const targetId = url.searchParams.get("targetId") ?? "";
|
|
5530
5842
|
const type = url.searchParams.get("type") ?? "";
|
|
5531
5843
|
const target = targetFor(ctx, targetId);
|
|
@@ -5538,19 +5850,13 @@ var handleAdminNetworkRecords = async (req, url, env, ctx) => {
|
|
|
5538
5850
|
}
|
|
5539
5851
|
const query = new URLSearchParams({ type, limit: String(limit), offset: String(offset) });
|
|
5540
5852
|
if (search) query.set("search", search);
|
|
5541
|
-
const result = await readTargetRecords(
|
|
5542
|
-
got.db,
|
|
5543
|
-
env,
|
|
5544
|
-
ctx,
|
|
5545
|
-
target,
|
|
5546
|
-
query
|
|
5547
|
-
);
|
|
5853
|
+
const result = await readTargetRecords(got.db, env, ctx, target, query);
|
|
5548
5854
|
return result.ok ? json(result.page) : json({ error: result.error }, 502);
|
|
5549
5855
|
};
|
|
5550
5856
|
|
|
5551
5857
|
// src/worker-routes-network-notes.ts
|
|
5552
5858
|
var IDENTIFIER2 = /^[a-z][a-zA-Z0-9_]*$/;
|
|
5553
|
-
var
|
|
5859
|
+
var RECORD_ID2 = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
|
|
5554
5860
|
var MAX_NOTE2 = 4e3;
|
|
5555
5861
|
async function gate7(req, env, ctx) {
|
|
5556
5862
|
const db = ctx.makeDb(env);
|
|
@@ -5564,7 +5870,7 @@ function noteIdentity(url, ctx) {
|
|
|
5564
5870
|
const recordType = url.searchParams.get("type") ?? "";
|
|
5565
5871
|
const recordId = url.searchParams.get("recordId") ?? "";
|
|
5566
5872
|
const configured = ctx.chapter.network.targets.some((target) => target.id === targetId);
|
|
5567
|
-
if (!configured || !IDENTIFIER2.test(recordType) || !
|
|
5873
|
+
if (!configured || !IDENTIFIER2.test(recordType) || !RECORD_ID2.test(recordId)) return null;
|
|
5568
5874
|
return {
|
|
5569
5875
|
targetId,
|
|
5570
5876
|
recordType,
|
|
@@ -5635,13 +5941,13 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
|
|
|
5635
5941
|
// src/worker-routes-network-shared-notes.ts
|
|
5636
5942
|
import {
|
|
5637
5943
|
addActivity,
|
|
5638
|
-
CRM_NS,
|
|
5639
|
-
getRecord as
|
|
5944
|
+
CRM_NS as CRM_NS2,
|
|
5945
|
+
getRecord as getRecord3
|
|
5640
5946
|
} from "@odla-ai/crm";
|
|
5641
5947
|
import { signFederatedRequest as signFederatedRequest4, verifyFederatedRequest as verifyFederatedRequest7 } from "@odla-ai/db";
|
|
5642
5948
|
var IDENTIFIER3 = /^[a-z][a-zA-Z0-9_]*$/;
|
|
5643
|
-
var
|
|
5644
|
-
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}$/;
|
|
5645
5951
|
var MAX_NOTE3 = 4e3;
|
|
5646
5952
|
var NETWORK_META = "chapterNetwork";
|
|
5647
5953
|
async function gate8(req, env, ctx) {
|
|
@@ -5667,7 +5973,7 @@ function sharedNote(activity, sourceId) {
|
|
|
5667
5973
|
}
|
|
5668
5974
|
async function listSharedNotes(db, sourceId, recordId) {
|
|
5669
5975
|
const result = await db.query({
|
|
5670
|
-
[
|
|
5976
|
+
[CRM_NS2.activity]: {
|
|
5671
5977
|
$: {
|
|
5672
5978
|
where: {
|
|
5673
5979
|
and: [
|
|
@@ -5680,7 +5986,7 @@ async function listSharedNotes(db, sourceId, recordId) {
|
|
|
5680
5986
|
}
|
|
5681
5987
|
}
|
|
5682
5988
|
});
|
|
5683
|
-
return (result[
|
|
5989
|
+
return (result[CRM_NS2.activity] ?? []).flatMap((activity) => {
|
|
5684
5990
|
const note = sharedNote(activity, sourceId);
|
|
5685
5991
|
return note ? [note] : [];
|
|
5686
5992
|
});
|
|
@@ -5705,10 +6011,10 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
5705
6011
|
const reader = readers.find((candidate) => candidate.id === verified.sender);
|
|
5706
6012
|
const recordType = url.searchParams.get("type") ?? "";
|
|
5707
6013
|
const recordId = url.searchParams.get("recordId") ?? "";
|
|
5708
|
-
if (!IDENTIFIER3.test(recordType) || !
|
|
6014
|
+
if (!IDENTIFIER3.test(recordType) || !RECORD_ID3.test(recordId) || !reader?.sharedNotes.includes(recordType)) {
|
|
5709
6015
|
return json({ error: "shared notes are not enabled for this reader and record type" }, 403);
|
|
5710
6016
|
}
|
|
5711
|
-
const record4 = await
|
|
6017
|
+
const record4 = await getRecord3({ crm: ctx.chapter.crm, db }, recordId);
|
|
5712
6018
|
if (!record4 || record4.type !== recordType) return json({ error: "record not found" }, 404);
|
|
5713
6019
|
if (req.method === "GET") {
|
|
5714
6020
|
return json({
|
|
@@ -5725,7 +6031,7 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
5725
6031
|
}
|
|
5726
6032
|
const body = typeof input.body === "string" ? input.body.trim() : "";
|
|
5727
6033
|
const mutationId = typeof input.mutationId === "string" ? input.mutationId : "";
|
|
5728
|
-
if (!body || body.length > MAX_NOTE3 || !
|
|
6034
|
+
if (!body || body.length > MAX_NOTE3 || !MUTATION_ID2.test(mutationId)) {
|
|
5729
6035
|
return json({ error: `body must be 1\u2013${MAX_NOTE3} characters and mutationId must be valid` }, 400);
|
|
5730
6036
|
}
|
|
5731
6037
|
const createdAt = Date.now();
|
|
@@ -5774,7 +6080,7 @@ var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
|
|
|
5774
6080
|
const recordType = url.searchParams.get("type") ?? "";
|
|
5775
6081
|
const recordId = url.searchParams.get("recordId") ?? "";
|
|
5776
6082
|
const target = targetFor2(ctx, targetId);
|
|
5777
|
-
if (!target || !target.sharedNotes.includes(recordType) || !IDENTIFIER3.test(recordType) || !
|
|
6083
|
+
if (!target || !target.sharedNotes.includes(recordType) || !IDENTIFIER3.test(recordType) || !RECORD_ID3.test(recordId)) {
|
|
5778
6084
|
return json({ error: "configured targetId, type, and recordId are required" }, 400);
|
|
5779
6085
|
}
|
|
5780
6086
|
let body = "";
|