@odla-ai/chapter 0.26.1 → 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.
@@ -36,7 +36,7 @@ interface ChapterCopy {
36
36
  admin: {
37
37
  auth: TextFields<"checking" | "notAuthorized" | "accountNotAuthorized" | "thisAccount" | "signOut" | "loading" | "signInNotConfigured" | "missingPublishableKey" | "noWorkspaces" | "signInTagline">;
38
38
  shell: TextFields<"adminRole" | "adminConsole" | "adminName" | "navigationLabel">;
39
- workspaces: TextFields<"dashboard" | "overview" | "billing" | "people" | "portfolio" | "settings" | "calendar" | "email" | "chapters" | "formations" | "dealFlow" | "dashboardViewsLabel" | "settingsViewsLabel" | "collectionsLabel" | "portfolioViewsLabel">;
39
+ workspaces: TextFields<"dashboard" | "overview" | "billing" | "people" | "network" | "portfolio" | "settings" | "calendar" | "email" | "chapters" | "formations" | "dealFlow" | "dashboardViewsLabel" | "settingsViewsLabel" | "collectionsLabel" | "portfolioViewsLabel">;
40
40
  dashboard: TextFields<"loading" | "loadFailed" | "upcomingCalls" | "callsNeedAttention" | "noUpcomingCalls" | "drift" | "open" | "meet" | "applications" | "newMembers" | "newMembersUnavailable" | "revenueAdded" | "revenue" | "revenueUnavailable" | "pipeline" | "pipelineLabel" | "thisWeek" | "noChange" | "activeMemberships" | "annualRunRate" | "testMode">;
41
41
  billing: TextFields<"loading" | "loadFailed" | "notConfigured" | "active" | "annualized" | "renewingSoon" | "pastDue" | "subscriptions" | "testMode" | "truncated" | "name" | "email" | "application" | "subscription" | "cancelling" | "amount" | "renews">;
42
42
  availability: TextFields<"loading" | "loadFailed" | "saved" | "title" | "days" | "startHour" | "endHour" | "slotMinutes" | "minNoticeHours" | "windowDays" | "timezone" | "summaryTemplate" | "save"> & {
@@ -74,11 +74,31 @@ interface ChapterNetworkTarget {
74
74
  /** Per-record-type field allowlist. If present, omitted types cannot be sent
75
75
  * to this target. Without it, package-safe person/company defaults apply. */
76
76
  fields?: Record<string, readonly string[]>;
77
- }
78
- /** Leader/follower network configuration. Followers need no code config to
79
- * receive records; they only vault `network_share_secret`. */
77
+ /** Record types on which leader admins may deliberately share notes with
78
+ * this follower. Omitted by default: a target never gains write permission
79
+ * merely because its records are browsable. */
80
+ sharedNotes?: readonly string[];
81
+ }
82
+ /** One signed peer allowed to browse a privacy-bounded projection of this
83
+ * site's CRM. The follower owns this allowlist; a leader target declaration
84
+ * cannot widen it. */
85
+ interface ChapterNetworkReader {
86
+ /** Stable app/site slug expected in the signed federation envelope. */
87
+ id: string;
88
+ /** Per-record-type field allowlist. Omitted types and fields never leave
89
+ * this site. Record id, name, stage, and timestamps are bounded metadata. */
90
+ fields: Record<string, readonly string[]>;
91
+ /** Record types on which this reader may append an explicitly shared note.
92
+ * Omitted by default: browse permission never silently grants write access.
93
+ * Shared notes land in the follower CRM activity feed. */
94
+ sharedNotes?: readonly string[];
95
+ }
96
+ /** Leader/follower network configuration. A follower needs no code config to
97
+ * receive pushed records. It must explicitly declare `readers` before any peer
98
+ * can browse its CRM. */
80
99
  interface ChapterNetwork {
81
100
  targets?: readonly ChapterNetworkTarget[];
101
+ readers?: readonly ChapterNetworkReader[];
82
102
  }
83
103
  /** Public chapter-formation intake backed by one CRM record type. The host owns
84
104
  * the branded form; Chapter owns the bounded, idempotent write contract. */
@@ -113,10 +133,18 @@ interface ResolvedNetworkTarget {
113
133
  binding?: string;
114
134
  secretName: string;
115
135
  fields?: Record<string, readonly string[]>;
136
+ sharedNotes: readonly string[];
137
+ }
138
+ /** A validated follower-owned record projection for one signed reader. */
139
+ interface ResolvedNetworkReader {
140
+ id: string;
141
+ fields: Record<string, readonly string[]>;
142
+ sharedNotes: readonly string[];
116
143
  }
117
144
  /** Fully-resolved leader network configuration. */
118
145
  interface ResolvedNetwork {
119
146
  targets: readonly ResolvedNetworkTarget[];
147
+ readers: readonly ResolvedNetworkReader[];
120
148
  }
121
149
 
122
150
  /** Which feature profile a site runs. `chapter` is the full public member site
@@ -377,8 +405,8 @@ interface ChapterConfig {
377
405
  /** User-visible language for packaged Chapter surfaces. Recursively partial;
378
406
  * `defineChapter()` resolves every omitted leaf from the package defaults. */
379
407
  copy?: ChapterCopyInput;
380
- /** Leader follower delivery targets. Secret values stay in the leader
381
- * tenant vault; see {@link ChapterNetworkTarget}. */
408
+ /** Signed network edges: leader delivery targets and follower-owned,
409
+ * privacy-bounded record readers. Secret values stay in tenant vaults. */
382
410
  network?: ChapterNetwork;
383
411
  /** Enable a public, host-rendered formation application workflow. */
384
412
  formation?: LeaderFormationConfig;
@@ -587,7 +615,7 @@ type Route = (req: Request, url: URL, env: ChapterEnv, ctx: WorkerContext) => Pr
587
615
  * Build the Cloudflare `ExportedHandler` for a chapter/hub site: Clerk-JWT
588
616
  * verification, the source-aware admin gate, the mounted @odla-ai/crm routes, the
589
617
  * hub→chapter network projection, and the static-asset fallback. Hub mode serves
590
- * /api/health, /api/config, /api/me, /api/crm/*, /api/network/shared; chapter mode
618
+ * /api/health, /api/config, /api/me, /api/crm/*, /api/network/*; chapter mode
591
619
  * adds the public member surface (join/apply/pay/book) and the admin surface
592
620
  * (/api/admin/*).
593
621
  *
@@ -36,7 +36,7 @@ interface ChapterCopy {
36
36
  admin: {
37
37
  auth: TextFields<"checking" | "notAuthorized" | "accountNotAuthorized" | "thisAccount" | "signOut" | "loading" | "signInNotConfigured" | "missingPublishableKey" | "noWorkspaces" | "signInTagline">;
38
38
  shell: TextFields<"adminRole" | "adminConsole" | "adminName" | "navigationLabel">;
39
- workspaces: TextFields<"dashboard" | "overview" | "billing" | "people" | "portfolio" | "settings" | "calendar" | "email" | "chapters" | "formations" | "dealFlow" | "dashboardViewsLabel" | "settingsViewsLabel" | "collectionsLabel" | "portfolioViewsLabel">;
39
+ workspaces: TextFields<"dashboard" | "overview" | "billing" | "people" | "network" | "portfolio" | "settings" | "calendar" | "email" | "chapters" | "formations" | "dealFlow" | "dashboardViewsLabel" | "settingsViewsLabel" | "collectionsLabel" | "portfolioViewsLabel">;
40
40
  dashboard: TextFields<"loading" | "loadFailed" | "upcomingCalls" | "callsNeedAttention" | "noUpcomingCalls" | "drift" | "open" | "meet" | "applications" | "newMembers" | "newMembersUnavailable" | "revenueAdded" | "revenue" | "revenueUnavailable" | "pipeline" | "pipelineLabel" | "thisWeek" | "noChange" | "activeMemberships" | "annualRunRate" | "testMode">;
41
41
  billing: TextFields<"loading" | "loadFailed" | "notConfigured" | "active" | "annualized" | "renewingSoon" | "pastDue" | "subscriptions" | "testMode" | "truncated" | "name" | "email" | "application" | "subscription" | "cancelling" | "amount" | "renews">;
42
42
  availability: TextFields<"loading" | "loadFailed" | "saved" | "title" | "days" | "startHour" | "endHour" | "slotMinutes" | "minNoticeHours" | "windowDays" | "timezone" | "summaryTemplate" | "save"> & {
@@ -74,11 +74,31 @@ interface ChapterNetworkTarget {
74
74
  /** Per-record-type field allowlist. If present, omitted types cannot be sent
75
75
  * to this target. Without it, package-safe person/company defaults apply. */
76
76
  fields?: Record<string, readonly string[]>;
77
- }
78
- /** Leader/follower network configuration. Followers need no code config to
79
- * receive records; they only vault `network_share_secret`. */
77
+ /** Record types on which leader admins may deliberately share notes with
78
+ * this follower. Omitted by default: a target never gains write permission
79
+ * merely because its records are browsable. */
80
+ sharedNotes?: readonly string[];
81
+ }
82
+ /** One signed peer allowed to browse a privacy-bounded projection of this
83
+ * site's CRM. The follower owns this allowlist; a leader target declaration
84
+ * cannot widen it. */
85
+ interface ChapterNetworkReader {
86
+ /** Stable app/site slug expected in the signed federation envelope. */
87
+ id: string;
88
+ /** Per-record-type field allowlist. Omitted types and fields never leave
89
+ * this site. Record id, name, stage, and timestamps are bounded metadata. */
90
+ fields: Record<string, readonly string[]>;
91
+ /** Record types on which this reader may append an explicitly shared note.
92
+ * Omitted by default: browse permission never silently grants write access.
93
+ * Shared notes land in the follower CRM activity feed. */
94
+ sharedNotes?: readonly string[];
95
+ }
96
+ /** Leader/follower network configuration. A follower needs no code config to
97
+ * receive pushed records. It must explicitly declare `readers` before any peer
98
+ * can browse its CRM. */
80
99
  interface ChapterNetwork {
81
100
  targets?: readonly ChapterNetworkTarget[];
101
+ readers?: readonly ChapterNetworkReader[];
82
102
  }
