@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.
@@ -2009,7 +2009,12 @@ var handleMember = async (req, url, env, ctx) => {
2009
2009
  const group = Array.isArray(groups) ? groups[0] : void 0;
2010
2010
  if (!group) return json({ error: "not found" }, 404);
2011
2011
  const state = await continueJoin(db, ctx, env, url.origin, app, group);
2012
- if (state) return json({ state, applicationId, ...state === "gift" ? { claimId: app.giftClaimId } : {} });
2012
+ if (state) return json({
2013
+ state,
2014
+ applicationId,
2015
+ ...state === "gift" ? { claimId: app.giftClaimId } : {},
2016
+ ...state === "payment" && typeof app.giftOfferId === "string" ? { giftPurchase: true } : {}
2017
+ });
2013
2018
  return json({ error: `application cannot resume from status "${String(app.status ?? "")}"` }, 409);
2014
2019
  }
2015
2020
  if (req.method === "POST" && url.pathname === "/api/applications") {
@@ -2058,6 +2063,7 @@ var handleMember = async (req, url, env, ctx) => {
2058
2063
  duplicate: result.duplicate,
2059
2064
  status: result.status,
2060
2065
  ...state ? { state } : {},
2066
+ ...state === "payment" && requestedGiftId ? { giftPurchase: true } : {},
2061
2067
  // Echoed so a site can see (and assert in an integration test) whether its
2062
2068
  // join page actually posted the ack. Absent consent is otherwise invisible.
2063
2069
  disclaimerAckAt: result.disclaimerAckAt
@@ -3756,9 +3762,15 @@ async function redeemGiftClaim(db, claim, beneficiary, now) {
3756
3762
  const target = claim.target;
3757
3763
  const ops = [];
3758
3764
  let result;
3765
+ let applicationGuard;
3759
3766
  if (target.type === "tier") {
3760
- const apps = (await db.query({ applications: { $: { where: { emailNormalized }, limit: 20 } } })).applications;
3761
- const existing = apps?.filter((row) => row.groupId === chapterId).sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0];
3767
+ const apps = beneficiary.applicationId ? (await db.query({ applications: { $: { where: { id: beneficiary.applicationId }, limit: 1 } } })).applications : (await db.query({ applications: { $: { where: { emailNormalized }, limit: 20 } } })).applications;
3768
+ const existing = beneficiary.applicationId ? apps?.[0] : apps?.filter((row) => row.groupId === chapterId).sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0];
3769
+ 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;
3770
+ if (beneficiary.applicationId) applicationGuard = {
3771
+ tierId: target.tierId,
3772
+ ...typeof existing?.clerkUserId === "string" ? { clerkUserId: existing.clerkUserId } : {}
3773
+ };
3762
3774
  const id = existing ? String(existing.id) : `gift-application:${String(claim.id)}`;
3763
3775
  const status = existing && existing.status !== "submitted" ? String(existing.status) : "paid_pending_vetting";
3764
3776
  ops.push({ t: "update", ns: "applications", id, attrs: {
@@ -3775,6 +3787,7 @@ async function redeemGiftClaim(db, claim, beneficiary, now) {
3775
3787
  } });
3776
3788
  result = { kind: "application", id };
3777
3789
  } else {
3790
+ if (beneficiary.applicationId) return null;
3778
3791
  const id = `gift-entitlement:${String(claim.id)}`;
3779
3792
  ops.push({ t: "update", ns: "giftEntitlements", id, attrs: {
3780
3793
  id,
@@ -3798,13 +3811,27 @@ async function redeemGiftClaim(db, claim, beneficiary, now) {
3798
3811
  attemptCount: Number(claim.attemptCount ?? 0) + 1
3799
3812
  } });
3800
3813
  try {
3814
+ const guards = [{ ns: "giftClaims", id: String(claim.id), exists: true, equals: {
3815
+ status: "pending",
3816
+ codeVersion: Number(claim.codeVersion),
3817
+ codeHash: String(claim.codeHash)
3818
+ } }];
3819
+ if (beneficiary.applicationId) guards.push({
3820
+ ns: "applications",
3821
+ id: beneficiary.applicationId,
3822
+ exists: true,
3823
+ equals: {
3824
+ status: "submitted",
3825
+ groupId: chapterId,
3826
+ emailNormalized,
3827
+ tierId: applicationGuard.tierId,
3828
+ ...applicationGuard?.clerkUserId ? { clerkUserId: applicationGuard.clerkUserId } : {}
3829
+ },
3830
+ absent: ["stripeSubscriptionId", "giftClaimId"]
3831
+ });
3801
3832
  await db.transact(ops, {
3802
3833
  mutationId: `gift-redeem:${String(claim.id)}:${beneficiary.userId}`,
3803
- guards: [{ ns: "giftClaims", id: String(claim.id), exists: true, equals: {
3804
- status: "pending",
3805
- codeVersion: Number(claim.codeVersion),
3806
- codeHash: String(claim.codeHash)
3807
- } }]
3834
+ guards
3808
3835
  });
3809
3836
  return result;
3810
3837
  } catch {
@@ -4319,15 +4346,35 @@ var handleGifts = async (req, url, env, ctx) => {
4319
4346
  return json({ error: "too many attempts" }, 429);
4320
4347
  }
4321
4348
  let code = "";
4349
+ let applicationId = "";
4322
4350
  try {
4323
- code = String(JSON.parse(await req.text()).code ?? "").trim();
4351
+ const body = JSON.parse(await req.text());
4352
+ code = String(body.code ?? "").trim();
4353
+ applicationId = typeof body.applicationId === "string" ? body.applicationId : "";
4324
4354
  } catch {
4325
4355
  return invalid();
4326
4356
  }
4327
- if (!/^gift1_[A-Za-z0-9_-]{43}$/.test(code)) return invalid();
4357
+ if (!/^gift1_[A-Za-z0-9_-]{43}$/.test(code) || !applicationId) return invalid();
4358
+ const app = first3((await db.query({ applications: { $: { where: { id: applicationId }, limit: 1 } } })).applications);
4359
+ const emailNormalized = normalizeChapterEmail(user.email);
4360
+ if (!app || String(app.groupId ?? "") !== ctx.chapter.id || String(app.emailNormalized ?? "") !== emailNormalized || typeof app.clerkUserId === "string" && app.clerkUserId !== user.userId) return invalid();
4328
4361
  const codeHash = await hashGiftCode(ctx.chapter.id, code);
4329
4362
  const claim = first3((await db.query({ giftClaims: { $: { where: { codeHash }, limit: 1 } } })).giftClaims);
4330
4363
  if (!claim || claim.chapterId !== ctx.chapter.id) return invalid();
4364
+ const sameRedemption = (candidate) => candidate.status === "redeemed" && candidate.applicationId === applicationId && candidate.beneficiaryEmailNormalized === emailNormalized && candidate.beneficiaryUserId === user.userId;
4365
+ const success = async () => {
4366
+ const current = first3((await db.query({ applications: { $: { where: { id: applicationId }, limit: 1 } } })).applications);
4367
+ const group2 = first3((await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups);
4368
+ const state = current && group2 ? await continueJoin(db, ctx, env, url.origin, current, group2) : null;
4369
+ return json({
4370
+ ok: true,
4371
+ kind: "application",
4372
+ id: applicationId,
4373
+ applicationId,
4374
+ ...state ? { state } : {}
4375
+ });
4376
+ };
4377
+ if (sameRedemption(claim)) return success();
4331
4378
  if (claim.status !== "pending" || Number(claim.codeExpiresAt) <= now) {
4332
4379
  await db.transact([{ t: "update", ns: "giftClaims", id: String(claim.id), attrs: {
4333
4380
  attemptCount: Number(claim.attemptCount ?? 0) + 1,
@@ -4335,8 +4382,18 @@ var handleGifts = async (req, url, env, ctx) => {
4335
4382
  } }]).catch(() => void 0);
4336
4383
  return invalid();
4337
4384
  }
4338
- const redeemed = await redeemGiftClaim(db, claim, { email: user.email, userId: user.userId }, now);
4339
- return redeemed ? json({ ok: true, ...redeemed }) : invalid();
4385
+ const target = claim.target;
4386
+ const group = first3((await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups);
4387
+ const tier = group ? findTier(await loadTiers(db, group), typeof app.tierId === "string" ? app.tierId : null) : void 0;
4388
+ 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();
4389
+ const redeemed = await redeemGiftClaim(db, claim, {
4390
+ email: user.email,
4391
+ userId: user.userId,
4392
+ applicationId
4393
+ }, now);
4394
+ if (redeemed) return success();
4395
+ const raced = await loadClaim(db, String(claim.id));
4396
+ return raced && sameRedemption(raced) ? success() : invalid();
4340
4397
  }
4341
4398
  return null;
4342
4399
  };
@@ -5319,12 +5376,22 @@ var handleAdminNetworkTargets = async (req, url, env, ctx) => {
5319
5376
  const got = await gate5(req, env, ctx);
5320
5377
  if ("response" in got) return got.response;
5321
5378
  return json({
5322
- targets: ctx.chapter.network.targets.map(({ id, name, url: targetUrl, fields, sharedNotes }) => ({
5379
+ targets: ctx.chapter.network.targets.map(({
5380
+ id,
5381
+ name,
5382
+ url: targetUrl,
5383
+ fields,
5384
+ sharedNotes,
5385
+ editableFields,
5386
+ stageTransitions
5387
+ }) => ({
5323
5388
  id,
5324
5389
  name,
5325
5390
  url: targetUrl,
5326
5391
  types: Object.keys(fields ?? DEFAULT_SHARE_FIELDS),
5327
- sharedNotes
5392
+ sharedNotes,
5393
+ editableFields,
5394
+ stageTransitions
5328
5395
  }))
5329
5396
  });
5330
5397
  };
@@ -5442,19 +5509,20 @@ var handleAdminNetworkPush = async (req, url, env, ctx) => {
5442
5509
  return json({ ok: results.every((result) => result.ok), results });
5443
5510
  };
5444
5511
 
5445
- // src/worker-routes-network-records.ts
5512
+ // src/worker-routes-network-records-follower.ts
5446
5513
  var import_crm7 = require("@odla-ai/crm");
5447
5514
  var import_db8 = require("@odla-ai/db");
5515
+
5516
+ // src/worker-routes-network-records-common.ts
5448
5517
  var IDENTIFIER = /^[a-z][a-zA-Z0-9_]*$/;
5449
5518
  var MAX_PAGE = 50;
5450
5519
  var MAX_OFFSET = 1e4;
5451
5520
  var MAX_SEARCH = 200;
5452
- async function gate6(req, env, ctx) {
5453
- const db = ctx.makeDb(env);
5454
- const user = await ctx.verifyUser(req, env);
5455
- if (!user) return { response: json({ error: "unauthorized" }, 401) };
5456
- if (!await ctx.isAdmin(db, user)) return { response: json({ error: "forbidden" }, 403) };
5457
- return { db, user };
5521
+ var MAX_BODY = 32768;
5522
+ var RECORD_ID = /^[a-zA-Z0-9][a-zA-Z0-9:_-]{0,191}$/;
5523
+ var MUTATION_ID = /^[a-zA-Z0-9][a-zA-Z0-9:._-]{7,127}$/;
5524
+ function bodyTooLarge(body) {
5525
+ return new TextEncoder().encode(body).byteLength > MAX_BODY;
5458
5526
  }
5459
5527
  function integer(value, fallback, min, max) {
5460
5528
  if (value == null || value === "") return fallback;
@@ -5462,10 +5530,22 @@ function integer(value, fallback, min, max) {
5462
5530
  const parsed = Number(value);
5463
5531
  return Number.isSafeInteger(parsed) && parsed >= min && parsed <= max ? parsed : null;
5464
5532
  }
5465
- function projectRecord(record4, allowed2) {
5533
+ function projectRecord(record4, allowed2, editable, stagesEnabled, crm) {
5466
5534
  const fields = Object.fromEntries(
5467
5535
  allowed2.flatMap((name) => Object.prototype.hasOwnProperty.call(record4.fields, name) ? [[name, record4.fields[name]]] : [])
5468
5536
  );
5537
+ const def = crm.type(record4.type);
5538
+ const editFields = editable.map((id) => {
5539
+ const field = def.fields[id];
5540
+ return {
5541
+ id,
5542
+ label: field.label ?? id,
5543
+ type: field.type,
5544
+ required: field.required ?? false,
5545
+ ...field.options ? { options: field.options } : {}
5546
+ };
5547
+ });
5548
+ const allowedStages = stagesEnabled ? (def.pipeline?.stages ?? []).flatMap((stage) => crm.canTransition(record4.type, record4, stage.id) ? [] : [{ id: stage.id, label: stage.label ?? stage.id }]) : [];
5469
5549
  return {
5470
5550
  id: record4.id,
5471
5551
  type: record4.type,
@@ -5473,11 +5553,66 @@ function projectRecord(record4, allowed2) {
5473
5553
  ...record4.stage ? { stage: record4.stage } : {},
5474
5554
  fields,
5475
5555
  createdAt: record4.createdAt,
5476
- updatedAt: record4.updatedAt
5556
+ updatedAt: record4.updatedAt,
5557
+ revision: record4.updatedAt,
5558
+ ...editFields.length || stagesEnabled ? { edit: { fields: editFields, allowedStages } } : {}
5477
5559
  };
5478
5560
  }
5561
+ function readerRecord(record4, reader, crm) {
5562
+ return projectRecord(
5563
+ record4,
5564
+ reader.fields[record4.type] ?? [],
5565
+ reader.editableFields[record4.type] ?? [],
5566
+ reader.stageTransitions.includes(record4.type),
5567
+ crm
5568
+ );
5569
+ }
5570
+ function restrictedRecord(record4, target) {
5571
+ if (!record4.edit) return record4;
5572
+ const fields = record4.edit.fields.filter((field) => target.editableFields[record4.type]?.includes(field.id));
5573
+ const allowedStages = target.stageTransitions.includes(record4.type) ? record4.edit.allowedStages : [];
5574
+ const edit = fields.length || target.stageTransitions.includes(record4.type) ? { fields, allowedStages } : void 0;
5575
+ return { ...record4, ...edit ? { edit } : { edit: void 0 } };
5576
+ }
5577
+
5578
+ // src/worker-routes-network-records-follower.ts
5579
+ var NETWORK_MUTATION_META = "chapterNetworkMutation";
5580
+ function mutationFingerprint(input) {
5581
+ const fields = input.fields ? Object.fromEntries(Object.entries(input.fields).sort(([a], [b]) => a.localeCompare(b))) : void 0;
5582
+ return JSON.stringify({
5583
+ recordId: input.recordId,
5584
+ type: input.type,
5585
+ expectedRevision: input.expectedRevision,
5586
+ expectedStage: input.expectedStage,
5587
+ mutationId: input.mutationId,
5588
+ fields,
5589
+ stage: input.stage,
5590
+ actor: input.actor
5591
+ });
5592
+ }
5593
+ function mutationError(error) {
5594
+ if (error?.code === "transact_guard_failed") {
5595
+ return json({ error: "record changed since it was reviewed", code: "stale_revision" }, 409);
5596
+ }
5597
+ if (error instanceof import_crm7.CrmInputError) {
5598
+ return json({ error: error.message, code: "invalid_transition_or_fields", ...error.fields ? { fields: error.fields } : {} }, 422);
5599
+ }
5600
+ return json({ error: "follower mutation failed", code: "mutation_failed" }, 500);
5601
+ }
5602
+ async function priorMutation(db, id) {
5603
+ const result = await db.query({
5604
+ [import_crm7.CRM_NS.activity]: { $: { where: { id }, limit: 1 } }
5605
+ });
5606
+ const row = (result[import_crm7.CRM_NS.activity] ?? [])[0];
5607
+ const meta = row?.meta;
5608
+ const receipt = meta?.[NETWORK_MUTATION_META];
5609
+ return typeof receipt?.fingerprint === "string" ? { fingerprint: receipt.fingerprint } : null;
5610
+ }
5479
5611
  var handleNetworkRecords = async (req, url, env, ctx) => {
5480
- if (req.method !== "GET" || url.pathname !== "/api/network/records") return null;
5612
+ if (url.pathname !== "/api/network/records" || req.method !== "GET" && req.method !== "PATCH") return null;
5613
+ if (req.method === "PATCH" && Number(req.headers.get("content-length") ?? 0) > MAX_BODY) {
5614
+ return json({ error: "mutation body is too large", code: "body_too_large" }, 413);
5615
+ }
5481
5616
  const db = ctx.makeDb(env);
5482
5617
  const secret = await getVaultSecret(db, "network_share_secret");
5483
5618
  const readers = ctx.chapter.network.readers;
@@ -5488,8 +5623,105 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
5488
5623
  });
5489
5624
  if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
5490
5625
  const reader = readers.find((candidate) => candidate.id === verified.sender);
5626
+ if (!reader) return json({ error: "unauthorized" }, 401);
5627
+ if (req.method === "PATCH") {
5628
+ if (bodyTooLarge(verified.body)) {
5629
+ return json({ error: "mutation body is too large", code: "body_too_large" }, 413);
5630
+ }
5631
+ let input;
5632
+ try {
5633
+ input = JSON.parse(verified.body);
5634
+ } catch {
5635
+ return json({ error: "invalid JSON body", code: "invalid_json" }, 422);
5636
+ }
5637
+ const fields = input?.fields;
5638
+ const hasFields = Boolean(fields && typeof fields === "object" && !Array.isArray(fields));
5639
+ const hasStage = typeof input?.stage === "string";
5640
+ const actor = input?.actor;
5641
+ 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) {
5642
+ return json({ error: "invalid network mutation", code: "invalid_mutation" }, 422);
5643
+ }
5644
+ const editable = reader.editableFields[input.type];
5645
+ if (hasFields) {
5646
+ const names = Object.keys(fields);
5647
+ if (!editable || names.length === 0 || names.length > 32 || names.some((name) => !editable.includes(name))) {
5648
+ return json({ error: "profile fields are not delegated for this reader", code: "field_forbidden" }, 403);
5649
+ }
5650
+ } else if (!reader.stageTransitions.includes(input.type)) {
5651
+ return json({ error: "stage transitions are not delegated for this reader", code: "stage_forbidden" }, 403);
5652
+ }
5653
+ const activityId = `network-edit:${verified.sender}:${input.mutationId}`;
5654
+ const fingerprint = mutationFingerprint(input);
5655
+ const prior = await priorMutation(db, activityId);
5656
+ if (prior) {
5657
+ if (prior.fingerprint !== fingerprint) {
5658
+ return json({ error: "mutationId was already used for different input", code: "mutation_conflict" }, 409);
5659
+ }
5660
+ const replayed = await (0, import_crm7.getRecord)({ crm: ctx.chapter.crm, db }, input.recordId);
5661
+ if (!replayed || replayed.type !== input.type) return json({ error: "record not found", code: "not_found" }, 404);
5662
+ const result = {
5663
+ version: 1,
5664
+ site: { id: ctx.chapter.id, name: ctx.chapter.name },
5665
+ record: readerRecord(replayed, reader, ctx.chapter.crm),
5666
+ duplicate: true
5667
+ };
5668
+ return json(result);
5669
+ }
5670
+ const record4 = await (0, import_crm7.getRecord)({ crm: ctx.chapter.crm, db }, input.recordId);
5671
+ if (!record4 || record4.type !== input.type) return json({ error: "record not found", code: "not_found" }, 404);
5672
+ if (record4.updatedAt !== input.expectedRevision) {
5673
+ return json({ error: "record changed since it was reviewed", code: "stale_revision" }, 409);
5674
+ }
5675
+ if (hasStage && (input.expectedStage ?? null) !== (record4.stage ?? null)) {
5676
+ return json({ error: "stage changed since it was reviewed", code: "stale_stage" }, 409);
5677
+ }
5678
+ const actorEmail = typeof actor.email === "string" && actor.email.length <= 320 ? actor.email : void 0;
5679
+ const auditMeta = {
5680
+ [NETWORK_MUTATION_META]: {
5681
+ version: 1,
5682
+ sourceId: verified.sender,
5683
+ actorId: actor.id,
5684
+ ...actorEmail ? { actorEmail } : {},
5685
+ mutationId: input.mutationId,
5686
+ fingerprint
5687
+ }
5688
+ };
5689
+ try {
5690
+ const deps = { crm: ctx.chapter.crm, db };
5691
+ const updated = hasFields ? await (0, import_crm7.updateRecord)(deps, {
5692
+ id: record4.id,
5693
+ input: fields,
5694
+ mutationId: activityId,
5695
+ expectedUpdatedAt: input.expectedRevision,
5696
+ audit: {
5697
+ id: activityId,
5698
+ body: `Profile edited remotely by ${verified.sender}`,
5699
+ authorId: `network:${verified.sender}`,
5700
+ meta: auditMeta
5701
+ }
5702
+ }) : await (0, import_crm7.setStage)(deps, {
5703
+ id: record4.id,
5704
+ to: input.stage,
5705
+ authorId: `network:${verified.sender}`,
5706
+ mutationId: activityId,
5707
+ expectedUpdatedAt: input.expectedRevision,
5708
+ expectedStage: input.expectedStage ?? null,
5709
+ activityId,
5710
+ activityMeta: auditMeta
5711
+ });
5712
+ const result = {
5713
+ version: 1,
5714
+ site: { id: ctx.chapter.id, name: ctx.chapter.name },
5715
+ record: readerRecord(updated.record, reader, ctx.chapter.crm),
5716
+ duplicate: updated.duplicate
5717
+ };
5718
+ return json(result);
5719
+ } catch (error) {
5720
+ return mutationError(error);
5721
+ }
5722
+ }
5491
5723
  const type = url.searchParams.get("type") ?? "";
5492
- const allowed2 = reader?.fields[type];
5724
+ const allowed2 = reader.fields[type];
5493
5725
  if (!IDENTIFIER.test(type) || !allowed2) return json({ error: "record type is not shared with this reader" }, 403);
5494
5726
  const limit = integer(url.searchParams.get("limit"), 25, 1, MAX_PAGE);
5495
5727
  const offset = integer(url.searchParams.get("offset"), 0, 0, MAX_OFFSET);
@@ -5511,13 +5743,23 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
5511
5743
  version: 1,
5512
5744
  site: { id: ctx.chapter.id, name: ctx.chapter.name },
5513
5745
  type,
5514
- records: page.records.map((record4) => projectRecord(record4, allowed2)),
5746
+ records: page.records.map((record4) => readerRecord(record4, reader, ctx.chapter.crm)),
5515
5747
  total: page.total,
5516
5748
  limit: page.limit,
5517
5749
  offset: page.offset
5518
5750
  };
5519
5751
  return json(response);
5520
5752
  };
5753
+
5754
+ // src/worker-routes-network-records-admin.ts
5755
+ var import_db9 = require("@odla-ai/db");
5756
+ async function gate6(req, env, ctx) {
5757
+ const db = ctx.makeDb(env);
5758
+ const user = await ctx.verifyUser(req, env);
5759
+ if (!user) return { response: json({ error: "unauthorized" }, 401) };
5760
+ if (!await ctx.isAdmin(db, user)) return { response: json({ error: "forbidden" }, 403) };
5761
+ return { db, user };
5762
+ }
5521
5763
  function targetFor(ctx, id) {
5522
5764
  return ctx.chapter.network.targets.find((target) => target.id === id);
5523
5765
  }
@@ -5527,12 +5769,7 @@ async function readTargetRecords(db, env, ctx, target, query) {
5527
5769
  const destination = new URL("/api/network/records", target.url);
5528
5770
  destination.search = query.toString();
5529
5771
  try {
5530
- const headers = await (0, import_db8.signFederatedRequest)({
5531
- secret,
5532
- sender: ctx.chapter.id,
5533
- method: "GET",
5534
- url: destination
5535
- });
5772
+ const headers = await (0, import_db9.signFederatedRequest)({ secret, sender: ctx.chapter.id, method: "GET", url: destination });
5536
5773
  const response = await fetchNetworkTarget(env, target, destination, {
5537
5774
  headers,
5538
5775
  signal: AbortSignal.timeout(1e4)
@@ -5545,15 +5782,90 @@ async function readTargetRecords(db, env, ctx, target, query) {
5545
5782
  error: upstream ?? (response.ok ? "invalid follower record response" : `follower returned ${response.status}`)
5546
5783
  };
5547
5784
  }
5548
- return { ok: true, page: body };
5785
+ return {
5786
+ ok: true,
5787
+ page: { ...body, records: body.records.map((record4) => restrictedRecord(record4, target)) }
5788
+ };
5549
5789
  } catch (error) {
5550
5790
  return { ok: false, error: error instanceof Error ? error.message : "record read failed" };
5551
5791
  }
5552
5792
  }
5793
+ async function mutateTargetRecord(db, env, ctx, target, input) {
5794
+ const fields = input.fields;
5795
+ if (fields) {
5796
+ const allowed2 = target.editableFields[input.type];
5797
+ if (!allowed2 || Object.keys(fields).some((name) => !allowed2.includes(name))) {
5798
+ return json({ error: "profile fields are not enabled for this target", code: "field_forbidden" }, 403);
5799
+ }
5800
+ } else if (!target.stageTransitions.includes(input.type)) {
5801
+ return json({ error: "stage transitions are not enabled for this target", code: "stage_forbidden" }, 403);
5802
+ }
5803
+ const secret = await getVaultSecret(db, target.secretName);
5804
+ if (!secret) return json({ error: "edge secret is missing", code: "edge_unavailable" }, 502);
5805
+ const destination = new URL("/api/network/records", target.url);
5806
+ const body = JSON.stringify(input);
5807
+ try {
5808
+ const headers = await (0, import_db9.signFederatedRequest)({
5809
+ secret,
5810
+ sender: ctx.chapter.id,
5811
+ method: "PATCH",
5812
+ url: destination,
5813
+ body
5814
+ });
5815
+ const response = await fetchNetworkTarget(env, target, destination, {
5816
+ method: "PATCH",
5817
+ headers: { ...headers, "content-type": "application/json" },
5818
+ body,
5819
+ signal: AbortSignal.timeout(1e4)
5820
+ });
5821
+ const payload = await response.json().catch(() => null);
5822
+ const site = payload && "site" in payload ? payload.site : void 0;
5823
+ if (!response.ok || !payload || !("record" in payload) || site && site.id !== target.id) {
5824
+ const status = [401, 403, 404, 409, 422].includes(response.status) ? response.status : 502;
5825
+ return json({
5826
+ error: payload && "error" in payload && typeof payload.error === "string" ? payload.error : `follower returned ${response.status}`,
5827
+ ...payload && "code" in payload && typeof payload.code === "string" ? { code: payload.code } : {}
5828
+ }, status);
5829
+ }
5830
+ return json({ ...payload, record: restrictedRecord(payload.record, target) });
5831
+ } catch {
5832
+ return json({ error: "follower mutation request failed", code: "follower_unavailable" }, 502);
5833
+ }
5834
+ }
5553
5835
  var handleAdminNetworkRecords = async (req, url, env, ctx) => {
5554
- if (req.method !== "GET" || url.pathname !== "/api/admin/network/records") return null;
5836
+ if (url.pathname !== "/api/admin/network/records" || req.method !== "GET" && req.method !== "PATCH") return null;
5837
+ if (req.method === "PATCH" && Number(req.headers.get("content-length") ?? 0) > MAX_BODY) {
5838
+ return json({ error: "mutation body is too large", code: "body_too_large" }, 413);
5839
+ }
5555
5840
  const got = await gate6(req, env, ctx);
5556
5841
  if ("response" in got) return got.response;
5842
+ if (req.method === "PATCH") {
5843
+ let input;
5844
+ try {
5845
+ const body = await req.text();
5846
+ if (bodyTooLarge(body)) {
5847
+ return json({ error: "mutation body is too large", code: "body_too_large" }, 413);
5848
+ }
5849
+ input = JSON.parse(body);
5850
+ } catch {
5851
+ return json({ error: "invalid JSON body", code: "invalid_json" }, 422);
5852
+ }
5853
+ const target2 = targetFor(ctx, input?.targetId);
5854
+ const hasFields = Boolean(input?.fields && typeof input.fields === "object" && !Array.isArray(input.fields));
5855
+ const hasStage = typeof input?.stage === "string";
5856
+ 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) {
5857
+ return json({ error: "invalid network mutation", code: "invalid_mutation" }, 422);
5858
+ }
5859
+ return mutateTargetRecord(got.db, env, ctx, target2, {
5860
+ type: input.type,
5861
+ recordId: input.recordId,
5862
+ expectedRevision: input.expectedRevision,
5863
+ mutationId: input.mutationId,
5864
+ ...input.expectedStage !== void 0 ? { expectedStage: input.expectedStage } : {},
5865
+ ...hasFields ? { fields: input.fields } : { stage: input.stage },
5866
+ actor: { id: got.user.userId, ...got.user.email ? { email: got.user.email } : {} }
5867
+ });
5868
+ }
5557
5869
  const targetId = url.searchParams.get("targetId") ?? "";
5558
5870
  const type = url.searchParams.get("type") ?? "";
5559
5871
  const target = targetFor(ctx, targetId);
@@ -5566,19 +5878,13 @@ var handleAdminNetworkRecords = async (req, url, env, ctx) => {
5566
5878
  }
5567
5879
  const query = new URLSearchParams({ type, limit: String(limit), offset: String(offset) });
5568
5880
  if (search) query.set("search", search);
5569
- const result = await readTargetRecords(
5570
- got.db,
5571
- env,
5572
- ctx,
5573
- target,
5574
- query
5575
- );
5881
+ const result = await readTargetRecords(got.db, env, ctx, target, query);
5576
5882
  return result.ok ? json(result.page) : json({ error: result.error }, 502);
5577
5883
  };
5578
5884
 
5579
5885
  // src/worker-routes-network-notes.ts
5580
5886
  var IDENTIFIER2 = /^[a-z][a-zA-Z0-9_]*$/;
5581
- var RECORD_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
5887
+ var RECORD_ID2 = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
5582
5888
  var MAX_NOTE2 = 4e3;
5583
5889
  async function gate7(req, env, ctx) {
5584
5890
  const db = ctx.makeDb(env);
@@ -5592,7 +5898,7 @@ function noteIdentity(url, ctx) {
5592
5898
  const recordType = url.searchParams.get("type") ?? "";
5593
5899
  const recordId = url.searchParams.get("recordId") ?? "";
5594
5900
  const configured = ctx.chapter.network.targets.some((target) => target.id === targetId);
5595
- if (!configured || !IDENTIFIER2.test(recordType) || !RECORD_ID.test(recordId)) return null;
5901
+ if (!configured || !IDENTIFIER2.test(recordType) || !RECORD_ID2.test(recordId)) return null;
5596
5902
  return {
5597
5903
  targetId,
5598
5904
  recordType,
@@ -5662,10 +5968,10 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
5662
5968
 
5663
5969
  // src/worker-routes-network-shared-notes.ts
5664
5970
  var import_crm8 = require("@odla-ai/crm");
5665
- var import_db9 = require("@odla-ai/db");
5971
+ var import_db10 = require("@odla-ai/db");
5666
5972
  var IDENTIFIER3 = /^[a-z][a-zA-Z0-9_]*$/;
5667
- var RECORD_ID2 = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
5668
- var MUTATION_ID = /^[A-Za-z0-9_-]{16,128}$/;
5973
+ var RECORD_ID3 = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
5974
+ var MUTATION_ID2 = /^[A-Za-z0-9_-]{16,128}$/;
5669
5975
  var MAX_NOTE3 = 4e3;
5670
5976
  var NETWORK_META = "chapterNetwork";
5671
5977
  async function gate8(req, env, ctx) {
@@ -5721,7 +6027,7 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
5721
6027
  const secret = await getVaultSecret(db, "network_share_secret");
5722
6028
  const readers = ctx.chapter.network.readers;
5723
6029
  if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
5724
- const verified = await (0, import_db9.verifyFederatedRequest)(req, {
6030
+ const verified = await (0, import_db10.verifyFederatedRequest)(req, {
5725
6031
  secret,
5726
6032
  senders: readers.map((reader2) => reader2.id)
5727
6033
  });
@@ -5729,7 +6035,7 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
5729
6035
  const reader = readers.find((candidate) => candidate.id === verified.sender);
5730
6036
  const recordType = url.searchParams.get("type") ?? "";
5731
6037
  const recordId = url.searchParams.get("recordId") ?? "";
5732
- if (!IDENTIFIER3.test(recordType) || !RECORD_ID2.test(recordId) || !reader?.sharedNotes.includes(recordType)) {
6038
+ if (!IDENTIFIER3.test(recordType) || !RECORD_ID3.test(recordId) || !reader?.sharedNotes.includes(recordType)) {
5733
6039
  return json({ error: "shared notes are not enabled for this reader and record type" }, 403);
5734
6040
  }
5735
6041
  const record4 = await (0, import_crm8.getRecord)({ crm: ctx.chapter.crm, db }, recordId);
@@ -5749,7 +6055,7 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
5749
6055
  }
5750
6056
  const body = typeof input.body === "string" ? input.body.trim() : "";
5751
6057
  const mutationId = typeof input.mutationId === "string" ? input.mutationId : "";
5752
- if (!body || body.length > MAX_NOTE3 || !MUTATION_ID.test(mutationId)) {
6058
+ if (!body || body.length > MAX_NOTE3 || !MUTATION_ID2.test(mutationId)) {
5753
6059
  return json({ error: `body must be 1\u2013${MAX_NOTE3} characters and mutationId must be valid` }, 400);
5754
6060
  }
5755
6061
  const createdAt = Date.now();
@@ -5798,7 +6104,7 @@ var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
5798
6104
  const recordType = url.searchParams.get("type") ?? "";
5799
6105
  const recordId = url.searchParams.get("recordId") ?? "";
5800
6106
  const target = targetFor2(ctx, targetId);
5801
- if (!target || !target.sharedNotes.includes(recordType) || !IDENTIFIER3.test(recordType) || !RECORD_ID2.test(recordId)) {
6107
+ if (!target || !target.sharedNotes.includes(recordType) || !IDENTIFIER3.test(recordType) || !RECORD_ID3.test(recordId)) {
5802
6108
  return json({ error: "configured targetId, type, and recordId are required" }, 400);
5803
6109
  }
5804
6110
  let body = "";
@@ -5818,7 +6124,7 @@ var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
5818
6124
  const destination = new URL("/api/network/shared-notes", target.url);
5819
6125
  destination.search = new URLSearchParams({ type: recordType, recordId }).toString();
5820
6126
  try {
5821
- const headers = await (0, import_db9.signFederatedRequest)({
6127
+ const headers = await (0, import_db10.signFederatedRequest)({
5822
6128
  secret,
5823
6129
  sender: ctx.chapter.id,
5824
6130
  method: req.method,
@@ -5925,7 +6231,7 @@ var handleFormation = async (req, url, env, ctx) => {
5925
6231
  };
5926
6232
 
5927
6233
  // src/worker-routes-builder-library.ts
5928
- var import_db10 = require("@odla-ai/db");
6234
+ var import_db11 = require("@odla-ai/db");
5929
6235
 
5930
6236
  // src/builder-library-validation.ts
5931
6237
  var ID4 = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -6208,7 +6514,7 @@ async function verifySource(req, db, ctx) {
6208
6514
  if (ctx.chapter.mode === "chapter") {
6209
6515
  const secret = await getVaultSecret(db, "network_share_secret");
6210
6516
  if (!secret) return null;
6211
- const verified = await (0, import_db10.verifyFederatedRequest)(req, {
6517
+ const verified = await (0, import_db11.verifyFederatedRequest)(req, {
6212
6518
  secret,
6213
6519
  senders: sources.map((source2) => source2.sourceId)
6214
6520
  });
@@ -6221,7 +6527,7 @@ async function verifySource(req, db, ctx) {
6221
6527
  if (!target) continue;
6222
6528
  const secret = await getVaultSecret(db, target.secretName);
6223
6529
  if (!secret) continue;
6224
- const verified = await (0, import_db10.verifyFederatedRequest)(req.clone(), { secret, senders: [source.sourceId] });
6530
+ const verified = await (0, import_db11.verifyFederatedRequest)(req.clone(), { secret, senders: [source.sourceId] });
6225
6531
  if (verified.ok) return { source, body: verified.body };
6226
6532
  }
6227
6533
  return null;
@@ -6410,11 +6716,11 @@ var handleAdminBuilderLibrary = async (req, url, env, ctx) => {
6410
6716
  };
6411
6717
 
6412
6718
  // src/signup-control-delivery.ts
6413
- var import_db11 = require("@odla-ai/db");
6719
+ var import_db12 = require("@odla-ai/db");
6414
6720
  async function deliverSignupControl(options) {
6415
6721
  const destination = new URL(SIGNUP_CONTROL_PATH, options.targetUrl);
6416
6722
  const body = JSON.stringify(options.envelope);
6417
- const signed = await (0, import_db11.signFederatedRequest)({
6723
+ const signed = await (0, import_db12.signFederatedRequest)({
6418
6724
  secret: options.secret,
6419
6725
  sender: options.sender,
6420
6726
  method: "POST",
@@ -6435,11 +6741,11 @@ async function deliverSignupControl(options) {
6435
6741
  }
6436
6742
 
6437
6743
  // src/member-content-delivery.ts
6438
- var import_db12 = require("@odla-ai/db");
6744
+ var import_db13 = require("@odla-ai/db");
6439
6745
  async function deliverMemberContent(options) {
6440
6746
  const destination = new URL(MEMBER_CONTENT_CONTROL_PATH, options.targetUrl);
6441
6747
  const body = JSON.stringify(options.envelope);
6442
- const signed = await (0, import_db12.signFederatedRequest)({
6748
+ const signed = await (0, import_db13.signFederatedRequest)({
6443
6749
  secret: options.secret,
6444
6750
  sender: options.sender,
6445
6751
  method: "POST",
@@ -6460,11 +6766,11 @@ async function deliverMemberContent(options) {
6460
6766
  }
6461
6767
 
6462
6768
  // src/builder-library-delivery.ts
6463
- var import_db13 = require("@odla-ai/db");
6769
+ var import_db14 = require("@odla-ai/db");
6464
6770
  async function deliverBuilderLibrary(options) {
6465
6771
  const destination = new URL(BUILDER_LIBRARY_CONTROL_PATH, options.targetUrl);
6466
6772
  const body = JSON.stringify(options.envelope);
6467
- const signed = await (0, import_db13.signFederatedRequest)({ secret: options.secret, sender: options.sender, method: "POST", url: destination, body });
6773
+ const signed = await (0, import_db14.signFederatedRequest)({ secret: options.secret, sender: options.sender, method: "POST", url: destination, body });
6468
6774
  const response = await (options.fetch ?? globalThis.fetch)(destination, { method: "POST", headers: { ...signed, "content-type": "application/json" }, body });
6469
6775
  const result = await response.json();
6470
6776
  if (!response.ok || !("ok" in result) || result.ok !== true) {
@@ -6475,11 +6781,11 @@ async function deliverBuilderLibrary(options) {
6475
6781
  }
6476
6782
 
6477
6783
  // src/admission-grants-delivery.ts
6478
- var import_db14 = require("@odla-ai/db");
6784
+ var import_db15 = require("@odla-ai/db");
6479
6785
  async function deliverAdmissionGrants(options) {
6480
6786
  const destination = new URL(ADMISSION_GRANTS_PATH, options.targetUrl);
6481
6787
  const body = JSON.stringify(options.envelope);
6482
- const signed = await (0, import_db14.signFederatedRequest)({
6788
+ const signed = await (0, import_db15.signFederatedRequest)({
6483
6789
  secret: options.secret,
6484
6790
  sender: options.sender,
6485
6791
  method: "POST",