@odla-ai/chapter 0.26.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1901,16 +1901,16 @@ var crmDeps2 = (db, ctx) => ({
1901
1901
  });
1902
1902
  var handleAdminCrmSync = async (req, url, env, ctx) => {
1903
1903
  if (req.method !== "POST" || url.pathname !== "/api/admin/crm/sync") return null;
1904
- const gate5 = await adminGate(req, env, ctx);
1905
- if (gate5 instanceof Response) return gate5;
1906
- const result = await backfillCrm(crmDeps2(gate5.db, ctx));
1904
+ const gate8 = await adminGate(req, env, ctx);
1905
+ if (gate8 instanceof Response) return gate8;
1906
+ const result = await backfillCrm(crmDeps2(gate8.db, ctx));
1907
1907
  return json({ ok: true, ...result });
1908
1908
  };
1909
1909
  var handleAdminPeople = async (req, url, env, ctx) => {
1910
1910
  if (req.method !== "GET" || url.pathname !== "/api/admin/people") return null;
1911
- const gate5 = await adminGate(req, env, ctx);
1912
- if (gate5 instanceof Response) return gate5;
1913
- const { db } = gate5;
1911
+ const gate8 = await adminGate(req, env, ctx);
1912
+ if (gate8 instanceof Response) return gate8;
1913
+ const { db } = gate8;
1914
1914
  const sk = await getVaultSecret(db, "clerk_secret_key");
1915
1915
  const [appsRes, usersRes, roleList] = await Promise.all([
1916
1916
  db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 200 } } }),
@@ -1961,9 +1961,9 @@ var handleAdminPeople = async (req, url, env, ctx) => {
1961
1961
  };