83
103
  /** Public chapter-formation intake backed by one CRM record type. The host owns
84
104
  * the branded form; Chapter owns the bounded, idempotent write contract. */
@@ -113,10 +133,18 @@ interface ResolvedNetworkTarget {
113
133
  binding?: string;
114
134
  secretName: string;
115
135
  fields?: Record<string, readonly string[]>;
136
+ sharedNotes: readonly string[];
137
+ }
138
+ /** A validated follower-owned record projection for one signed reader. */
139
+ interface ResolvedNetworkReader {
140
+ id: string;
141
+ fields: Record<string, readonly string[]>;
142
+ sharedNotes: readonly string[];
116
143
  }
117
144
  /** Fully-resolved leader network configuration. */
118
145
  interface ResolvedNetwork {
119
146
  targets: readonly ResolvedNetworkTarget[];
147
+ readers: readonly ResolvedNetworkReader[];
120
148
  }
121
149
 
122
150
  /** Which feature profile a site runs. `chapter` is the full public member site
@@ -377,8 +405,8 @@ interface ChapterConfig {
377
405
  /** User-visible language for packaged Chapter surfaces. Recursively partial;
378
406
  * `defineChapter()` resolves every omitted leaf from the package defaults. */
379
407
  copy?: ChapterCopyInput;
