@odla-ai/chapter 0.26.1 → 0.27.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -3
- package/dist/{chunk-QBTFQUDL.js → chunk-IOHVFBXH.js} +2 -2
- package/dist/{chunk-OIWCKF2G.js → chunk-JAWTIROV.js} +2 -1
- package/dist/chunk-JAWTIROV.js.map +1 -0
- package/dist/{chunk-4QZEAWQL.js → chunk-L2SA47IO.js} +434 -79
- package/dist/chunk-L2SA47IO.js.map +1 -0
- package/dist/{copy-context-Vof2eTMt.d.ts → copy-context-efEMF_aD.d.ts} +33 -5
- package/dist/index.cjs +90 -6
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +84 -11
- package/dist/index.d.ts +84 -11
- package/dist/index.js +90 -6
- package/dist/index.js.map +1 -1
- package/dist/ui/admin/index.d.ts +12 -3
- package/dist/ui/admin/index.js +6 -2
- package/dist/ui/index.d.ts +2 -2
- package/dist/ui/index.js +7 -3
- package/dist/ui/member/index.d.ts +2 -2
- package/dist/ui/member/index.js +2 -2
- package/dist/worker/index.cjs +424 -16
- package/dist/worker/index.cjs.map +1 -1
- package/dist/worker/index.d.cts +35 -7
- package/dist/worker/index.d.ts +35 -7
- package/dist/worker/index.js +426 -14
- package/dist/worker/index.js.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-4QZEAWQL.js.map +0 -1
- package/dist/chunk-OIWCKF2G.js.map +0 -1
- /package/dist/{chunk-QBTFQUDL.js.map → chunk-IOHVFBXH.js.map} +0 -0
package/dist/worker/index.d.cts
CHANGED
|
@@ -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
|
-
|
|
79
|
-
|
|
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
|
-
/**
|
|
381
|
-
*
|
|
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
|
|
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
|
*
|
package/dist/worker/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
79
|
-
|
|
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
|
-
/**
|
|
381
|
-
*
|
|
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
|
|
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
|
*
|
package/dist/worker/index.js
CHANGED
|
@@ -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
|
|
1905
|
-
if (
|
|
1906
|
-
const result = await backfillCrm(crmDeps2(
|
|
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
|
|
1912
|
-
if (
|
|
1913
|
-
const { db } =
|
|
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
|
|
1965
|
-
if (
|
|
1966
|
-
const { db } =
|
|
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
|
|
1978
|
-
if (
|
|
1979
|
-
const { db, actor } =
|
|
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.
|