@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.
@@ -1923,16 +1923,16 @@ var crmDeps2 = (db, ctx) => ({
1923
1923
  });
1924
1924
  var handleAdminCrmSync = async (req, url, env, ctx) => {
1925
1925
  if (req.method !== "POST" || url.pathname !== "/api/admin/crm/sync") return null;
1926
- const gate5 = await adminGate(req, env, ctx);
1927
- if (gate5 instanceof Response) return gate5;
1928
- const result = await backfillCrm(crmDeps2(gate5.db, ctx));
1926
+ const gate8 = await adminGate(req, env, ctx);
1927
+ if (gate8 instanceof Response) return gate8;
1928
+ const result = await backfillCrm(crmDeps2(gate8.db, ctx));
1929
1929
  return json({ ok: true, ...result });
1930
1930
  };
1931
1931
  var handleAdminPeople = async (req, url, env, ctx) => {
1932
1932
  if (req.method !== "GET" || url.pathname !== "/api/admin/people") return null;
1933
- const gate5 = await adminGate(req, env, ctx);
1934
- if (gate5 instanceof Response) return gate5;
1935
- const { db } = gate5;
1933
+ const gate8 = await adminGate(req, env, ctx);
1934
+ if (gate8 instanceof Response) return gate8;
1935
+ const { db } = gate8;
1936
1936
  const sk = await getVaultSecret(db, "clerk_secret_key");
1937
1937
  const [appsRes, usersRes, roleList] = await Promise.all([
1938
1938
  db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 200 } } }),
@@ -1983,9 +1983,9 @@ var handleAdminPeople = async (req, url, env, ctx) => {
1983
1983
  };