1962
1962
  var handleAdminPeopleAccess = async (req, url, env, ctx) => {
1963
1963
  if (req.method !== "GET" || url.pathname !== "/api/admin/people/access") return null;
1964
- const gate5 = await adminGate(req, env, ctx);
1965
- if (gate5 instanceof Response) return gate5;
1966
- const { db } = gate5;
1964
+ const gate8 = await adminGate(req, env, ctx);
1965
+ if (gate8 instanceof Response) return gate8;
1966
+ const { db } = gate8;
1967
1967
  const targetId = url.searchParams.get("userId") ?? "";
1968
1968
  if (!targetId.startsWith("user_")) return json({ error: "invalid userId" }, 400);
1969
1969
  const sk = await getVaultSecret(db, "clerk_secret_key");
@@ -1974,9 +1974,9 @@ var handleAdminPeopleAccess = async (req, url, env, ctx) => {
1974
1974
  };
1975
1975
  var handleAdminPeopleRole = async (req, url, env, ctx) => {
1976
1976
  if (req.method !== "POST" || url.pathname !== "/api/admin/people/role") return null;
1977
- const gate5 = await adminGate(req, env, ctx);
1978
- if (gate5 instanceof Response) return gate5;
1979
- const { db, actor } = gate5;
1977
+ const gate8 = await adminGate(req, env, ctx);
1978
+ if (gate8 instanceof Response) return gate8;
1979
+ const { db, actor } = gate8;
1980
1980
  let body;
1981
1981
  try {
1982
1982
  body = await req.json();
@@ -2615,8 +2615,118 @@ var handleAdminComms = async (req, url, env, ctx) => {
2615
2615
  };
2616
2616
 
2617
2617
  // src/worker-routes-network.ts
2618
- import { getRecord, recordDeliveryAttempt, typeSummary } from "@odla-ai/crm";
2619
- import { signFederatedRequest, verifyFederatedRequest as verifyFederatedRequest2 } from "@odla-ai/db";
2618
+ import { getRecord, typeSummary } from "@odla-ai/crm";
2619
+ import { signFederatedRequest as signFederatedRequest2, verifyFederatedRequest as verifyFederatedRequest2 } from "@odla-ai/db";
2620
+
2621
+ // src/worker-network-fetch.ts
2622
+ function targetFetcher(env, target) {
2623
+ if (!target.binding) return null;
2624
+ const candidate = env[target.binding];
2625
+ if (typeof candidate !== "object" || candidate === null || !("fetch" in candidate) || typeof candidate.fetch !== "function") {
2626
+ throw new Error(`service binding "${target.binding}" is unavailable`);
2627
+ }
2628
+ return candidate;
2629
+ }
2630
+ function fetchNetworkTarget(env, target, destination, init) {
2631
+ const binding = targetFetcher(env, target);
2632
+ return binding ? binding.fetch(new Request(destination, init)) : globalThis.fetch(destination, init);
2633
+ }
2634
+
2635
+ // src/worker-network-push.ts
2636
+ import { recordDeliveryAttempt } from "@odla-ai/crm";
2637
+ import { signFederatedRequest } from "@odla-ai/db";
2638
+ async function pushNetworkRecord(db, env, ctx, target, record) {
2639
+ const secret = await getVaultSecret(db, target.secretName);
2640
+ const crmDeps3 = {
2641
+ crm: ctx.chapter.crm,
2642
+ db,
2643
+ now: () => Date.now(),
2644
+ newId: () => crypto.randomUUID()
2645
+ };
2646
+ if (!secret) {
2647
+ const error = `vault secret "${target.secretName}" is missing`;
2648
+ await recordDeliveryAttempt(crmDeps3, {
2649
+ recordId: record.id,
2650
+ targetId: target.id,
2651
+ status: "failed",
2652
+ payloadVersion: 2,
2653
+ error
2654
+ });
2655
+ return { id: target.id, name: target.name, ok: false, error };
2656
+ }
2657
+ let payload;
2658
+ try {
2659
+ payload = sharedRecordFromCrm(ctx.chapter.crm, record, target, ctx.chapter.id);
2660
+ } catch (err) {
2661
+ return {
2662
+ id: target.id,
2663
+ name: target.name,
2664
+ ok: false,
2665
+ error: err instanceof Error ? err.message : "record is not shareable"
2666
+ };
2667
+ }
2668
+ const payloadBody = JSON.stringify(payload);
2669
+ const destination = new URL("/api/network/shared", target.url);
2670
+ try {
2671
+ const signed = await signFederatedRequest({
2672
+ secret,
2673
+ sender: ctx.chapter.id,
2674
+ method: "POST",
2675
+ url: destination,
2676
+ body: payloadBody
2677
+ });
2678
+ const res = await fetchNetworkTarget(env, target, destination, {
2679
+ method: "POST",
2680
+ headers: { ...signed, "content-type": "application/json" },
2681
+ body: payloadBody,
2682
+ signal: AbortSignal.timeout(1e4)
2683
+ });
2684
+ const responseBody = await res.json().catch(() => ({}));
2685
+ if (!res.ok) {
2686
+ const error = responseBody.error ?? "follower rejected the record";
2687
+ await recordDeliveryAttempt(crmDeps3, {
2688
+ recordId: record.id,
2689
+ targetId: target.id,
2690
+ status: "failed",
2691
+ payloadVersion: 2,
2692
+ error
2693
+ });
2694
+ return {
2695
+ id: target.id,
2696
+ name: target.name,
2697
+ ok: false,
2698
+ status: res.status,
2699
+ error
2700
+ };
2701
+ }
2702
+ await recordDeliveryAttempt(crmDeps3, {
2703
+ recordId: record.id,
2704
+ targetId: target.id,
2705
+ status: "delivered",
2706
+ payloadVersion: 2,
2707
+ ...responseBody.recordId ? { remoteRecordId: responseBody.recordId } : {}
2708
+ });
2709
+ return {
2710
+ id: target.id,
2711
+ name: target.name,
2712
+ ok: true,
2713
+ status: res.status,
2714
+ recordId: responseBody.recordId
2715
+ };
2716
+ } catch (err) {
2717
+ const error = err instanceof Error ? err.message : "delivery failed";
2718
+ await recordDeliveryAttempt(crmDeps3, {
2719
+ recordId: record.id,
2720
+ targetId: target.id,
2721
+ status: "failed",
2722
+ payloadVersion: 2,
2723
+ error
2724
+ });
2725
+ return { id: target.id, name: target.name, ok: false, error };
2726
+ }
2727
+ }
2728
+
2729
+ // src/worker-routes-network.ts
2620
2730
  async function gate4(req, env, ctx) {
2621
2731
  const db = ctx.makeDb(env);
2622
2732
  const user = await ctx.verifyUser(req, env);
@@ -2629,11 +2739,12 @@ var handleAdminNetworkTargets = async (req, url, env, ctx) => {
2629
2739
  const got = await gate4(req, env, ctx);
2630
2740
  if ("response" in got) return got.response;
2631
2741
  return json({
2632
- targets: ctx.chapter.network.targets.map(({ id, name, url: targetUrl, fields }) => ({
2742
+ targets: ctx.chapter.network.targets.map(({ id, name, url: targetUrl, fields, sharedNotes }) => ({
2633
2743
  id,
2634
2744
  name,
2635
2745
  url: targetUrl,
2636
- types: Object.keys(fields ?? DEFAULT_SHARE_FIELDS)
2746
+ types: Object.keys(fields ?? DEFAULT_SHARE_FIELDS),
2747
+ sharedNotes
2637
2748
  }))
2638
2749
  });
2639
2750
  };
@@ -2655,18 +2766,21 @@ var handleNetworkSnapshot = async (req, url, env, ctx) => {
2655
2766
  };
2656
2767
  return json(snapshot);
2657
2768
  };
2658
- async function snapshotOne(db, ctx, target) {
2769
+ async function snapshotOne(db, env, ctx, target) {
2659
2770
  const secret = await getVaultSecret(db, target.secretName);
2660
2771
  if (!secret) return { id: target.id, name: target.name, url: target.url, available: false, error: "edge secret is missing" };
2661
2772
  const destination = new URL("/api/network/snapshot", target.url);
2662
2773
  try {
2663
- const headers = await signFederatedRequest({
2774
+ const headers = await signFederatedRequest2({
2664
2775
  secret,
2665
2776
  sender: ctx.chapter.id,
2666
2777
  method: "GET",
2667
2778
  url: destination
2668
2779
  });
2669
- const response = await fetch(destination, { headers, signal: AbortSignal.timeout(1e4) });
2780
+ const response = await fetchNetworkTarget(env, target, destination, {
2781
+ headers,
2782
+ signal: AbortSignal.timeout(1e4)
2783
+ });
2670
2784
  const body = await response.json().catch(() => null);
2671
2785
  if (!response.ok || !body || !("version" in body) || body.version !== 1 || !("site" in body) || body.site.id !== target.id || !Array.isArray(body.types)) {
2672
2786
  const upstream = body && "error" in body && typeof body.error === "string" ? body.error : void 0;
@@ -2701,7 +2815,7 @@ var handleAdminNetworkRollup = async (req, url, env, ctx) => {
2701
2815
  const got = await gate4(req, env, ctx);
2702
2816
  if ("response" in got) return got.response;
2703
2817
  const targets = await Promise.all(
2704
- ctx.chapter.network.targets.map((target) => snapshotOne(got.db, ctx, target))
2818
+ ctx.chapter.network.targets.map((target) => snapshotOne(got.db, env, ctx, target))
2705
2819
  );
2706
2820
  const byType = /* @__PURE__ */ new Map();
2707
2821
  for (const target of targets) {
@@ -2723,74 +2837,6 @@ var handleAdminNetworkRollup = async (req, url, env, ctx) => {
2723
2837
  };
2724
2838
  return json(rollup);
2725
2839
  };
2726
- async function pushOne(db, ctx, target, record) {
2727
- const secret = await getVaultSecret(db, target.secretName);
2728
- const crmDeps3 = { crm: ctx.chapter.crm, db, now: () => Date.now(), newId: () => crypto.randomUUID() };
2729
- if (!secret) {
2730
- const error = `vault secret "${target.secretName}" is missing`;
2731
- await recordDeliveryAttempt(crmDeps3, {
2732
- recordId: record.id,
2733
- targetId: target.id,
2734
- status: "failed",
2735
- payloadVersion: 2,
2736
- error
2737
- });
2738
- return { id: target.id, name: target.name, ok: false, error };
2739
- }
2740
- let payload;
2741
- try {
2742
- payload = sharedRecordFromCrm(ctx.chapter.crm, record, target, ctx.chapter.id);
2743
- } catch (err) {
2744
- return { id: target.id, name: target.name, ok: false, error: err instanceof Error ? err.message : "record is not shareable" };
2745
- }
2746
- const payloadBody = JSON.stringify(payload);
2747
- const destination = new URL("/api/network/shared", target.url);
2748
- try {
2749
- const signed = await signFederatedRequest({
2750
- secret,
2751
- sender: ctx.chapter.id,
2752
- method: "POST",
2753
- url: destination,
2754
- body: payloadBody
2755
- });
2756
- const res = await fetch(destination, {
2757
- method: "POST",
2758
- headers: { ...signed, "content-type": "application/json" },
2759
- body: payloadBody,
2760
- signal: AbortSignal.timeout(1e4)
2761
- });
2762
- const responseBody = await res.json().catch(() => ({}));
2763
- if (!res.ok) {
2764
- const error = responseBody.error ?? "follower rejected the record";
2765
- await recordDeliveryAttempt(crmDeps3, {
2766
- recordId: record.id,
2767
- targetId: target.id,
2768
- status: "failed",
2769
- payloadVersion: 2,
2770
- error
2771
- });
2772
- return { id: target.id, name: target.name, ok: false, status: res.status, error };
2773
- }
2774
- await recordDeliveryAttempt(crmDeps3, {
2775
- recordId: record.id,
2776
- targetId: target.id,
2777
- status: "delivered",
2778
- payloadVersion: 2,
2779
- ...responseBody.recordId ? { remoteRecordId: responseBody.recordId } : {}
2780
- });
2781
- return { id: target.id, name: target.name, ok: true, status: res.status, recordId: responseBody.recordId };
2782
- } catch (err) {
2783
- const error = err instanceof Error ? err.message : "delivery failed";
2784
- await recordDeliveryAttempt(crmDeps3, {
2785
- recordId: record.id,
2786
- targetId: target.id,
2787
- status: "failed",
2788
- payloadVersion: 2,
2789
- error
2790
- });
2791
- return { id: target.id, name: target.name, ok: false, error };
2792
- }
2793
- }
2794
2840
  var handleAdminNetworkPush = async (req, url, env, ctx) => {
2795
2841
  if (req.method !== "POST" || url.pathname !== "/api/admin/network/push") return null;
2796
2842
  const got = await gate4(req, env, ctx);
@@ -2811,11 +2857,417 @@ var handleAdminNetworkPush = async (req, url, env, ctx) => {
2811
2857
  const record = await getRecord({ crm: ctx.chapter.crm, db: got.db }, body.recordId);
2812
2858
  if (!record) return json({ error: "record not found" }, 404);
2813
2859
  const results = await Promise.all(
2814
- targets.map((target) => pushOne(got.db, ctx, target, record))
2860
+ targets.map((target) => pushNetworkRecord(got.db, env, ctx, target, record))
2815
2861
  );
2816
2862
  return json({ ok: results.every((result) => result.ok), results });
2817
2863
  };
2818
2864
 
2865
+ // src/worker-routes-network-records.ts
2866
+ import { listRecords } from "@odla-ai/crm";
2867
+ import { signFederatedRequest as signFederatedRequest3, verifyFederatedRequest as verifyFederatedRequest3 } from "@odla-ai/db";
2868
+ var IDENTIFIER = /^[a-z][a-zA-Z0-9_]*$/;
2869
+ var MAX_PAGE = 50;
2870
+ var MAX_OFFSET = 1e4;
2871
+ var MAX_SEARCH = 200;
2872
+ async function gate5(req, env, ctx) {
2873
+ const db = ctx.makeDb(env);
2874
+ const user = await ctx.verifyUser(req, env);
2875
+ if (!user) return { response: json({ error: "unauthorized" }, 401) };
2876
+ if (!await ctx.isAdmin(db, user)) return { response: json({ error: "forbidden" }, 403) };
2877
+ return { db, user };
2878
+ }
2879
+ function integer(value, fallback, min, max) {
2880
+ if (value == null || value === "") return fallback;
2881
+ if (!/^\d+$/.test(value)) return null;
2882
+ const parsed = Number(value);
2883
+ return Number.isSafeInteger(parsed) && parsed >= min && parsed <= max ? parsed : null;
2884
+ }
2885
+ function projectRecord(record, allowed) {
2886
+ const fields = Object.fromEntries(
2887
+ allowed.flatMap((name) => Object.prototype.hasOwnProperty.call(record.fields, name) ? [[name, record.fields[name]]] : [])
2888
+ );
2889
+ return {
2890
+ id: record.id,
2891
+ type: record.type,
2892
+ name: record.name,
2893
+ ...record.stage ? { stage: record.stage } : {},
2894
+ fields,
2895
+ createdAt: record.createdAt,
2896
+ updatedAt: record.updatedAt
2897
+ };
2898
+ }
2899
+ var handleNetworkRecords = async (req, url, env, ctx) => {
2900
+ if (req.method !== "GET" || url.pathname !== "/api/network/records") return null;
2901
+ const db = ctx.makeDb(env);
2902
+ const secret = await getVaultSecret(db, "network_share_secret");
2903
+ const readers = ctx.chapter.network.readers;
2904
+ if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
2905
+ const verified = await verifyFederatedRequest3(req, {
2906
+ secret,
2907
+ senders: readers.map((reader2) => reader2.id)
2908
+ });
2909
+ if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
2910
+ const reader = readers.find((candidate) => candidate.id === verified.sender);
2911
+ const type = url.searchParams.get("type") ?? "";
2912
+ const allowed = reader?.fields[type];
2913
+ if (!IDENTIFIER.test(type) || !allowed) return json({ error: "record type is not shared with this reader" }, 403);
2914
+ const limit = integer(url.searchParams.get("limit"), 25, 1, MAX_PAGE);
2915
+ const offset = integer(url.searchParams.get("offset"), 0, 0, MAX_OFFSET);
2916
+ const search = (url.searchParams.get("search") ?? "").trim();
2917
+ if (limit == null || offset == null || search.length > MAX_SEARCH) {
2918
+ return json({ error: "invalid pagination or search parameters" }, 400);
2919
+ }
2920
+ const page = await listRecords(
2921
+ { crm: ctx.chapter.crm, db },
2922
+ {
2923
+ type,
2924
+ limit,
2925
+ offset,
2926
+ ...search ? { search } : {},
2927
+ sort: { field: "updatedAt", dir: "desc" }
2928
+ }
2929
+ );
2930
+ const response = {
2931
+ version: 1,
2932
+ site: { id: ctx.chapter.id, name: ctx.chapter.name },
2933
+ type,
2934
+ records: page.records.map((record) => projectRecord(record, allowed)),
2935
+ total: page.total,
2936
+ limit: page.limit,
2937
+ offset: page.offset
2938
+ };
2939
+ return json(response);
2940
+ };
2941
+ function targetFor(ctx, id) {
2942
+ return ctx.chapter.network.targets.find((target) => target.id === id);
2943
+ }
2944
+ async function readTargetRecords(db, env, ctx, target, query) {
2945
+ const secret = await getVaultSecret(db, target.secretName);
2946
+ if (!secret) return { ok: false, error: "edge secret is missing" };
2947
+ const destination = new URL("/api/network/records", target.url);
2948
+ destination.search = query.toString();
2949
+ try {
2950
+ const headers = await signFederatedRequest3({
2951
+ secret,
2952
+ sender: ctx.chapter.id,
2953
+ method: "GET",
2954
+ url: destination
2955
+ });
2956
+ const response = await fetchNetworkTarget(env, target, destination, {
2957
+ headers,
2958
+ signal: AbortSignal.timeout(1e4)
2959
+ });
2960
+ const body = await response.json().catch(() => null);
2961
+ if (!response.ok || !body || !("version" in body) || body.version !== 1 || !("site" in body) || body.site.id !== target.id || !Array.isArray(body.records)) {
2962
+ const upstream = body && "error" in body && typeof body.error === "string" ? body.error : void 0;
2963
+ return {
2964
+ ok: false,
2965
+ error: upstream ?? (response.ok ? "invalid follower record response" : `follower returned ${response.status}`)
2966
+ };
2967
+ }
2968
+ return { ok: true, page: body };
2969
+ } catch (error) {
2970
+ return { ok: false, error: error instanceof Error ? error.message : "record read failed" };
2971
+ }
2972
+ }
2973
+ var handleAdminNetworkRecords = async (req, url, env, ctx) => {
2974
+ if (req.method !== "GET" || url.pathname !== "/api/admin/network/records") return null;
2975
+ const got = await gate5(req, env, ctx);
2976
+ if ("response" in got) return got.response;
2977
+ const targetId = url.searchParams.get("targetId") ?? "";
2978
+ const type = url.searchParams.get("type") ?? "";
2979
+ const target = targetFor(ctx, targetId);
2980
+ if (!target || !IDENTIFIER.test(type)) return json({ error: "configured targetId and record type are required" }, 400);
2981
+ const limit = integer(url.searchParams.get("limit"), 25, 1, MAX_PAGE);
2982
+ const offset = integer(url.searchParams.get("offset"), 0, 0, MAX_OFFSET);
2983
+ const search = (url.searchParams.get("search") ?? "").trim();
2984
+ if (limit == null || offset == null || search.length > MAX_SEARCH) {
2985
+ return json({ error: "invalid pagination or search parameters" }, 400);
2986
+ }
2987
+ const query = new URLSearchParams({ type, limit: String(limit), offset: String(offset) });
2988
+ if (search) query.set("search", search);
2989
+ const result = await readTargetRecords(
2990
+ got.db,
2991
+ env,
2992
+ ctx,
2993
+ target,
2994
+ query
2995
+ );
2996
+ return result.ok ? json(result.page) : json({ error: result.error }, 502);
2997
+ };
2998
+
2999
+ // src/worker-routes-network-notes.ts
3000
+ var IDENTIFIER2 = /^[a-z][a-zA-Z0-9_]*$/;
3001
+ var RECORD_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
3002
+ var MAX_NOTE = 4e3;
3003
+ async function gate6(req, env, ctx) {
3004
+ const db = ctx.makeDb(env);
3005
+ const user = await ctx.verifyUser(req, env);
3006
+ if (!user) return { response: json({ error: "unauthorized" }, 401) };
3007
+ if (!await ctx.isAdmin(db, user)) return { response: json({ error: "forbidden" }, 403) };
3008
+ return { db, user };
3009
+ }
3010
+ function noteIdentity(url, ctx) {
3011
+ const targetId = url.searchParams.get("targetId") ?? "";
3012
+ const recordType = url.searchParams.get("type") ?? "";
3013
+ const recordId = url.searchParams.get("recordId") ?? "";
3014
+ const configured = ctx.chapter.network.targets.some((target) => target.id === targetId);
3015
+ if (!configured || !IDENTIFIER2.test(recordType) || !RECORD_ID.test(recordId)) return null;
3016
+ return {
3017
+ targetId,
3018
+ recordType,
3019
+ recordId,
3020
+ recordKey: `${targetId}:${recordType}:${recordId}`
3021
+ };
3022
+ }
3023
+ function asNote(row) {
3024
+ return {
3025
+ id: String(row.id),
3026
+ targetId: String(row.targetId),
3027
+ recordType: String(row.recordType),
3028
+ recordId: String(row.recordId),
3029
+ body: String(row.body),
3030
+ authorId: String(row.authorId),
3031
+ ...typeof row.authorEmail === "string" ? { authorEmail: row.authorEmail } : {},
3032
+ createdAt: Number(row.createdAt)
3033
+ };
3034
+ }
3035
+ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
3036
+ if (url.pathname !== "/api/admin/network/notes" || req.method !== "GET" && req.method !== "POST") return null;
3037
+ const got = await gate6(req, env, ctx);
3038
+ if ("response" in got) return got.response;
3039
+ const identity = noteIdentity(url, ctx);
3040
+ if (!identity) return json({ error: "valid targetId, type, and recordId are required" }, 400);
3041
+ if (req.method === "GET") {
3042
+ const result = await got.db.query({
3043
+ networkNotes: {
3044
+ $: {
3045
+ where: { recordKey: identity.recordKey },
3046
+ order: { createdAt: "desc" },
3047
+ limit: 100
3048
+ }
3049
+ }
3050
+ });
3051
+ return json({ notes: (result.networkNotes ?? []).map((row) => asNote(row)) });
3052
+ }
3053
+ const contentLength = Number(req.headers.get("content-length") ?? 0);
3054
+ if (contentLength > MAX_NOTE * 2) return json({ error: "note is too large" }, 413);
3055
+ let input;
3056
+ try {
3057
+ input = await req.json();
3058
+ } catch {
3059
+ return json({ error: "invalid JSON body" }, 400);
3060
+ }
3061
+ const body = typeof input.body === "string" ? input.body.trim() : "";
3062
+ if (!body || body.length > MAX_NOTE) return json({ error: `note must be 1\u2013${MAX_NOTE} characters` }, 400);
3063
+ const id = crypto.randomUUID();
3064
+ const note = {
3065
+ id,
3066
+ targetId: identity.targetId,
3067
+ recordType: identity.recordType,
3068
+ recordId: identity.recordId,
3069
+ body,
3070
+ authorId: got.user.userId,
3071
+ ...got.user.email ? { authorEmail: got.user.email } : {},
3072
+ createdAt: Date.now()
3073
+ };
3074
+ await got.db.transact([{
3075
+ t: "update",
3076
+ ns: "networkNotes",
3077
+ id,
3078
+ attrs: { ...note, recordKey: identity.recordKey }
3079
+ }]);
3080
+ return json({ note }, 201);
3081
+ };
3082
+
3083
+ // src/worker-routes-network-shared-notes.ts
3084
+ import {
3085
+ addActivity,
3086
+ CRM_NS,
3087
+ getRecord as getRecord2
3088
+ } from "@odla-ai/crm";
3089
+ import { signFederatedRequest as signFederatedRequest4, verifyFederatedRequest as verifyFederatedRequest4 } from "@odla-ai/db";
3090
+ var IDENTIFIER3 = /^[a-z][a-zA-Z0-9_]*$/;
3091
+ var RECORD_ID2 = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
3092
+ var MUTATION_ID = /^[A-Za-z0-9_-]{16,128}$/;
3093
+ var MAX_NOTE2 = 4e3;
3094
+ var NETWORK_META = "chapterNetwork";
3095
+ async function gate7(req, env, ctx) {
3096
+ const db = ctx.makeDb(env);
3097
+ const user = await ctx.verifyUser(req, env);
3098
+ if (!user) return { response: json({ error: "unauthorized" }, 401) };
3099
+ if (!await ctx.isAdmin(db, user)) return { response: json({ error: "forbidden" }, 403) };
3100
+ return { db, user };
3101
+ }
3102
+ function sharedNote(activity, sourceId) {
3103
+ const meta = activity.meta?.[NETWORK_META];
3104
+ if (!meta || typeof meta !== "object" || Array.isArray(meta)) return null;
3105
+ const value = meta;
3106
+ if (value.version !== 1 || value.sourceId !== sourceId || typeof value.body !== "string") return null;
3107
+ return {
3108
+ id: activity.id,
3109
+ sourceId,
3110
+ recordType: String(value.recordType ?? ""),
3111
+ recordId: activity.recordId,
3112
+ body: value.body,
3113
+ createdAt: activity.createdAt
3114
+ };
3115
+ }
3116
+ async function listSharedNotes(db, sourceId, recordId) {
3117
+ const result = await db.query({
3118
+ [CRM_NS.activity]: {
3119
+ $: {
3120
+ where: {
3121
+ and: [
3122
+ { recordId },
3123
+ { authorId: `network:${sourceId}` }
3124
+ ]
3125
+ },
3126
+ order: { createdAt: "desc" },
3127
+ limit: 100
3128
+ }
3129
+ }
3130
+ });
3131
+ return (result[CRM_NS.activity] ?? []).flatMap((activity) => {
3132
+ const note = sharedNote(activity, sourceId);
3133
+ return note ? [note] : [];
3134
+ });
3135
+ }
3136
+ function sourceLabel(sourceId) {
3137
+ return sourceId.split("-").map((word) => word ? `${word[0]?.toUpperCase()}${word.slice(1)}` : word).join(" ");
3138
+ }
3139
+ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
3140
+ if (url.pathname !== "/api/network/shared-notes" || req.method !== "GET" && req.method !== "POST") return null;
3141
+ if (req.method === "POST" && Number(req.headers.get("content-length") ?? 0) > MAX_NOTE2 * 2) {
3142
+ return json({ error: "note is too large" }, 413);
3143
+ }
3144
+ const db = ctx.makeDb(env);
3145
+ const secret = await getVaultSecret(db, "network_share_secret");
3146
+ const readers = ctx.chapter.network.readers;
3147
+ if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
3148
+ const verified = await verifyFederatedRequest4(req, {
3149
+ secret,
3150
+ senders: readers.map((reader2) => reader2.id)
3151
+ });
3152
+ if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
3153
+ const reader = readers.find((candidate) => candidate.id === verified.sender);
3154
+ const recordType = url.searchParams.get("type") ?? "";
3155
+ const recordId = url.searchParams.get("recordId") ?? "";
3156
+ if (!IDENTIFIER3.test(recordType) || !RECORD_ID2.test(recordId) || !reader?.sharedNotes.includes(recordType)) {
3157
+ return json({ error: "shared notes are not enabled for this reader and record type" }, 403);
3158
+ }
3159
+ const record = await getRecord2({ crm: ctx.chapter.crm, db }, recordId);
3160
+ if (!record || record.type !== recordType) return json({ error: "record not found" }, 404);
3161
+ if (req.method === "GET") {
3162
+ return json({
3163
+ version: 1,
3164
+ site: { id: ctx.chapter.id, name: ctx.chapter.name },
3165
+ notes: await listSharedNotes(db, verified.sender, recordId)
3166
+ });
3167
+ }
3168
+ let input;
3169
+ try {
3170
+ input = JSON.parse(verified.body);
3171
+ } catch {
3172
+ return json({ error: "invalid JSON body" }, 400);
3173
+ }
3174
+ const body = typeof input.body === "string" ? input.body.trim() : "";
3175
+ const mutationId = typeof input.mutationId === "string" ? input.mutationId : "";
3176
+ if (!body || body.length > MAX_NOTE2 || !MUTATION_ID.test(mutationId)) {
3177
+ return json({ error: `body must be 1\u2013${MAX_NOTE2} characters and mutationId must be valid` }, 400);
3178
+ }
3179
+ const createdAt = Date.now();
3180
+ const id = `network:${verified.sender}:${mutationId}`;
3181
+ const result = await addActivity(
3182
+ {
3183
+ crm: ctx.chapter.crm,
3184
+ db,
3185
+ now: () => createdAt,
3186
+ newId: () => id
3187
+ },
3188
+ {
3189
+ recordId,
3190
+ kind: "note",
3191
+ body: `Shared by ${sourceLabel(verified.sender)}: ${body}`,
3192
+ authorId: `network:${verified.sender}`,
3193
+ meta: {
3194
+ [NETWORK_META]: {
3195
+ version: 1,
3196
+ sourceId: verified.sender,
3197
+ recordType,
3198
+ body
3199
+ }
3200
+ },
3201
+ mutationId: id
3202
+ }
3203
+ );
3204
+ const note = {
3205
+ id,
3206
+ sourceId: verified.sender,
3207
+ recordType,
3208
+ recordId,
3209
+ body,
3210
+ createdAt
3211
+ };
3212
+ return json({ version: 1, site: { id: ctx.chapter.id, name: ctx.chapter.name }, note, duplicate: result.duplicate }, 201);
3213
+ };
3214
+ function targetFor2(ctx, id) {
3215
+ return ctx.chapter.network.targets.find((target) => target.id === id);
3216
+ }
3217
+ var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
3218
+ if (url.pathname !== "/api/admin/network/shared-notes" || req.method !== "GET" && req.method !== "POST") return null;
3219
+ const got = await gate7(req, env, ctx);
3220
+ if ("response" in got) return got.response;
3221
+ const targetId = url.searchParams.get("targetId") ?? "";
3222
+ const recordType = url.searchParams.get("type") ?? "";
3223
+ const recordId = url.searchParams.get("recordId") ?? "";
3224
+ const target = targetFor2(ctx, targetId);
3225
+ if (!target || !target.sharedNotes.includes(recordType) || !IDENTIFIER3.test(recordType) || !RECORD_ID2.test(recordId)) {
3226
+ return json({ error: "configured targetId, type, and recordId are required" }, 400);
3227
+ }
3228
+ let body = "";
3229
+ if (req.method === "POST") {
3230
+ let input;
3231
+ try {
3232
+ input = await req.json();
3233
+ } catch {
3234
+ return json({ error: "invalid JSON body" }, 400);
3235
+ }
3236
+ const note = typeof input.body === "string" ? input.body.trim() : "";
3237
+ if (!note || note.length > MAX_NOTE2) return json({ error: `note must be 1\u2013${MAX_NOTE2} characters` }, 400);
3238
+ body = JSON.stringify({ body: note, mutationId: crypto.randomUUID() });
3239
+ }
3240
+ const secret = await getVaultSecret(got.db, target.secretName);
3241
+ if (!secret) return json({ error: "edge secret is missing" }, 502);
3242
+ const destination = new URL("/api/network/shared-notes", target.url);
3243
+ destination.search = new URLSearchParams({ type: recordType, recordId }).toString();
3244
+ try {
3245
+ const headers = await signFederatedRequest4({
3246
+ secret,
3247
+ sender: ctx.chapter.id,
3248
+ method: req.method,
3249
+ url: destination,
3250
+ ...body ? { body } : {}
3251
+ });
3252
+ const response = await fetchNetworkTarget(env, target, destination, {
3253
+ method: req.method,
3254
+ headers: { ...headers, ...body ? { "content-type": "application/json" } : {} },
3255
+ ...body ? { body } : {},
3256
+ signal: AbortSignal.timeout(1e4)
3257
+ });
3258
+ const payload = await response.json().catch(() => null);
3259
+ const site = payload?.site;
3260
+ if (!response.ok || !payload || site?.id !== target.id) {
3261
+ return json({
3262
+ error: typeof payload?.error === "string" ? payload.error : `follower returned ${response.status}`
3263
+ }, 502);
3264
+ }
3265
+ return json(payload, req.method === "POST" ? 201 : 200);
3266
+ } catch (error) {
3267
+ return json({ error: error instanceof Error ? error.message : "shared note request failed" }, 502);
3268
+ }
3269
+ };
3270
+
2819
3271
  // src/worker-routes-formation.ts
2820
3272
  import { createRecord as createRecord3 } from "@odla-ai/crm";
2821
3273
 
@@ -2918,6 +3370,8 @@ var BUILTIN_ROUTES = [
2918
3370
  handleCrm,
2919
3371
  handleNetworkShared,
2920
3372
  handleNetworkSnapshot,
3373
+ handleNetworkRecords,
3374
+ handleNetworkSharedNotes,
2921
3375
  handleFormation,
2922
3376
  handleMember,
2923
3377
  handleSchedule,
@@ -2948,6 +3402,9 @@ var BUILTIN_ROUTES = [
2948
3402
  handleAdminNetworkTargets,
2949
3403
  handleAdminNetworkRollup,
2950
3404
  handleAdminNetworkPush,
3405
+ handleAdminNetworkRecords,
3406
+ handleAdminNetworkNotes,
3407
+ handleAdminNetworkSharedNotes,
2951
3408
  // API requests must never fall through to an SPA asset response. Hosts still
2952
3409
  // get first refusal through options.routes, then this terminates unknown API
2953
3410
  // paths with an explicit machine-readable 404.