@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.
@@ -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({ state, applicationId, ...state === "gift" ? { claimId: app.giftClaimId } : {} });
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: [{ ns: "giftClaims", id: String(claim.id), exists: true, equals: {
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
- code = String(JSON.parse(await req.text()).code ?? "").trim();
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 redeemed = await redeemGiftClaim(db, claim, { email: user.email, userId: user.userId }, now);
4311
- return redeemed ? json({ ok: true, ...redeemed }) : invalid();
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(({ id, name, url: targetUrl, fields, sharedNotes }) => ({
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 { signFederatedRequest as signFederatedRequest3, verifyFederatedRequest as verifyFederatedRequest6 } from "@odla-ai/db";
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
- async function gate6(req, env, ctx) {
5425
- const db = ctx.makeDb(env);
5426
- const user = await ctx.verifyUser(req, env);
5427
- if (!user) return { response: json({ error: "unauthorized" }, 401) };
5428
- if (!await ctx.isAdmin(db, user)) return { response: json({ error: "forbidden" }, 403) };
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" || url.pathname !== "/api/network/records") return null;
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?.fields[type];
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) => projectRecord(record4, allowed2)),
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 { ok: true, page: body };
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" || url.pathname !== "/api/admin/network/records") return null;
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 RECORD_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
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) || !RECORD_ID.test(recordId)) return null;
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 getRecord2
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 RECORD_ID2 = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
5644
- var MUTATION_ID = /^[A-Za-z0-9_-]{16,128}$/;
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
- [CRM_NS.activity]: {
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[CRM_NS.activity] ?? []).flatMap((activity) => {
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) || !RECORD_ID2.test(recordId) || !reader?.sharedNotes.includes(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 getRecord2({ crm: ctx.chapter.crm, db }, recordId);
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 || !MUTATION_ID.test(mutationId)) {
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) || !RECORD_ID2.test(recordId)) {
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 = "";