380
- /** Leader follower delivery targets. Secret values stay in the leader
381
- * tenant vault; see {@link ChapterNetworkTarget}. */
408
+ /** Signed network edges: leader delivery targets and follower-owned,
409
+ * privacy-bounded record readers. Secret values stay in tenant vaults. */
382
410
  network?: ChapterNetwork;
383
411
  /** Enable a public, host-rendered formation application workflow. */
384
412
  formation?: LeaderFormationConfig;
@@ -587,7 +615,7 @@ type Route = (req: Request, url: URL, env: ChapterEnv, ctx: WorkerContext) => Pr
587
615
  * Build the Cloudflare `ExportedHandler` for a chapter/hub site: Clerk-JWT
588
616
  * verification, the source-aware admin gate, the mounted @odla-ai/crm routes, the
589
617
  * hub→chapter network projection, and the static-asset fallback. Hub mode serves
590
- * /api/health, /api/config, /api/me, /api/crm/*, /api/network/shared; chapter mode
618
+ * /api/health, /api/config, /api/me, /api/crm/*, /api/network/*; chapter mode
591
619
  * adds the public member surface (join/apply/pay/book) and the admin surface
592
620
  * (/api/admin/*).
593
621
  *
@@ -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();
@@ -2739,11 +2739,12 @@ var handleAdminNetworkTargets = async (req, url, env, ctx) => {
2739
2739
  const got = await gate4(req, env, ctx);
2740
2740
  if ("response" in got) return got.response;
2741
2741
  return json({
2742
- targets: ctx.chapter.network.targets.map(({ id, name, url: targetUrl, fields }) => ({
2742
+ targets: ctx.chapter.network.targets.map(({ id, name, url: targetUrl, fields, sharedNotes }) => ({
2743
2743
  id,
2744
2744
  name,
2745
2745
  url: targetUrl,
2746
- types: Object.keys(fields ?? DEFAULT_SHARE_FIELDS)
2746
+ types: Object.keys(fields ?? DEFAULT_SHARE_FIELDS),
2747
+ sharedNotes
2747
2748
  }))
2748
2749
  });
2749
2750
  };
@@ -2861,6 +2862,412 @@ var handleAdminNetworkPush = async (req, url, env, ctx) => {
2861
2862
  return json({ ok: results.every((result) => result.ok), results });
2862
2863
  };
2863
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
+
2864
3271
  // src/worker-routes-formation.ts
2865
3272
  import { createRecord as createRecord3 } from "@odla-ai/crm";
2866
3273
 
@@ -2963,6 +3370,8 @@ var BUILTIN_ROUTES = [
2963
3370
  handleCrm,
2964
3371
  handleNetworkShared,
2965
3372
  handleNetworkSnapshot,
3373
+ handleNetworkRecords,
3374
+ handleNetworkSharedNotes,
2966
3375
  handleFormation,
2967
3376
  handleMember,
2968
3377
  handleSchedule,
@@ -2993,6 +3402,9 @@ var BUILTIN_ROUTES = [
2993
3402
  handleAdminNetworkTargets,
2994
3403
  handleAdminNetworkRollup,
2995
3404
  handleAdminNetworkPush,
3405
+ handleAdminNetworkRecords,
3406
+ handleAdminNetworkNotes,
3407
+ handleAdminNetworkSharedNotes,
2996
3408
  // API requests must never fall through to an SPA asset response. Hosts still
2997
3409
  // get first refusal through options.routes, then this terminates unknown API
2998
3410
  // paths with an explicit machine-readable 404.