1984
1984
  var handleAdminPeopleAccess = async (req, url, env, ctx) => {
1985
1985
  if (req.method !== "GET" || url.pathname !== "/api/admin/people/access") return null;
1986
- const gate5 = await adminGate(req, env, ctx);
1987
- if (gate5 instanceof Response) return gate5;
1988
- const { db } = gate5;
1986
+ const gate8 = await adminGate(req, env, ctx);
1987
+ if (gate8 instanceof Response) return gate8;
1988
+ const { db } = gate8;
1989
1989
  const targetId = url.searchParams.get("userId") ?? "";
1990
1990
  if (!targetId.startsWith("user_")) return json({ error: "invalid userId" }, 400);
1991
1991
  const sk = await getVaultSecret(db, "clerk_secret_key");
@@ -1996,9 +1996,9 @@ var handleAdminPeopleAccess = async (req, url, env, ctx) => {
1996
1996
  };
1997
1997
  var handleAdminPeopleRole = async (req, url, env, ctx) => {
1998
1998
  if (req.method !== "POST" || url.pathname !== "/api/admin/people/role") return null;
1999
- const gate5 = await adminGate(req, env, ctx);
2000
- if (gate5 instanceof Response) return gate5;
2001
- const { db, actor } = gate5;
1999
+ const gate8 = await adminGate(req, env, ctx);
2000
+ if (gate8 instanceof Response) return gate8;
2001
+ const { db, actor } = gate8;
2002
2002
  let body;
2003
2003
  try {
2004
2004
  body = await req.json();
@@ -2637,8 +2637,118 @@ var handleAdminComms = async (req, url, env, ctx) => {
2637
2637
  };
2638
2638
 
2639
2639
  // src/worker-routes-network.ts
2640
+ var import_crm5 = require("@odla-ai/crm");
2641
+ var import_db4 = require("@odla-ai/db");
2642
+
2643
+ // src/worker-network-fetch.ts
2644
+ function targetFetcher(env, target) {
2645
+ if (!target.binding) return null;
2646
+ const candidate = env[target.binding];
2647
+ if (typeof candidate !== "object" || candidate === null || !("fetch" in candidate) || typeof candidate.fetch !== "function") {
2648
+ throw new Error(`service binding "${target.binding}" is unavailable`);
2649
+ }
2650
+ return candidate;
2651
+ }
2652
+ function fetchNetworkTarget(env, target, destination, init) {
2653
+ const binding = targetFetcher(env, target);
2654
+ return binding ? binding.fetch(new Request(destination, init)) : globalThis.fetch(destination, init);
2655
+ }
2656
+
2657
+ // src/worker-network-push.ts
2640
2658
  var import_crm4 = require("@odla-ai/crm");
2641
2659
  var import_db3 = require("@odla-ai/db");
2660
+ async function pushNetworkRecord(db, env, ctx, target, record) {
2661
+ const secret = await getVaultSecret(db, target.secretName);
2662
+ const crmDeps3 = {
2663
+ crm: ctx.chapter.crm,
2664
+ db,
2665
+ now: () => Date.now(),
2666
+ newId: () => crypto.randomUUID()
2667
+ };
2668
+ if (!secret) {
2669
+ const error = `vault secret "${target.secretName}" is missing`;
2670
+ await (0, import_crm4.recordDeliveryAttempt)(crmDeps3, {
2671
+ recordId: record.id,
2672
+ targetId: target.id,
2673
+ status: "failed",
2674
+ payloadVersion: 2,
2675
+ error
2676
+ });
2677
+ return { id: target.id, name: target.name, ok: false, error };
2678
+ }
2679
+ let payload;
2680
+ try {
2681
+ payload = sharedRecordFromCrm(ctx.chapter.crm, record, target, ctx.chapter.id);
2682
+ } catch (err) {
2683
+ return {
2684
+ id: target.id,
2685
+ name: target.name,
2686
+ ok: false,
2687
+ error: err instanceof Error ? err.message : "record is not shareable"
2688
+ };
2689
+ }
2690
+ const payloadBody = JSON.stringify(payload);
2691
+ const destination = new URL("/api/network/shared", target.url);
2692
+ try {
2693
+ const signed = await (0, import_db3.signFederatedRequest)({
2694
+ secret,
2695
+ sender: ctx.chapter.id,
2696
+ method: "POST",
2697
+ url: destination,
2698
+ body: payloadBody
2699
+ });
2700
+ const res = await fetchNetworkTarget(env, target, destination, {
2701
+ method: "POST",
2702
+ headers: { ...signed, "content-type": "application/json" },
2703
+ body: payloadBody,
2704
+ signal: AbortSignal.timeout(1e4)
2705
+ });
2706
+ const responseBody = await res.json().catch(() => ({}));
2707
+ if (!res.ok) {
2708
+ const error = responseBody.error ?? "follower rejected the record";
2709
+ await (0, import_crm4.recordDeliveryAttempt)(crmDeps3, {
2710
+ recordId: record.id,
2711
+ targetId: target.id,
2712
+ status: "failed",
2713
+ payloadVersion: 2,
2714
+ error
2715
+ });
2716
+ return {
2717
+ id: target.id,
2718
+ name: target.name,
2719
+ ok: false,
2720
+ status: res.status,
2721
+ error
2722
+ };
2723
+ }
2724
+ await (0, import_crm4.recordDeliveryAttempt)(crmDeps3, {
2725
+ recordId: record.id,
2726
+ targetId: target.id,
2727
+ status: "delivered",
2728
+ payloadVersion: 2,
2729
+ ...responseBody.recordId ? { remoteRecordId: responseBody.recordId } : {}
2730
+ });
2731
+ return {
2732
+ id: target.id,
2733
+ name: target.name,
2734
+ ok: true,
2735
+ status: res.status,
2736
+ recordId: responseBody.recordId
2737
+ };
2738
+ } catch (err) {
2739
+ const error = err instanceof Error ? err.message : "delivery failed";
2740
+ await (0, import_crm4.recordDeliveryAttempt)(crmDeps3, {
2741
+ recordId: record.id,
2742
+ targetId: target.id,
2743
+ status: "failed",
2744
+ payloadVersion: 2,
2745
+ error
2746
+ });
2747
+ return { id: target.id, name: target.name, ok: false, error };
2748
+ }
2749
+ }
2750
+
2751
+ // src/worker-routes-network.ts
2642
2752
  async function gate4(req, env, ctx) {
2643
2753
  const db = ctx.makeDb(env);
2644
2754
  const user = await ctx.verifyUser(req, env);
@@ -2651,11 +2761,12 @@ var handleAdminNetworkTargets = async (req, url, env, ctx) => {
2651
2761
  const got = await gate4(req, env, ctx);
2652
2762
  if ("response" in got) return got.response;
2653
2763
  return json({
2654
- targets: ctx.chapter.network.targets.map(({ id, name, url: targetUrl, fields }) => ({
2764
+ targets: ctx.chapter.network.targets.map(({ id, name, url: targetUrl, fields, sharedNotes }) => ({
2655
2765
  id,
2656
2766
  name,
2657
2767
  url: targetUrl,
2658
- types: Object.keys(fields ?? DEFAULT_SHARE_FIELDS)
2768
+ types: Object.keys(fields ?? DEFAULT_SHARE_FIELDS),
2769
+ sharedNotes
2659
2770
  }))
2660
2771
  });
2661
2772
  };
@@ -2664,10 +2775,10 @@ var handleNetworkSnapshot = async (req, url, env, ctx) => {
2664
2775
  const db = ctx.makeDb(env);
2665
2776
  const secret = await getVaultSecret(db, "network_share_secret");
2666
2777
  if (!secret) return json({ error: "unauthorized" }, 401);
2667
- const verified = await (0, import_db3.verifyFederatedRequest)(req, { secret });
2778
+ const verified = await (0, import_db4.verifyFederatedRequest)(req, { secret });
2668
2779
  if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
2669
2780
  const types = await Promise.all(
2670
- Object.keys(ctx.chapter.crm.config.types).map((type) => (0, import_crm4.typeSummary)({ crm: ctx.chapter.crm, db }, type))
2781
+ Object.keys(ctx.chapter.crm.config.types).map((type) => (0, import_crm5.typeSummary)({ crm: ctx.chapter.crm, db }, type))
2671
2782
  );
2672
2783
  const snapshot = {
2673
2784
  version: 1,
@@ -2677,18 +2788,21 @@ var handleNetworkSnapshot = async (req, url, env, ctx) => {
2677
2788
  };
2678
2789
  return json(snapshot);
2679
2790
  };
2680
- async function snapshotOne(db, ctx, target) {
2791
+ async function snapshotOne(db, env, ctx, target) {
2681
2792
  const secret = await getVaultSecret(db, target.secretName);
2682
2793
  if (!secret) return { id: target.id, name: target.name, url: target.url, available: false, error: "edge secret is missing" };
2683
2794
  const destination = new URL("/api/network/snapshot", target.url);
2684
2795
  try {
2685
- const headers = await (0, import_db3.signFederatedRequest)({
2796
+ const headers = await (0, import_db4.signFederatedRequest)({
2686
2797
  secret,
2687
2798
  sender: ctx.chapter.id,
2688
2799
  method: "GET",
2689
2800
  url: destination
2690
2801
  });
2691
- const response = await fetch(destination, { headers, signal: AbortSignal.timeout(1e4) });
2802
+ const response = await fetchNetworkTarget(env, target, destination, {
2803
+ headers,
2804
+ signal: AbortSignal.timeout(1e4)
2805
+ });
2692
2806
  const body = await response.json().catch(() => null);
2693
2807
  if (!response.ok || !body || !("version" in body) || body.version !== 1 || !("site" in body) || body.site.id !== target.id || !Array.isArray(body.types)) {
2694
2808
  const upstream = body && "error" in body && typeof body.error === "string" ? body.error : void 0;
@@ -2723,7 +2837,7 @@ var handleAdminNetworkRollup = async (req, url, env, ctx) => {
2723
2837
  const got = await gate4(req, env, ctx);
2724
2838
  if ("response" in got) return got.response;
2725
2839
  const targets = await Promise.all(
2726
- ctx.chapter.network.targets.map((target) => snapshotOne(got.db, ctx, target))
2840
+ ctx.chapter.network.targets.map((target) => snapshotOne(got.db, env, ctx, target))
2727
2841
  );
2728
2842
  const byType = /* @__PURE__ */ new Map();
2729
2843
  for (const target of targets) {
@@ -2745,74 +2859,6 @@ var handleAdminNetworkRollup = async (req, url, env, ctx) => {
2745
2859
  };
2746
2860
  return json(rollup);
2747
2861
  };
2748
- async function pushOne(db, ctx, target, record) {
2749
- const secret = await getVaultSecret(db, target.secretName);
2750
- const crmDeps3 = { crm: ctx.chapter.crm, db, now: () => Date.now(), newId: () => crypto.randomUUID() };
2751
- if (!secret) {
2752
- const error = `vault secret "${target.secretName}" is missing`;
2753
- await (0, import_crm4.recordDeliveryAttempt)(crmDeps3, {
2754
- recordId: record.id,
2755
- targetId: target.id,
2756
- status: "failed",
2757
- payloadVersion: 2,
2758
- error
2759
- });
2760
- return { id: target.id, name: target.name, ok: false, error };
2761
- }
2762
- let payload;
2763
- try {
2764
- payload = sharedRecordFromCrm(ctx.chapter.crm, record, target, ctx.chapter.id);
2765
- } catch (err) {
2766
- return { id: target.id, name: target.name, ok: false, error: err instanceof Error ? err.message : "record is not shareable" };
2767
- }
2768
- const payloadBody = JSON.stringify(payload);
2769
- const destination = new URL("/api/network/shared", target.url);
2770
- try {
2771
- const signed = await (0, import_db3.signFederatedRequest)({
2772
- secret,
2773
- sender: ctx.chapter.id,
2774
- method: "POST",
2775
- url: destination,
2776
- body: payloadBody
2777
- });
2778
- const res = await fetch(destination, {
2779
- method: "POST",
2780
- headers: { ...signed, "content-type": "application/json" },
2781
- body: payloadBody,
2782
- signal: AbortSignal.timeout(1e4)
2783
- });
2784
- const responseBody = await res.json().catch(() => ({}));
2785
- if (!res.ok) {
2786
- const error = responseBody.error ?? "follower rejected the record";
2787
- await (0, import_crm4.recordDeliveryAttempt)(crmDeps3, {
2788
- recordId: record.id,
2789
- targetId: target.id,
2790
- status: "failed",
2791
- payloadVersion: 2,
2792
- error
2793
- });
2794
- return { id: target.id, name: target.name, ok: false, status: res.status, error };
2795
- }
2796
- await (0, import_crm4.recordDeliveryAttempt)(crmDeps3, {
2797
- recordId: record.id,
2798
- targetId: target.id,
2799
- status: "delivered",
2800
- payloadVersion: 2,
2801
- ...responseBody.recordId ? { remoteRecordId: responseBody.recordId } : {}
2802
- });
2803
- return { id: target.id, name: target.name, ok: true, status: res.status, recordId: responseBody.recordId };
2804
- } catch (err) {
2805
- const error = err instanceof Error ? err.message : "delivery failed";
2806
- await (0, import_crm4.recordDeliveryAttempt)(crmDeps3, {
2807
- recordId: record.id,
2808
- targetId: target.id,
2809
- status: "failed",
2810
- payloadVersion: 2,
2811
- error
2812
- });
2813
- return { id: target.id, name: target.name, ok: false, error };
2814
- }
2815
- }
2816
2862
  var handleAdminNetworkPush = async (req, url, env, ctx) => {
2817
2863
  if (req.method !== "POST" || url.pathname !== "/api/admin/network/push") return null;
2818
2864
  const got = await gate4(req, env, ctx);
@@ -2830,16 +2876,418 @@ var handleAdminNetworkPush = async (req, url, env, ctx) => {
2830
2876
  if (requested.size !== body.targetIds.length) return json({ error: "targetIds must contain unique strings" }, 400);
2831
2877
  const targets = ctx.chapter.network.targets.filter((target) => requested.has(target.id));
2832
2878
  if (targets.length !== requested.size) return json({ error: "one or more targetIds are not configured" }, 400);
2833
- const record = await (0, import_crm4.getRecord)({ crm: ctx.chapter.crm, db: got.db }, body.recordId);
2879
+ const record = await (0, import_crm5.getRecord)({ crm: ctx.chapter.crm, db: got.db }, body.recordId);
2834
2880
  if (!record) return json({ error: "record not found" }, 404);
2835
2881
  const results = await Promise.all(
2836
- targets.map((target) => pushOne(got.db, ctx, target, record))
2882
+ targets.map((target) => pushNetworkRecord(got.db, env, ctx, target, record))
2837
2883
  );
2838
2884
  return json({ ok: results.every((result) => result.ok), results });
2839
2885
  };
2840
2886
 
2887
+ // src/worker-routes-network-records.ts
2888
+ var import_crm6 = require("@odla-ai/crm");
2889
+ var import_db5 = require("@odla-ai/db");
2890
+ var IDENTIFIER = /^[a-z][a-zA-Z0-9_]*$/;
2891
+ var MAX_PAGE = 50;
2892
+ var MAX_OFFSET = 1e4;
2893
+ var MAX_SEARCH = 200;
2894
+ async function gate5(req, env, ctx) {
2895
+ const db = ctx.makeDb(env);
2896
+ const user = await ctx.verifyUser(req, env);
2897
+ if (!user) return { response: json({ error: "unauthorized" }, 401) };
2898
+ if (!await ctx.isAdmin(db, user)) return { response: json({ error: "forbidden" }, 403) };
2899
+ return { db, user };
2900
+ }
2901
+ function integer(value, fallback, min, max) {
2902
+ if (value == null || value === "") return fallback;
2903
+ if (!/^\d+$/.test(value)) return null;
2904
+ const parsed = Number(value);
2905
+ return Number.isSafeInteger(parsed) && parsed >= min && parsed <= max ? parsed : null;
2906
+ }
2907
+ function projectRecord(record, allowed) {
2908
+ const fields = Object.fromEntries(
2909
+ allowed.flatMap((name) => Object.prototype.hasOwnProperty.call(record.fields, name) ? [[name, record.fields[name]]] : [])
2910
+ );
2911
+ return {
2912
+ id: record.id,
2913
+ type: record.type,
2914
+ name: record.name,
2915
+ ...record.stage ? { stage: record.stage } : {},
2916
+ fields,
2917
+ createdAt: record.createdAt,
2918
+ updatedAt: record.updatedAt
2919
+ };
2920
+ }
2921
+ var handleNetworkRecords = async (req, url, env, ctx) => {
2922
+ if (req.method !== "GET" || url.pathname !== "/api/network/records") return null;
2923
+ const db = ctx.makeDb(env);
2924
+ const secret = await getVaultSecret(db, "network_share_secret");
2925
+ const readers = ctx.chapter.network.readers;
2926
+ if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
2927
+ const verified = await (0, import_db5.verifyFederatedRequest)(req, {
2928
+ secret,
2929
+ senders: readers.map((reader2) => reader2.id)
2930
+ });
2931
+ if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
2932
+ const reader = readers.find((candidate) => candidate.id === verified.sender);
2933
+ const type = url.searchParams.get("type") ?? "";
2934
+ const allowed = reader?.fields[type];
2935
+ if (!IDENTIFIER.test(type) || !allowed) return json({ error: "record type is not shared with this reader" }, 403);
2936
+ const limit = integer(url.searchParams.get("limit"), 25, 1, MAX_PAGE);
2937
+ const offset = integer(url.searchParams.get("offset"), 0, 0, MAX_OFFSET);
2938
+ const search = (url.searchParams.get("search") ?? "").trim();
2939
+ if (limit == null || offset == null || search.length > MAX_SEARCH) {
2940
+ return json({ error: "invalid pagination or search parameters" }, 400);
2941
+ }
2942
+ const page = await (0, import_crm6.listRecords)(
2943
+ { crm: ctx.chapter.crm, db },
2944
+ {
2945
+ type,
2946
+ limit,
2947
+ offset,
2948
+ ...search ? { search } : {},
2949
+ sort: { field: "updatedAt", dir: "desc" }
2950
+ }
2951
+ );
2952
+ const response = {
2953
+ version: 1,
2954
+ site: { id: ctx.chapter.id, name: ctx.chapter.name },
2955
+ type,
2956
+ records: page.records.map((record) => projectRecord(record, allowed)),
2957
+ total: page.total,
2958
+ limit: page.limit,
2959
+ offset: page.offset
2960
+ };
2961
+ return json(response);
2962
+ };
2963
+ function targetFor(ctx, id) {
2964
+ return ctx.chapter.network.targets.find((target) => target.id === id);
2965
+ }
2966
+ async function readTargetRecords(db, env, ctx, target, query) {
2967
+ const secret = await getVaultSecret(db, target.secretName);
2968
+ if (!secret) return { ok: false, error: "edge secret is missing" };
2969
+ const destination = new URL("/api/network/records", target.url);
2970
+ destination.search = query.toString();
2971
+ try {
2972
+ const headers = await (0, import_db5.signFederatedRequest)({
2973
+ secret,
2974
+ sender: ctx.chapter.id,
2975
+ method: "GET",
2976
+ url: destination
2977
+ });
2978
+ const response = await fetchNetworkTarget(env, target, destination, {
2979
+ headers,
2980
+ signal: AbortSignal.timeout(1e4)
2981
+ });
2982
+ const body = await response.json().catch(() => null);
2983
+ if (!response.ok || !body || !("version" in body) || body.version !== 1 || !("site" in body) || body.site.id !== target.id || !Array.isArray(body.records)) {
2984
+ const upstream = body && "error" in body && typeof body.error === "string" ? body.error : void 0;
2985
+ return {
2986
+ ok: false,
2987
+ error: upstream ?? (response.ok ? "invalid follower record response" : `follower returned ${response.status}`)
2988
+ };
2989
+ }
2990
+ return { ok: true, page: body };
2991
+ } catch (error) {
2992
+ return { ok: false, error: error instanceof Error ? error.message : "record read failed" };
2993
+ }
2994
+ }
2995
+ var handleAdminNetworkRecords = async (req, url, env, ctx) => {
2996
+ if (req.method !== "GET" || url.pathname !== "/api/admin/network/records") return null;
2997
+ const got = await gate5(req, env, ctx);
2998
+ if ("response" in got) return got.response;
2999
+ const targetId = url.searchParams.get("targetId") ?? "";
3000
+ const type = url.searchParams.get("type") ?? "";
3001
+ const target = targetFor(ctx, targetId);
3002
+ if (!target || !IDENTIFIER.test(type)) return json({ error: "configured targetId and record type are required" }, 400);
3003
+ const limit = integer(url.searchParams.get("limit"), 25, 1, MAX_PAGE);
3004
+ const offset = integer(url.searchParams.get("offset"), 0, 0, MAX_OFFSET);
3005
+ const search = (url.searchParams.get("search") ?? "").trim();
3006
+ if (limit == null || offset == null || search.length > MAX_SEARCH) {
3007
+ return json({ error: "invalid pagination or search parameters" }, 400);
3008
+ }
3009
+ const query = new URLSearchParams({ type, limit: String(limit), offset: String(offset) });
3010
+ if (search) query.set("search", search);
3011
+ const result = await readTargetRecords(
3012
+ got.db,
3013
+ env,
3014
+ ctx,
3015
+ target,
3016
+ query
3017
+ );
3018
+ return result.ok ? json(result.page) : json({ error: result.error }, 502);
3019
+ };
3020
+
3021
+ // src/worker-routes-network-notes.ts
3022
+ var IDENTIFIER2 = /^[a-z][a-zA-Z0-9_]*$/;
3023
+ var RECORD_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
3024
+ var MAX_NOTE = 4e3;
3025
+ async function gate6(req, env, ctx) {
3026
+ const db = ctx.makeDb(env);
3027
+ const user = await ctx.verifyUser(req, env);
3028
+ if (!user) return { response: json({ error: "unauthorized" }, 401) };
3029
+ if (!await ctx.isAdmin(db, user)) return { response: json({ error: "forbidden" }, 403) };
3030
+ return { db, user };
3031
+ }
3032
+ function noteIdentity(url, ctx) {
3033
+ const targetId = url.searchParams.get("targetId") ?? "";
3034
+ const recordType = url.searchParams.get("type") ?? "";
3035
+ const recordId = url.searchParams.get("recordId") ?? "";
3036
+ const configured = ctx.chapter.network.targets.some((target) => target.id === targetId);
3037
+ if (!configured || !IDENTIFIER2.test(recordType) || !RECORD_ID.test(recordId)) return null;
3038
+ return {
3039
+ targetId,
3040
+ recordType,
3041
+ recordId,
3042
+ recordKey: `${targetId}:${recordType}:${recordId}`
3043
+ };
3044
+ }
3045
+ function asNote(row) {
3046
+ return {
3047
+ id: String(row.id),
3048
+ targetId: String(row.targetId),
3049
+ recordType: String(row.recordType),
3050
+ recordId: String(row.recordId),
3051
+ body: String(row.body),
3052
+ authorId: String(row.authorId),
3053
+ ...typeof row.authorEmail === "string" ? { authorEmail: row.authorEmail } : {},
3054
+ createdAt: Number(row.createdAt)
3055
+ };
3056
+ }
3057
+ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
3058
+ if (url.pathname !== "/api/admin/network/notes" || req.method !== "GET" && req.method !== "POST") return null;
3059
+ const got = await gate6(req, env, ctx);
3060
+ if ("response" in got) return got.response;
3061
+ const identity = noteIdentity(url, ctx);
3062
+ if (!identity) return json({ error: "valid targetId, type, and recordId are required" }, 400);
3063
+ if (req.method === "GET") {
3064
+ const result = await got.db.query({
3065
+ networkNotes: {
3066
+ $: {
3067
+ where: { recordKey: identity.recordKey },
3068
+ order: { createdAt: "desc" },
3069
+ limit: 100
3070
+ }
3071
+ }
3072
+ });
3073
+ return json({ notes: (result.networkNotes ?? []).map((row) => asNote(row)) });
3074
+ }
3075
+ const contentLength = Number(req.headers.get("content-length") ?? 0);
3076
+ if (contentLength > MAX_NOTE * 2) return json({ error: "note is too large" }, 413);
3077
+ let input;
3078
+ try {
3079
+ input = await req.json();
3080
+ } catch {
3081
+ return json({ error: "invalid JSON body" }, 400);
3082
+ }
3083
+ const body = typeof input.body === "string" ? input.body.trim() : "";
3084
+ if (!body || body.length > MAX_NOTE) return json({ error: `note must be 1\u2013${MAX_NOTE} characters` }, 400);
3085
+ const id = crypto.randomUUID();
3086
+ const note = {
3087
+ id,
3088
+ targetId: identity.targetId,
3089
+ recordType: identity.recordType,
3090
+ recordId: identity.recordId,
3091
+ body,
3092
+ authorId: got.user.userId,
3093
+ ...got.user.email ? { authorEmail: got.user.email } : {},
3094
+ createdAt: Date.now()
3095
+ };
3096
+ await got.db.transact([{
3097
+ t: "update",
3098
+ ns: "networkNotes",
3099
+ id,
3100
+ attrs: { ...note, recordKey: identity.recordKey }
3101
+ }]);
3102
+ return json({ note }, 201);
3103
+ };
3104
+
3105
+ // src/worker-routes-network-shared-notes.ts
3106
+ var import_crm7 = require("@odla-ai/crm");
3107
+ var import_db6 = require("@odla-ai/db");
3108
+ var IDENTIFIER3 = /^[a-z][a-zA-Z0-9_]*$/;
3109
+ var RECORD_ID2 = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
3110
+ var MUTATION_ID = /^[A-Za-z0-9_-]{16,128}$/;
3111
+ var MAX_NOTE2 = 4e3;
3112
+ var NETWORK_META = "chapterNetwork";
3113
+ async function gate7(req, env, ctx) {
3114
+ const db = ctx.makeDb(env);
3115
+ const user = await ctx.verifyUser(req, env);
3116
+ if (!user) return { response: json({ error: "unauthorized" }, 401) };
3117
+ if (!await ctx.isAdmin(db, user)) return { response: json({ error: "forbidden" }, 403) };
3118
+ return { db, user };
3119
+ }
3120
+ function sharedNote(activity, sourceId) {
3121
+ const meta = activity.meta?.[NETWORK_META];
3122
+ if (!meta || typeof meta !== "object" || Array.isArray(meta)) return null;
3123
+ const value = meta;
3124
+ if (value.version !== 1 || value.sourceId !== sourceId || typeof value.body !== "string") return null;
3125
+ return {
3126
+ id: activity.id,
3127
+ sourceId,
3128
+ recordType: String(value.recordType ?? ""),
3129
+ recordId: activity.recordId,
3130
+ body: value.body,
3131
+ createdAt: activity.createdAt
3132
+ };
3133
+ }
3134
+ async function listSharedNotes(db, sourceId, recordId) {
3135
+ const result = await db.query({
3136
+ [import_crm7.CRM_NS.activity]: {
3137
+ $: {
3138
+ where: {
3139
+ and: [
3140
+ { recordId },
3141
+ { authorId: `network:${sourceId}` }
3142
+ ]
3143
+ },
3144
+ order: { createdAt: "desc" },
3145
+ limit: 100
3146
+ }
3147
+ }
3148
+ });
3149
+ return (result[import_crm7.CRM_NS.activity] ?? []).flatMap((activity) => {
3150
+ const note = sharedNote(activity, sourceId);
3151
+ return note ? [note] : [];
3152
+ });
3153
+ }
3154
+ function sourceLabel(sourceId) {
3155
+ return sourceId.split("-").map((word) => word ? `${word[0]?.toUpperCase()}${word.slice(1)}` : word).join(" ");
3156
+ }
3157
+ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
3158
+ if (url.pathname !== "/api/network/shared-notes" || req.method !== "GET" && req.method !== "POST") return null;
3159
+ if (req.method === "POST" && Number(req.headers.get("content-length") ?? 0) > MAX_NOTE2 * 2) {
3160
+ return json({ error: "note is too large" }, 413);
3161
+ }
3162
+ const db = ctx.makeDb(env);
3163
+ const secret = await getVaultSecret(db, "network_share_secret");
3164
+ const readers = ctx.chapter.network.readers;
3165
+ if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
3166
+ const verified = await (0, import_db6.verifyFederatedRequest)(req, {
3167
+ secret,
3168
+ senders: readers.map((reader2) => reader2.id)
3169
+ });
3170
+ if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
3171
+ const reader = readers.find((candidate) => candidate.id === verified.sender);
3172
+ const recordType = url.searchParams.get("type") ?? "";
3173
+ const recordId = url.searchParams.get("recordId") ?? "";
3174
+ if (!IDENTIFIER3.test(recordType) || !RECORD_ID2.test(recordId) || !reader?.sharedNotes.includes(recordType)) {
3175
+ return json({ error: "shared notes are not enabled for this reader and record type" }, 403);
3176
+ }
3177
+ const record = await (0, import_crm7.getRecord)({ crm: ctx.chapter.crm, db }, recordId);
3178
+ if (!record || record.type !== recordType) return json({ error: "record not found" }, 404);
3179
+ if (req.method === "GET") {
3180
+ return json({
3181
+ version: 1,
3182
+ site: { id: ctx.chapter.id, name: ctx.chapter.name },
3183
+ notes: await listSharedNotes(db, verified.sender, recordId)
3184
+ });
3185
+ }
3186
+ let input;
3187
+ try {
3188
+ input = JSON.parse(verified.body);
3189
+ } catch {
3190
+ return json({ error: "invalid JSON body" }, 400);
3191
+ }
3192
+ const body = typeof input.body === "string" ? input.body.trim() : "";
3193
+ const mutationId = typeof input.mutationId === "string" ? input.mutationId : "";
3194
+ if (!body || body.length > MAX_NOTE2 || !MUTATION_ID.test(mutationId)) {
3195
+ return json({ error: `body must be 1\u2013${MAX_NOTE2} characters and mutationId must be valid` }, 400);
3196
+ }
3197
+ const createdAt = Date.now();
3198
+ const id = `network:${verified.sender}:${mutationId}`;
3199
+ const result = await (0, import_crm7.addActivity)(
3200
+ {
3201
+ crm: ctx.chapter.crm,
3202
+ db,
3203
+ now: () => createdAt,
3204
+ newId: () => id
3205
+ },
3206
+ {
3207
+ recordId,
3208
+ kind: "note",
3209
+ body: `Shared by ${sourceLabel(verified.sender)}: ${body}`,
3210
+ authorId: `network:${verified.sender}`,
3211
+ meta: {
3212
+ [NETWORK_META]: {
3213
+ version: 1,
3214
+ sourceId: verified.sender,
3215
+ recordType,
3216
+ body
3217
+ }
3218
+ },
3219
+ mutationId: id
3220
+ }
3221
+ );
3222
+ const note = {
3223
+ id,
3224
+ sourceId: verified.sender,
3225
+ recordType,
3226
+ recordId,
3227
+ body,
3228
+ createdAt
3229
+ };
3230
+ return json({ version: 1, site: { id: ctx.chapter.id, name: ctx.chapter.name }, note, duplicate: result.duplicate }, 201);
3231
+ };
3232
+ function targetFor2(ctx, id) {
3233
+ return ctx.chapter.network.targets.find((target) => target.id === id);
3234
+ }
3235
+ var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
3236
+ if (url.pathname !== "/api/admin/network/shared-notes" || req.method !== "GET" && req.method !== "POST") return null;
3237
+ const got = await gate7(req, env, ctx);
3238
+ if ("response" in got) return got.response;
3239
+ const targetId = url.searchParams.get("targetId") ?? "";
3240
+ const recordType = url.searchParams.get("type") ?? "";
3241
+ const recordId = url.searchParams.get("recordId") ?? "";
3242
+ const target = targetFor2(ctx, targetId);
3243
+ if (!target || !target.sharedNotes.includes(recordType) || !IDENTIFIER3.test(recordType) || !RECORD_ID2.test(recordId)) {
3244
+ return json({ error: "configured targetId, type, and recordId are required" }, 400);
3245
+ }
3246
+ let body = "";
3247
+ if (req.method === "POST") {
3248
+ let input;
3249
+ try {
3250
+ input = await req.json();
3251
+ } catch {
3252
+ return json({ error: "invalid JSON body" }, 400);
3253
+ }
3254
+ const note = typeof input.body === "string" ? input.body.trim() : "";
3255
+ if (!note || note.length > MAX_NOTE2) return json({ error: `note must be 1\u2013${MAX_NOTE2} characters` }, 400);
3256
+ body = JSON.stringify({ body: note, mutationId: crypto.randomUUID() });
3257
+ }
3258
+ const secret = await getVaultSecret(got.db, target.secretName);
3259
+ if (!secret) return json({ error: "edge secret is missing" }, 502);
3260
+ const destination = new URL("/api/network/shared-notes", target.url);
3261
+ destination.search = new URLSearchParams({ type: recordType, recordId }).toString();
3262
+ try {
3263
+ const headers = await (0, import_db6.signFederatedRequest)({
3264
+ secret,
3265
+ sender: ctx.chapter.id,
3266
+ method: req.method,
3267
+ url: destination,
3268
+ ...body ? { body } : {}
3269
+ });
3270
+ const response = await fetchNetworkTarget(env, target, destination, {
3271
+ method: req.method,
3272
+ headers: { ...headers, ...body ? { "content-type": "application/json" } : {} },
3273
+ ...body ? { body } : {},
3274
+ signal: AbortSignal.timeout(1e4)
3275
+ });
3276
+ const payload = await response.json().catch(() => null);
3277
+ const site = payload?.site;
3278
+ if (!response.ok || !payload || site?.id !== target.id) {
3279
+ return json({
3280
+ error: typeof payload?.error === "string" ? payload.error : `follower returned ${response.status}`
3281
+ }, 502);
3282
+ }
3283
+ return json(payload, req.method === "POST" ? 201 : 200);
3284
+ } catch (error) {
3285
+ return json({ error: error instanceof Error ? error.message : "shared note request failed" }, 502);
3286
+ }
3287
+ };
3288
+
2841
3289
  // src/worker-routes-formation.ts
2842
- var import_crm5 = require("@odla-ai/crm");
3290
+ var import_crm8 = require("@odla-ai/crm");
2843
3291
 
2844
3292
  // src/formation.ts
2845
3293
  function formationFields(crm, formation) {
@@ -2905,7 +3353,7 @@ var handleFormation = async (req, url, env, ctx) => {
2905
3353
  const db = ctx.makeDb(env);
2906
3354
  const id = submissionId ? await applicationIdForSubmission(`formation:${ctx.chapter.id}:${submissionId}`) : crypto.randomUUID();
2907
3355
  try {
2908
- const created = await (0, import_crm5.createRecord)(
3356
+ const created = await (0, import_crm8.createRecord)(
2909
3357
  {
2910
3358
  crm: ctx.chapter.crm,
2911
3359
  db,
@@ -2940,6 +3388,8 @@ var BUILTIN_ROUTES = [
2940
3388
  handleCrm,
2941
3389
  handleNetworkShared,
2942
3390
  handleNetworkSnapshot,
3391
+ handleNetworkRecords,
3392
+ handleNetworkSharedNotes,
2943
3393
  handleFormation,
2944
3394
  handleMember,
2945
3395
  handleSchedule,
@@ -2970,6 +3420,9 @@ var BUILTIN_ROUTES = [
2970
3420
  handleAdminNetworkTargets,
2971
3421
  handleAdminNetworkRollup,
2972
3422
  handleAdminNetworkPush,
3423
+ handleAdminNetworkRecords,
3424
+ handleAdminNetworkNotes,
3425
+ handleAdminNetworkSharedNotes,
2973
3426
  // API requests must never fall through to an SPA asset response. Hosts still
2974
3427
  // get first refusal through options.routes, then this terminates unknown API
2975
3428
  // paths with an explicit machine-readable 404.