@promptowl/contextnest-community 1.15.0 → 1.17.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.
- package/CONFIGURATION.md +415 -414
- package/LICENSE.md +142 -142
- package/README.md +164 -169
- package/dist/{chunk-C4U75IUR.js → chunk-4YGJSUJC.js} +52 -59
- package/dist/{chunk-5IAKUUZF.js → chunk-HWDPCPYT.js} +11 -10
- package/dist/{chunk-BCFKLY4H.js → chunk-JMJLNEXE.js} +24 -0
- package/dist/{chunk-7BOG3S5H.js → chunk-SNFGU5Q7.js} +244 -162
- package/dist/{chunk-JWVZ3T35.js → chunk-UXJSO54R.js} +21 -2
- package/dist/{chunk-GWDOLYFJ.js → chunk-YF4OFT2V.js} +2 -2
- package/dist/{client-NFUHPLLM.js → client-OBS5HOTA.js} +1 -1
- package/dist/{grants-service-O4AO326A.js → grants-service-4NXPBYWD.js} +4 -4
- package/dist/index.js +1160 -544
- package/dist/{review-service-JUAFYB73.js → review-service-3CCKDO5N.js} +10 -8
- package/dist/{stewardship-service-FHJ2RBKN.js → stewardship-service-OHTTZFND.js} +3 -3
- package/dist/{version-service-ECYCBVSE.js → version-service-EFZGGL3Y.js} +7 -3
- package/dist/web3/assets/{hootie-C2ocYkn4.svg → hootie-_U3ECslt.svg} +8 -8
- package/dist/web3/assets/index-D1299AIf.css +1 -0
- package/dist/web3/assets/index-W6tEA3RK.js +1046 -0
- package/dist/web3/index.html +14 -14
- package/package.json +165 -164
- package/dist/web3/assets/index-BaMJ4tEz.css +0 -1
- package/dist/web3/assets/index-D950G4hR.js +0 -1052
package/dist/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
addWatcher,
|
|
4
4
|
approve,
|
|
5
5
|
canReadNode,
|
|
6
|
+
canRequestDeletion,
|
|
6
7
|
cancelReview,
|
|
7
8
|
declineDeletion,
|
|
8
9
|
deletionTargetTitle,
|
|
@@ -35,23 +36,21 @@ import {
|
|
|
35
36
|
requireWorkflowPlane,
|
|
36
37
|
resolveCallerEmail,
|
|
37
38
|
safeJson,
|
|
38
|
-
safePublishDocument,
|
|
39
39
|
seedDefaultEdgeTypes,
|
|
40
40
|
sendTestNotification,
|
|
41
|
-
serializeNodeSafe,
|
|
42
|
-
stripUndefinedDeep,
|
|
43
41
|
submitForReview,
|
|
44
|
-
withNodeWriteLock
|
|
45
|
-
|
|
42
|
+
withNodeWriteLock,
|
|
43
|
+
withdrawDeletionRequest
|
|
44
|
+
} from "./chunk-4YGJSUJC.js";
|
|
46
45
|
import {
|
|
47
46
|
createGrant,
|
|
48
47
|
deleteGrant,
|
|
49
48
|
grantCoversNode,
|
|
50
|
-
|
|
49
|
+
grantRoleForNode,
|
|
51
50
|
listGrants,
|
|
52
51
|
listUserGrants,
|
|
53
52
|
resolveNodeGrant
|
|
54
|
-
} from "./chunk-
|
|
53
|
+
} from "./chunk-HWDPCPYT.js";
|
|
55
54
|
import {
|
|
56
55
|
generateApiKey,
|
|
57
56
|
getKeyPrefix,
|
|
@@ -64,13 +63,15 @@ import {
|
|
|
64
63
|
checkConflict,
|
|
65
64
|
createVersion,
|
|
66
65
|
getApprovedVersion,
|
|
66
|
+
getApprovedVersions,
|
|
67
67
|
getCurrentVersion,
|
|
68
68
|
getDisplayStatus,
|
|
69
|
+
getTrackedNodeIds,
|
|
69
70
|
getVersions,
|
|
70
71
|
listMyDrafts,
|
|
71
72
|
setApprovedVersion,
|
|
72
73
|
upsertVersion
|
|
73
|
-
} from "./chunk-
|
|
74
|
+
} from "./chunk-UXJSO54R.js";
|
|
74
75
|
import {
|
|
75
76
|
canCreateInNest,
|
|
76
77
|
canManageStewards,
|
|
@@ -90,7 +91,7 @@ import {
|
|
|
90
91
|
resolveUserRoles,
|
|
91
92
|
syncFromConfig,
|
|
92
93
|
updateSteward
|
|
93
|
-
} from "./chunk-
|
|
94
|
+
} from "./chunk-YF4OFT2V.js";
|
|
94
95
|
import {
|
|
95
96
|
addMember,
|
|
96
97
|
buildTitleMap,
|
|
@@ -101,6 +102,7 @@ import {
|
|
|
101
102
|
deleteTeam,
|
|
102
103
|
disableStewardshipAndWipeGovernance,
|
|
103
104
|
docLink,
|
|
105
|
+
engineApi,
|
|
104
106
|
engineCache,
|
|
105
107
|
findTeamByExternalId,
|
|
106
108
|
folderForNode,
|
|
@@ -136,6 +138,7 @@ import {
|
|
|
136
138
|
nestStorageRoot,
|
|
137
139
|
normalizeTag,
|
|
138
140
|
nowExpr,
|
|
141
|
+
opContext,
|
|
139
142
|
permissionLevel,
|
|
140
143
|
persistSetting,
|
|
141
144
|
removeMember,
|
|
@@ -144,6 +147,7 @@ import {
|
|
|
144
147
|
renameTeam,
|
|
145
148
|
replaceTeamRoster,
|
|
146
149
|
resolveMemberIdentity,
|
|
150
|
+
resolveNestAccess,
|
|
147
151
|
resolveNestPath,
|
|
148
152
|
resolveNestPermission,
|
|
149
153
|
sendEmailToRecipient,
|
|
@@ -161,7 +165,7 @@ import {
|
|
|
161
165
|
updateMemberRole,
|
|
162
166
|
validateLicense,
|
|
163
167
|
verifySmtp
|
|
164
|
-
} from "./chunk-
|
|
168
|
+
} from "./chunk-SNFGU5Q7.js";
|
|
165
169
|
import {
|
|
166
170
|
AppError,
|
|
167
171
|
ConflictError,
|
|
@@ -177,7 +181,7 @@ import {
|
|
|
177
181
|
initDb,
|
|
178
182
|
isEmailListish,
|
|
179
183
|
isEmailish
|
|
180
|
-
} from "./chunk-
|
|
184
|
+
} from "./chunk-JMJLNEXE.js";
|
|
181
185
|
import {
|
|
182
186
|
ANON_EMAIL,
|
|
183
187
|
ANON_USER_ID
|
|
@@ -195,6 +199,29 @@ import { LinearRouter } from "hono/router/linear-router";
|
|
|
195
199
|
import { createMiddleware as createMiddleware2 } from "hono/factory";
|
|
196
200
|
import { cors } from "hono/cors";
|
|
197
201
|
|
|
202
|
+
// src/shared/artifact-sandbox.ts
|
|
203
|
+
var ARTIFACT_SANDBOX_CSP = [
|
|
204
|
+
"sandbox allow-scripts allow-popups allow-modals",
|
|
205
|
+
"default-src 'none'",
|
|
206
|
+
"script-src 'unsafe-inline'",
|
|
207
|
+
"style-src 'unsafe-inline'",
|
|
208
|
+
"img-src data: blob:",
|
|
209
|
+
"font-src data:",
|
|
210
|
+
"connect-src 'none'",
|
|
211
|
+
"base-uri 'none'",
|
|
212
|
+
"form-action 'none'"
|
|
213
|
+
].join("; ");
|
|
214
|
+
function mergeFrameAncestors(existing, directive) {
|
|
215
|
+
if (!existing) return directive;
|
|
216
|
+
if (/(^|;)\s*frame-ancestors\b/i.test(existing)) return existing;
|
|
217
|
+
return `${existing}; ${directive}`;
|
|
218
|
+
}
|
|
219
|
+
function applyArtifactSandboxHeaders(c) {
|
|
220
|
+
c.header("Content-Security-Policy", ARTIFACT_SANDBOX_CSP);
|
|
221
|
+
c.header("X-Content-Type-Options", "nosniff");
|
|
222
|
+
c.header("Referrer-Policy", "no-referrer");
|
|
223
|
+
}
|
|
224
|
+
|
|
198
225
|
// src/auth/routes.ts
|
|
199
226
|
import { Hono as Hono2 } from "hono";
|
|
200
227
|
import { v4 as uuid2 } from "uuid";
|
|
@@ -1580,17 +1607,42 @@ authRoutes.get("/teammates", async (c) => {
|
|
|
1580
1607
|
|
|
1581
1608
|
// src/nests/routes.ts
|
|
1582
1609
|
import { Hono as Hono3 } from "hono";
|
|
1583
|
-
import { streamText } from "hono/streaming";
|
|
1584
1610
|
|
|
1585
1611
|
// src/nodes/service.ts
|
|
1586
1612
|
import {
|
|
1587
|
-
serializeDocument,
|
|
1588
1613
|
parseDocument as parseDocument2,
|
|
1589
1614
|
normalizeStatus,
|
|
1615
|
+
isRejected,
|
|
1590
1616
|
DocumentNotFoundError
|
|
1591
1617
|
} from "@promptowl/contextnest-engine";
|
|
1592
|
-
import { rename, mkdir
|
|
1593
|
-
import { dirname, join as join2
|
|
1618
|
+
import { rename, mkdir } from "fs/promises";
|
|
1619
|
+
import { dirname, join as join2 } from "path";
|
|
1620
|
+
|
|
1621
|
+
// src/governance/safe-publish.ts
|
|
1622
|
+
import {
|
|
1623
|
+
publishDocument,
|
|
1624
|
+
serializeDocument
|
|
1625
|
+
} from "@promptowl/contextnest-engine";
|
|
1626
|
+
function serializeNodeSafe(node) {
|
|
1627
|
+
return serializeDocument({
|
|
1628
|
+
...node,
|
|
1629
|
+
frontmatter: stripUndefinedDeep(node.frontmatter)
|
|
1630
|
+
});
|
|
1631
|
+
}
|
|
1632
|
+
function stripUndefinedDeep(value) {
|
|
1633
|
+
if (Array.isArray(value)) {
|
|
1634
|
+
return value.filter((v) => v !== void 0).map((v) => stripUndefinedDeep(v));
|
|
1635
|
+
}
|
|
1636
|
+
if (value && typeof value === "object") {
|
|
1637
|
+
const out = {};
|
|
1638
|
+
for (const [k, v] of Object.entries(value)) {
|
|
1639
|
+
if (v === void 0) continue;
|
|
1640
|
+
out[k] = stripUndefinedDeep(v);
|
|
1641
|
+
}
|
|
1642
|
+
return out;
|
|
1643
|
+
}
|
|
1644
|
+
return value;
|
|
1645
|
+
}
|
|
1594
1646
|
|
|
1595
1647
|
// src/governance/prime-service.ts
|
|
1596
1648
|
async function isPrimeDocument(nestId, tags, metadata) {
|
|
@@ -1615,6 +1667,7 @@ import { join } from "path";
|
|
|
1615
1667
|
import {
|
|
1616
1668
|
detectDrift,
|
|
1617
1669
|
stageSuggestion,
|
|
1670
|
+
ChainEventLog,
|
|
1618
1671
|
approveSuggestion,
|
|
1619
1672
|
rejectSuggestion,
|
|
1620
1673
|
listSuggestions,
|
|
@@ -1697,7 +1750,8 @@ async function scanDocumentForDriftInternal(nestId, documentId, actor) {
|
|
|
1697
1750
|
return { meta: result.meta, created: true };
|
|
1698
1751
|
}
|
|
1699
1752
|
async function scanNestForDrift(nestId, actor = "system:scanner") {
|
|
1700
|
-
const { storage } = await engineCache.get(nestId);
|
|
1753
|
+
const { storage, invalidateDiscovery } = await engineCache.get(nestId);
|
|
1754
|
+
invalidateDiscovery();
|
|
1701
1755
|
const docs = await storage.discoverDocuments();
|
|
1702
1756
|
const results = await Promise.all(
|
|
1703
1757
|
docs.map((doc) => scanDocumentForDriftInternal(nestId, doc.id, actor))
|
|
@@ -1768,6 +1822,68 @@ async function getExternalEditDetail(nestId, documentId, suggestionId) {
|
|
|
1768
1822
|
patch: found.patch
|
|
1769
1823
|
};
|
|
1770
1824
|
}
|
|
1825
|
+
async function recordVerdict(nestId, event) {
|
|
1826
|
+
try {
|
|
1827
|
+
const { storage } = await engineCache.get(nestId);
|
|
1828
|
+
await new ChainEventLog(storage).append(event);
|
|
1829
|
+
return true;
|
|
1830
|
+
} catch (err) {
|
|
1831
|
+
console.error(
|
|
1832
|
+
`[external-edit] failed to record verdict ${event.event_id} for nest ${nestId}:`,
|
|
1833
|
+
err
|
|
1834
|
+
);
|
|
1835
|
+
return false;
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
async function listExternalEditVerdicts(nestId, documentId) {
|
|
1839
|
+
try {
|
|
1840
|
+
const { storage } = await engineCache.get(nestId);
|
|
1841
|
+
const events = await new ChainEventLog(storage).readByDocument(documentId);
|
|
1842
|
+
return events.flatMap((e) => {
|
|
1843
|
+
const meta = e.action_metadata || {};
|
|
1844
|
+
if (typeof meta.suggestion_id !== "string") return [];
|
|
1845
|
+
const reason = meta.rejection_reason;
|
|
1846
|
+
const comment = meta.approval_comment;
|
|
1847
|
+
return [
|
|
1848
|
+
{
|
|
1849
|
+
suggestion_id: meta.suggestion_id,
|
|
1850
|
+
status: typeof reason === "string" ? "rejected" : "approved",
|
|
1851
|
+
actor: e.actor,
|
|
1852
|
+
at: e.timestamp,
|
|
1853
|
+
note: typeof reason === "string" ? reason : typeof comment === "string" ? comment : void 0,
|
|
1854
|
+
source: typeof meta.source === "string" ? meta.source : void 0
|
|
1855
|
+
}
|
|
1856
|
+
];
|
|
1857
|
+
});
|
|
1858
|
+
} catch (err) {
|
|
1859
|
+
console.error(
|
|
1860
|
+
`[external-edit] failed to read verdicts for ${nestId}/${documentId}:`,
|
|
1861
|
+
err
|
|
1862
|
+
);
|
|
1863
|
+
return [];
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
async function mirrorVersion(input) {
|
|
1867
|
+
const { storage } = await engineCache.get(input.nestId);
|
|
1868
|
+
const node = await storage.readDocument(input.documentId);
|
|
1869
|
+
const { upsertVersion: upsertVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-EFZGGL3Y.js");
|
|
1870
|
+
await upsertVersion2({
|
|
1871
|
+
nestId: input.nestId,
|
|
1872
|
+
nodeId: input.documentId,
|
|
1873
|
+
version: input.version,
|
|
1874
|
+
content: node.body || "",
|
|
1875
|
+
author: input.actor,
|
|
1876
|
+
status: "published",
|
|
1877
|
+
tags: node.frontmatter.tags || [],
|
|
1878
|
+
changeNote: input.note
|
|
1879
|
+
});
|
|
1880
|
+
await setApprovedVersion2(
|
|
1881
|
+
input.nestId,
|
|
1882
|
+
input.documentId,
|
|
1883
|
+
input.version,
|
|
1884
|
+
input.actor
|
|
1885
|
+
);
|
|
1886
|
+
}
|
|
1771
1887
|
async function approveExternalEdit(input) {
|
|
1772
1888
|
const { storage } = await engineCache.get(input.nestId);
|
|
1773
1889
|
let result;
|
|
@@ -1789,33 +1905,21 @@ async function approveExternalEdit(input) {
|
|
|
1789
1905
|
throw err;
|
|
1790
1906
|
}
|
|
1791
1907
|
try {
|
|
1792
|
-
|
|
1793
|
-
const versionNum = result.versionEntry.version;
|
|
1794
|
-
const tags = node.frontmatter.tags || [];
|
|
1795
|
-
const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-ECYCBVSE.js");
|
|
1796
|
-
await createVersion2({
|
|
1908
|
+
await mirrorVersion({
|
|
1797
1909
|
nestId: input.nestId,
|
|
1798
|
-
|
|
1799
|
-
version:
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
status: "published",
|
|
1803
|
-
tags,
|
|
1804
|
-
changeNote: input.comment || "External edit approved"
|
|
1910
|
+
documentId: input.documentId,
|
|
1911
|
+
version: result.versionEntry.version,
|
|
1912
|
+
actor: input.actor,
|
|
1913
|
+
note: input.comment ? `External edit approved: ${input.comment}` : "External edit approved"
|
|
1805
1914
|
});
|
|
1806
|
-
await setApprovedVersion2(
|
|
1807
|
-
input.nestId,
|
|
1808
|
-
input.documentId,
|
|
1809
|
-
versionNum,
|
|
1810
|
-
input.actor
|
|
1811
|
-
);
|
|
1812
1915
|
} catch (err) {
|
|
1813
1916
|
console.error(
|
|
1814
1917
|
`[external-edit] failed to mirror approved version into node_versions for ${input.nestId}/${input.documentId}:`,
|
|
1815
1918
|
err
|
|
1816
1919
|
);
|
|
1817
1920
|
}
|
|
1818
|
-
|
|
1921
|
+
const historyRecorded = await recordVerdict(input.nestId, result.chainEvent);
|
|
1922
|
+
return { ...result, historyRecorded };
|
|
1819
1923
|
}
|
|
1820
1924
|
async function rejectExternalEdit(input) {
|
|
1821
1925
|
const { storage } = await engineCache.get(input.nestId);
|
|
@@ -1828,6 +1932,7 @@ async function rejectExternalEdit(input) {
|
|
|
1828
1932
|
zone: "default",
|
|
1829
1933
|
reason: input.reason
|
|
1830
1934
|
});
|
|
1935
|
+
const historyRecorded = await recordVerdict(input.nestId, result.chainEvent);
|
|
1831
1936
|
try {
|
|
1832
1937
|
const approved = await loadChainHead(storage, input.documentId);
|
|
1833
1938
|
if (approved) {
|
|
@@ -1839,7 +1944,7 @@ async function rejectExternalEdit(input) {
|
|
|
1839
1944
|
err
|
|
1840
1945
|
);
|
|
1841
1946
|
}
|
|
1842
|
-
return result;
|
|
1947
|
+
return { ...result, historyRecorded };
|
|
1843
1948
|
}
|
|
1844
1949
|
var scannerTimer = null;
|
|
1845
1950
|
async function scanAllNests() {
|
|
@@ -1881,12 +1986,24 @@ function assertTitleLength(title) {
|
|
|
1881
1986
|
);
|
|
1882
1987
|
}
|
|
1883
1988
|
}
|
|
1989
|
+
function assertTitleSluggable(title) {
|
|
1990
|
+
if (!slugify(title)) {
|
|
1991
|
+
throw new ValidationError(
|
|
1992
|
+
"the document id is built from the title using a-z and 0-9, so the title needs at least one of those"
|
|
1993
|
+
);
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1884
1996
|
var slugify = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1885
1997
|
function isIdUnderFolder(id, folder) {
|
|
1886
1998
|
return id.startsWith(`nodes/${folder}/`) || id.startsWith(`${folder}/`);
|
|
1887
1999
|
}
|
|
1888
2000
|
function folderToSegments(folder) {
|
|
1889
2001
|
const segments = folder.split("/").map(slugify).filter(Boolean);
|
|
2002
|
+
if (folder.trim() && segments.length === 0) {
|
|
2003
|
+
throw new ValidationError(
|
|
2004
|
+
"the folder path is built from the name using a-z and 0-9, so the name needs at least one of those"
|
|
2005
|
+
);
|
|
2006
|
+
}
|
|
1890
2007
|
if (segments[0] === "nodes") segments.shift();
|
|
1891
2008
|
if (segments.length > 8) {
|
|
1892
2009
|
throw new ValidationError("folder may nest at most 8 levels deep");
|
|
@@ -1898,7 +2015,6 @@ function folderToSegments(folder) {
|
|
|
1898
2015
|
}
|
|
1899
2016
|
return segments;
|
|
1900
2017
|
}
|
|
1901
|
-
var stripUndefined = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== void 0));
|
|
1902
2018
|
async function departmentTagFor(userEmail) {
|
|
1903
2019
|
if (!config.OIDC_DEPARTMENT_TAGGING) return null;
|
|
1904
2020
|
try {
|
|
@@ -1915,6 +2031,12 @@ async function departmentTagFor(userEmail) {
|
|
|
1915
2031
|
}
|
|
1916
2032
|
function toSafeError(err, context, message) {
|
|
1917
2033
|
if (err instanceof AppError) return err;
|
|
2034
|
+
if (err?.code === "REJECTED_DOCUMENT") {
|
|
2035
|
+
console.error(`${context}:`, err);
|
|
2036
|
+
return new ConflictError(
|
|
2037
|
+
"This document is rejected. Change its status before saving."
|
|
2038
|
+
);
|
|
2039
|
+
}
|
|
1918
2040
|
console.error(`${context}:`, err);
|
|
1919
2041
|
return new AppError(500, message);
|
|
1920
2042
|
}
|
|
@@ -2005,28 +2127,86 @@ async function resolveAuthorship(nestId, nodes) {
|
|
|
2005
2127
|
}
|
|
2006
2128
|
return result;
|
|
2007
2129
|
}
|
|
2008
|
-
async function
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
const wanted = new Set(
|
|
2013
|
-
Array.isArray(filters.type) ? filters.type : [filters.type]
|
|
2130
|
+
async function getNodeForCaller(nestId, selector, userId, userEmail, opts = {}) {
|
|
2131
|
+
if (selector.id && !await canReadNode(nestId, selector.id, userId, userEmail)) {
|
|
2132
|
+
throw new ForbiddenError(
|
|
2133
|
+
"Access denied \u2014 no steward assignment for this node"
|
|
2014
2134
|
);
|
|
2015
|
-
documents = documents.filter((n) => wanted.has(n.frontmatter.type));
|
|
2016
2135
|
}
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2136
|
+
let got;
|
|
2137
|
+
try {
|
|
2138
|
+
got = await engineApi.run(
|
|
2139
|
+
"context_get",
|
|
2140
|
+
{
|
|
2141
|
+
...selector,
|
|
2142
|
+
include_raw: true,
|
|
2143
|
+
// A retired node stays readable here: its stewards decide whether to
|
|
2144
|
+
// revive it, and they cannot do that if the read refuses.
|
|
2145
|
+
allow_rejected: true,
|
|
2146
|
+
...opts.verifyChecksum ? { verify_checksum: true } : {}
|
|
2147
|
+
},
|
|
2148
|
+
await opContext(nestId, userEmail)
|
|
2021
2149
|
);
|
|
2150
|
+
} catch (err) {
|
|
2151
|
+
if (err?.code !== "DOCUMENT_NOT_FOUND") {
|
|
2152
|
+
console.error(`getNodeForCaller failed (${nestId}/${selector.id ?? selector.title})`, err);
|
|
2153
|
+
}
|
|
2154
|
+
throw new NotFoundError(`Node not found: ${selector.id ?? selector.title}`);
|
|
2022
2155
|
}
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
accessible = grants.length ? documents.filter((d) => grantCoversNode(grants, d.id)) : accessible;
|
|
2156
|
+
if (!selector.id && !await canReadNode(nestId, got.id, userId, userEmail)) {
|
|
2157
|
+
throw new ForbiddenError(
|
|
2158
|
+
"Access denied \u2014 no steward assignment for this node"
|
|
2159
|
+
);
|
|
2028
2160
|
}
|
|
2161
|
+
return {
|
|
2162
|
+
id: got.id,
|
|
2163
|
+
filePath: "",
|
|
2164
|
+
rawContent: got.raw ?? "",
|
|
2165
|
+
frontmatter: got.frontmatter,
|
|
2166
|
+
body: got.body,
|
|
2167
|
+
...got.pendingChange ? { pendingChange: got.pendingChange } : {}
|
|
2168
|
+
};
|
|
2169
|
+
}
|
|
2170
|
+
async function listNodesForCaller(nestId, userId, filters = {}) {
|
|
2171
|
+
const { versions: versionManager } = await engineCache.get(nestId);
|
|
2172
|
+
const userEmail = await resolveCallerEmail(userId);
|
|
2173
|
+
const { documents } = await engineApi.run(
|
|
2174
|
+
"context_list",
|
|
2175
|
+
{
|
|
2176
|
+
...filters.type ? { type: filters.type } : {},
|
|
2177
|
+
...filters.tag ? { tag: filters.tag } : {},
|
|
2178
|
+
...filters.status ? { status: filters.status } : {},
|
|
2179
|
+
// Unlike the CLI and mcp-server, a retired node stays in this listing:
|
|
2180
|
+
// here it is an unpublished document its stewards may still need to act
|
|
2181
|
+
// on, not one removed from retrieval. An imported vault carrying
|
|
2182
|
+
// `status: rejected` registers exactly that way.
|
|
2183
|
+
include_retired: true,
|
|
2184
|
+
full: true
|
|
2185
|
+
},
|
|
2186
|
+
await opContext(nestId, userEmail)
|
|
2187
|
+
);
|
|
2188
|
+
const nodes = documents.map((d) => ({
|
|
2189
|
+
id: d.id,
|
|
2190
|
+
filePath: "",
|
|
2191
|
+
rawContent: "",
|
|
2192
|
+
frontmatter: d.frontmatter,
|
|
2193
|
+
body: d.body
|
|
2194
|
+
}));
|
|
2029
2195
|
const publicReader = await isPublicReader(nestId, userId);
|
|
2196
|
+
const approvedOnly = publicReader || filters.approvedOnly === true;
|
|
2197
|
+
const approvedVersions = approvedOnly ? await getApprovedVersions(nestId) : null;
|
|
2198
|
+
let accessible = await filterAccessible(
|
|
2199
|
+
nestId,
|
|
2200
|
+
userId,
|
|
2201
|
+
userEmail,
|
|
2202
|
+
nodes,
|
|
2203
|
+
approvedVersions ?? void 0
|
|
2204
|
+
);
|
|
2205
|
+
const myGrants = await listUserGrants(nestId, userId);
|
|
2206
|
+
if (approvedVersions && !publicReader) {
|
|
2207
|
+
accessible = accessible.filter((d) => approvedVersions.has(d.id));
|
|
2208
|
+
}
|
|
2209
|
+
if (filters.limit) accessible = accessible.slice(0, filters.limit);
|
|
2030
2210
|
const authorship = publicReader ? null : await resolveAuthorship(
|
|
2031
2211
|
nestId,
|
|
2032
2212
|
accessible.map((d) => ({
|
|
@@ -2042,8 +2222,11 @@ async function listNodesForCaller(nestId, userId, filters = {}) {
|
|
|
2042
2222
|
r.author = people.author;
|
|
2043
2223
|
r.collaborators = people.collaborators;
|
|
2044
2224
|
}
|
|
2045
|
-
if (
|
|
2046
|
-
|
|
2225
|
+
if (myGrants.length) {
|
|
2226
|
+
r.myGrant = grantRoleForNode(myGrants, doc.id);
|
|
2227
|
+
}
|
|
2228
|
+
if (approvedVersions) {
|
|
2229
|
+
const approved = approvedVersions.get(doc.id);
|
|
2047
2230
|
if (approved != null) {
|
|
2048
2231
|
try {
|
|
2049
2232
|
const raw = await versionManager.reconstructVersion(
|
|
@@ -2080,7 +2263,7 @@ async function listNodesForCaller(nestId, userId, filters = {}) {
|
|
|
2080
2263
|
return r;
|
|
2081
2264
|
})
|
|
2082
2265
|
);
|
|
2083
|
-
return
|
|
2266
|
+
return enriched;
|
|
2084
2267
|
}
|
|
2085
2268
|
async function listNodesForCallerByEmail(nestId, userEmail, filters = {}) {
|
|
2086
2269
|
return listNodesForCaller(nestId, await userIdFromEmail(userEmail), filters);
|
|
@@ -2109,13 +2292,13 @@ async function autoPublishReason(nestId, userEmail, doc) {
|
|
|
2109
2292
|
async function createNode(nestId, input, userEmail) {
|
|
2110
2293
|
const { storage, versions: versionManager } = await engineCache.get(nestId);
|
|
2111
2294
|
assertTitleLength(input.title);
|
|
2295
|
+
if (!input.id) assertTitleSluggable(input.title);
|
|
2112
2296
|
const slug = slugify(input.title).slice(0, 100).replace(/-+$/, "");
|
|
2113
2297
|
let id = input.id;
|
|
2114
2298
|
if (!id) {
|
|
2115
2299
|
const folderSegments = folderToSegments(input.folder ?? "");
|
|
2116
2300
|
id = folderSegments.length > 0 ? `nodes/${folderSegments.join("/")}/${slug}` : `nodes/${slug}`;
|
|
2117
2301
|
}
|
|
2118
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2119
2302
|
const tags = (input.tags || []).map(normalizeTag2);
|
|
2120
2303
|
const requestedType = input.type || "document";
|
|
2121
2304
|
if (requestedType === "artifact" && !config.TYPE_ARTIFACT_ENABLED) {
|
|
@@ -2172,33 +2355,62 @@ async function createNode(nestId, input, userEmail) {
|
|
|
2172
2355
|
tags,
|
|
2173
2356
|
metadata
|
|
2174
2357
|
});
|
|
2175
|
-
const
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2358
|
+
const publishNote = publishReason === null ? void 0 : `Auto-published on create (${publishReason})`;
|
|
2359
|
+
let published = publishReason !== null;
|
|
2360
|
+
try {
|
|
2361
|
+
await engineApi.run(
|
|
2362
|
+
"context_create",
|
|
2363
|
+
{
|
|
2364
|
+
id,
|
|
2365
|
+
title: input.title,
|
|
2366
|
+
content: input.content,
|
|
2367
|
+
type: requestedType,
|
|
2368
|
+
// A skill node is invalid without a skill block, and this surface has
|
|
2369
|
+
// no field for one — default the trigger from the title, same as the
|
|
2370
|
+
// CLI and mcp-server do.
|
|
2371
|
+
...requestedType === "skill" ? { trigger: `when asked to ${input.title.toLowerCase()}` } : {},
|
|
2372
|
+
...tags.length ? { tags } : {},
|
|
2373
|
+
...input.status ? { status: input.status } : {},
|
|
2374
|
+
metadata,
|
|
2375
|
+
publish: published,
|
|
2376
|
+
...publishNote ? { note: publishNote } : {}
|
|
2377
|
+
},
|
|
2378
|
+
await opContext(nestId, userEmail)
|
|
2379
|
+
);
|
|
2380
|
+
} catch (err) {
|
|
2381
|
+
let onDisk = true;
|
|
2382
|
+
try {
|
|
2383
|
+
await storage.readDocument(id);
|
|
2384
|
+
} catch {
|
|
2385
|
+
onDisk = false;
|
|
2386
|
+
}
|
|
2387
|
+
if (!onDisk) throw err;
|
|
2388
|
+
console.error("publishDocument failed (node create auto-publish)", err);
|
|
2389
|
+
published = false;
|
|
2390
|
+
}
|
|
2391
|
+
const node = await storage.readDocument(id);
|
|
2195
2392
|
await syncNodeTags(nestId, id, tags);
|
|
2196
2393
|
let savedVersion = 1;
|
|
2197
|
-
if (
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2394
|
+
if (published) {
|
|
2395
|
+
savedVersion = node.frontmatter.version || 1;
|
|
2396
|
+
await createVersion({
|
|
2397
|
+
nestId,
|
|
2398
|
+
nodeId: id,
|
|
2399
|
+
version: savedVersion,
|
|
2400
|
+
content: node.body || "",
|
|
2401
|
+
author: userEmail,
|
|
2402
|
+
status: "published",
|
|
2403
|
+
tags,
|
|
2404
|
+
changeNote: publishNote
|
|
2405
|
+
});
|
|
2406
|
+
await setApprovedVersion(nestId, id, savedVersion, userEmail);
|
|
2407
|
+
} else {
|
|
2408
|
+
if (publishReason === null) {
|
|
2409
|
+
try {
|
|
2410
|
+
await versionManager.createVersion(node, userEmail);
|
|
2411
|
+
} catch (err) {
|
|
2412
|
+
console.error("VersionManager.createVersion failed (node create)", err);
|
|
2413
|
+
}
|
|
2202
2414
|
}
|
|
2203
2415
|
await createVersion({
|
|
2204
2416
|
nestId,
|
|
@@ -2209,170 +2421,100 @@ async function createNode(nestId, input, userEmail) {
|
|
|
2209
2421
|
status: "draft",
|
|
2210
2422
|
tags
|
|
2211
2423
|
});
|
|
2212
|
-
} else {
|
|
2213
|
-
const publishNote = `Auto-published on create (${publishReason})`;
|
|
2214
|
-
try {
|
|
2215
|
-
const result = await safePublishDocument(storage, id, {
|
|
2216
|
-
editedBy: userEmail,
|
|
2217
|
-
note: publishNote
|
|
2218
|
-
});
|
|
2219
|
-
savedVersion = result.node.frontmatter.version || 1;
|
|
2220
|
-
await createVersion({
|
|
2221
|
-
nestId,
|
|
2222
|
-
nodeId: id,
|
|
2223
|
-
version: savedVersion,
|
|
2224
|
-
content: result.node.body || "",
|
|
2225
|
-
author: userEmail,
|
|
2226
|
-
status: "published",
|
|
2227
|
-
tags,
|
|
2228
|
-
changeNote: publishNote
|
|
2229
|
-
});
|
|
2230
|
-
await setApprovedVersion(nestId, id, savedVersion, userEmail);
|
|
2231
|
-
node = result.node;
|
|
2232
|
-
} catch (err) {
|
|
2233
|
-
console.error("publishDocument failed (node create auto-publish)", err);
|
|
2234
|
-
await createVersion({
|
|
2235
|
-
nestId,
|
|
2236
|
-
nodeId: id,
|
|
2237
|
-
version: 1,
|
|
2238
|
-
content: input.content,
|
|
2239
|
-
author: userEmail,
|
|
2240
|
-
status: "draft",
|
|
2241
|
-
tags
|
|
2242
|
-
});
|
|
2243
|
-
}
|
|
2244
2424
|
}
|
|
2245
2425
|
await trackEvent("node.create", { nestId, nodeId: id });
|
|
2246
2426
|
return { node, version: savedVersion };
|
|
2247
2427
|
});
|
|
2248
2428
|
}
|
|
2249
|
-
function
|
|
2250
|
-
const
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
return
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
async function registerImportedDocuments(nestId, userEmail, onProgress) {
|
|
2270
|
-
const { storage } = await engineCache.get(nestId);
|
|
2271
|
-
let docs;
|
|
2429
|
+
async function stageImportedFiles(nestId, files, userEmail) {
|
|
2430
|
+
const carried = files.filter(
|
|
2431
|
+
(f) => !String(f?.path || "").split(/[\\/]/).includes("_suggestions")
|
|
2432
|
+
);
|
|
2433
|
+
if (!carried.length) return { written: 0, rejected: [] };
|
|
2434
|
+
const result = await engineApi.run(
|
|
2435
|
+
"context_import",
|
|
2436
|
+
{ files: carried, publish: false },
|
|
2437
|
+
await opContext(nestId, userEmail)
|
|
2438
|
+
);
|
|
2439
|
+
const rejected = (result.failed ?? []).map((f) => f.id ?? "(unnamed)");
|
|
2440
|
+
if (rejected.length) {
|
|
2441
|
+
console.warn(
|
|
2442
|
+
`[import] ${rejected.length} file(s) rejected:`,
|
|
2443
|
+
(result.failed ?? []).map((f) => `${f.id}: ${f.error}`).join(", ")
|
|
2444
|
+
);
|
|
2445
|
+
}
|
|
2446
|
+
return { written: result.written ?? 0, rejected };
|
|
2447
|
+
}
|
|
2448
|
+
async function alignOnDiskStatus(nestId, documentId, status) {
|
|
2272
2449
|
try {
|
|
2273
|
-
|
|
2450
|
+
const { storage } = await engineCache.get(nestId);
|
|
2451
|
+
const node = await storage.readDocument(documentId);
|
|
2452
|
+
if (node.frontmatter.status === status) return;
|
|
2453
|
+
await storage.writeDocument(
|
|
2454
|
+
documentId,
|
|
2455
|
+
serializeNodeSafe({
|
|
2456
|
+
...node,
|
|
2457
|
+
frontmatter: { ...node.frontmatter, status }
|
|
2458
|
+
})
|
|
2459
|
+
);
|
|
2274
2460
|
} catch (err) {
|
|
2275
|
-
console.
|
|
2276
|
-
|
|
2461
|
+
console.warn(
|
|
2462
|
+
`[import] could not align on-disk status for ${nestId}/${documentId}:`,
|
|
2463
|
+
err
|
|
2464
|
+
);
|
|
2277
2465
|
}
|
|
2466
|
+
}
|
|
2467
|
+
async function registerImportedDocuments(nestId, userEmail) {
|
|
2278
2468
|
const startedAt = Date.now();
|
|
2279
|
-
|
|
2280
|
-
await
|
|
2281
|
-
let
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
console.error("import: failed to persist import metadata", nodeId, err);
|
|
2300
|
-
}
|
|
2301
|
-
const rawTags = Array.isArray(doc.frontmatter?.tags) ? doc.frontmatter.tags : [];
|
|
2302
|
-
const tags = rawTags.map((t) => normalizeTag2(String(t)));
|
|
2303
|
-
const fmVersion = Number(doc.frontmatter?.version);
|
|
2304
|
-
let version = Number.isInteger(fmVersion) && fmVersion > 0 ? fmVersion : 1;
|
|
2305
|
-
let content = doc.body || "";
|
|
2306
|
-
const status = explicitFrontmatterStatus(doc.rawContent);
|
|
2307
|
-
if (status && status !== "published" && status !== "approved") {
|
|
2308
|
-
await createVersion({
|
|
2309
|
-
nestId,
|
|
2310
|
-
nodeId,
|
|
2311
|
-
version,
|
|
2312
|
-
content,
|
|
2313
|
-
author: userEmail,
|
|
2314
|
-
status: "draft",
|
|
2315
|
-
changeNote: "Imported from existing folder (kept unpublished)",
|
|
2316
|
-
tags
|
|
2317
|
-
});
|
|
2318
|
-
await syncNodeTags(nestId, nodeId, tags);
|
|
2319
|
-
keptUnpublished++;
|
|
2320
|
-
registered++;
|
|
2321
|
-
continue;
|
|
2322
|
-
}
|
|
2323
|
-
let published = false;
|
|
2324
|
-
try {
|
|
2325
|
-
const result = await safePublishDocument(storage, nodeId, {
|
|
2326
|
-
editedBy: userEmail,
|
|
2327
|
-
note: "Imported from existing folder"
|
|
2328
|
-
});
|
|
2329
|
-
version = result.node.frontmatter.version || version;
|
|
2330
|
-
content = result.node.body || content;
|
|
2331
|
-
published = true;
|
|
2332
|
-
} catch (err) {
|
|
2333
|
-
console.warn(
|
|
2334
|
-
`[import] publish retry needed for ${nodeId}: ${err?.message ?? err}`
|
|
2335
|
-
);
|
|
2336
|
-
try {
|
|
2337
|
-
await resetImportedDocForPublish(storage, nestId, nodeId);
|
|
2338
|
-
const result = await safePublishDocument(storage, nodeId, {
|
|
2339
|
-
editedBy: userEmail,
|
|
2340
|
-
note: "Imported from existing folder (reset chain)"
|
|
2341
|
-
});
|
|
2342
|
-
version = result.node.frontmatter.version || 1;
|
|
2343
|
-
content = result.node.body || content;
|
|
2344
|
-
published = true;
|
|
2345
|
-
publishRecovered++;
|
|
2346
|
-
} catch (err2) {
|
|
2347
|
-
publishFailed++;
|
|
2348
|
-
console.error(
|
|
2349
|
-
"reset+republish failed (import register)",
|
|
2350
|
-
nodeId,
|
|
2351
|
-
err2
|
|
2352
|
-
);
|
|
2353
|
-
}
|
|
2354
|
-
}
|
|
2469
|
+
const tracked = await getTrackedNodeIds(nestId);
|
|
2470
|
+
(await engineCache.get(nestId)).invalidateDiscovery();
|
|
2471
|
+
let result;
|
|
2472
|
+
try {
|
|
2473
|
+
result = await engineApi.run(
|
|
2474
|
+
"context_import",
|
|
2475
|
+
{ discover: true, author: userEmail, exclude_ids: [...tracked] },
|
|
2476
|
+
await opContext(nestId, userEmail)
|
|
2477
|
+
);
|
|
2478
|
+
} catch (err) {
|
|
2479
|
+
console.error("[import] register failed", nestId, err);
|
|
2480
|
+
throw new Error(
|
|
2481
|
+
`Failed to import documents: ${err?.message ?? String(err)}`
|
|
2482
|
+
);
|
|
2483
|
+
}
|
|
2484
|
+
const docs = result.documents ?? [];
|
|
2485
|
+
const engineMs = Date.now() - startedAt;
|
|
2486
|
+
const dbStartedAt = Date.now();
|
|
2487
|
+
for (const d of docs) {
|
|
2488
|
+
const tags = d.tags.map((t) => normalizeTag2(String(t)));
|
|
2355
2489
|
await createVersion({
|
|
2356
2490
|
nestId,
|
|
2357
|
-
nodeId,
|
|
2358
|
-
version,
|
|
2359
|
-
content,
|
|
2491
|
+
nodeId: d.id,
|
|
2492
|
+
version: d.version,
|
|
2493
|
+
content: d.content,
|
|
2360
2494
|
author: userEmail,
|
|
2361
|
-
status:
|
|
2362
|
-
changeNote: "Imported from existing folder",
|
|
2495
|
+
status: d.status,
|
|
2496
|
+
changeNote: d.status === "published" ? "Imported from existing folder" : "Imported from existing folder \u2014 could not be published, kept as a draft",
|
|
2363
2497
|
tags
|
|
2364
2498
|
});
|
|
2365
|
-
if (published) {
|
|
2366
|
-
await setApprovedVersion(nestId,
|
|
2499
|
+
if (d.status === "published") {
|
|
2500
|
+
await setApprovedVersion(nestId, d.id, d.version, userEmail);
|
|
2501
|
+
} else {
|
|
2502
|
+
await alignOnDiskStatus(nestId, d.id, d.status);
|
|
2367
2503
|
}
|
|
2368
|
-
await syncNodeTags(nestId,
|
|
2369
|
-
registered++;
|
|
2504
|
+
await syncNodeTags(nestId, d.id, tags);
|
|
2370
2505
|
}
|
|
2506
|
+
const published = docs.filter((d) => d.status === "published").length;
|
|
2371
2507
|
console.log(
|
|
2372
|
-
`[import] register done: registered=${
|
|
2508
|
+
`[import] register done: registered=${docs.length} published=${published} kept=${docs.length - published} failed=${result.failed.length} alreadyTracked=${tracked.size} engineMs=${engineMs} dbMs=${Date.now() - dbStartedAt}`
|
|
2373
2509
|
);
|
|
2374
|
-
|
|
2375
|
-
|
|
2510
|
+
if (result.failed.length) {
|
|
2511
|
+
console.warn(
|
|
2512
|
+
"[import] documents that would not publish:",
|
|
2513
|
+
result.failed.map((f) => `${f.id}: ${f.error}`).join(", ")
|
|
2514
|
+
);
|
|
2515
|
+
}
|
|
2516
|
+
await trackEvent("nest.import.documents", { nestId, registered: docs.length });
|
|
2517
|
+
return docs.length;
|
|
2376
2518
|
}
|
|
2377
2519
|
async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
2378
2520
|
return withNodeWriteLock(nodeWriteKey(nestId, nodeId), async () => {
|
|
@@ -2386,33 +2528,43 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
2386
2528
|
} catch {
|
|
2387
2529
|
throw new NotFoundError(`Node not found: ${nodeId}`);
|
|
2388
2530
|
}
|
|
2531
|
+
const opInput = {};
|
|
2389
2532
|
if (patch.content !== void 0) {
|
|
2390
2533
|
node = { ...node, body: patch.content };
|
|
2534
|
+
opInput.content = patch.content;
|
|
2391
2535
|
}
|
|
2392
2536
|
if (patch.append) {
|
|
2393
2537
|
node = { ...node, body: (node.body || "") + "\n\n" + patch.append };
|
|
2538
|
+
opInput.append = "\n" + patch.append;
|
|
2394
2539
|
}
|
|
2395
2540
|
if (patch.tags) {
|
|
2396
|
-
const
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
];
|
|
2400
|
-
node = { ...node, frontmatter: { ...node.frontmatter, tags: merged } };
|
|
2541
|
+
const nextTags = [...new Set(patch.tags.map(normalizeTag2))];
|
|
2542
|
+
node = { ...node, frontmatter: { ...node.frontmatter, tags: nextTags } };
|
|
2543
|
+
opInput.tags = nextTags;
|
|
2401
2544
|
}
|
|
2402
2545
|
if (patch.status) {
|
|
2403
2546
|
node = {
|
|
2404
2547
|
...node,
|
|
2405
2548
|
frontmatter: { ...node.frontmatter, status: patch.status }
|
|
2406
2549
|
};
|
|
2550
|
+
opInput.status = normalizeStatus(patch.status);
|
|
2551
|
+
} else if (isRejected(node)) {
|
|
2552
|
+
node = {
|
|
2553
|
+
...node,
|
|
2554
|
+
frontmatter: { ...node.frontmatter, status: "draft" }
|
|
2555
|
+
};
|
|
2556
|
+
opInput.status = "draft";
|
|
2407
2557
|
}
|
|
2408
2558
|
if (patch.title) {
|
|
2409
2559
|
if (patch.title !== node.frontmatter.title) {
|
|
2410
2560
|
assertTitleLength(patch.title);
|
|
2561
|
+
assertTitleSluggable(patch.title);
|
|
2411
2562
|
}
|
|
2412
2563
|
node = {
|
|
2413
2564
|
...node,
|
|
2414
2565
|
frontmatter: { ...node.frontmatter, title: patch.title }
|
|
2415
2566
|
};
|
|
2567
|
+
opInput.title = patch.title;
|
|
2416
2568
|
}
|
|
2417
2569
|
if (patch.schedule !== void 0) {
|
|
2418
2570
|
if (!isRunnableType(node.frontmatter.type)) {
|
|
@@ -2426,6 +2578,10 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
2426
2578
|
if (patch.schedule === "") delete metadata.schedule;
|
|
2427
2579
|
else metadata.schedule = patch.schedule;
|
|
2428
2580
|
node = { ...node, frontmatter: { ...node.frontmatter, metadata } };
|
|
2581
|
+
opInput.metadata = {
|
|
2582
|
+
...opInput.metadata,
|
|
2583
|
+
schedule: patch.schedule === "" ? null : patch.schedule
|
|
2584
|
+
};
|
|
2429
2585
|
}
|
|
2430
2586
|
if (patch.prime !== void 0) {
|
|
2431
2587
|
await assertMayFlagPrime(nestId, userEmail);
|
|
@@ -2433,6 +2589,7 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
2433
2589
|
if (patch.prime === null) delete metadata.prime;
|
|
2434
2590
|
else metadata.prime = patch.prime;
|
|
2435
2591
|
node = { ...node, frontmatter: { ...node.frontmatter, metadata } };
|
|
2592
|
+
opInput.metadata = { ...opInput.metadata, prime: patch.prime };
|
|
2436
2593
|
}
|
|
2437
2594
|
const hasStewards = await isStewardshipEnabled(nestId);
|
|
2438
2595
|
const currentTags = node.frontmatter.tags || [];
|
|
@@ -2451,19 +2608,12 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
2451
2608
|
if (publishReason === null || isAuthorEditingOwnReview) {
|
|
2452
2609
|
const currentVersion = await getCurrentVersion(nestId, nodeId);
|
|
2453
2610
|
const newVersion = await versionManager.nextVersion(nodeId, currentVersion);
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
// Drop stale published-state checksum so the next verified read
|
|
2461
|
-
// doesn't flag this write as external drift.
|
|
2462
|
-
checksum: void 0
|
|
2463
|
-
}
|
|
2464
|
-
};
|
|
2465
|
-
node = { ...node, frontmatter: stripUndefined(node.frontmatter) };
|
|
2466
|
-
await storage.writeDocument(nodeId, serializeDocument(node));
|
|
2611
|
+
await engineApi.run(
|
|
2612
|
+
"context_update",
|
|
2613
|
+
{ id: nodeId, ...opInput, publish: false, version: newVersion },
|
|
2614
|
+
await opContext(nestId, userEmail)
|
|
2615
|
+
);
|
|
2616
|
+
node = await storage.readDocument(nodeId);
|
|
2467
2617
|
await syncNodeTags(nestId, nodeId, currentTags);
|
|
2468
2618
|
try {
|
|
2469
2619
|
await versionManager.createVersion(node, userEmail, {
|
|
@@ -2491,26 +2641,15 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
2491
2641
|
}
|
|
2492
2642
|
responseVersion = newVersion;
|
|
2493
2643
|
} else {
|
|
2494
|
-
node = {
|
|
2495
|
-
...node,
|
|
2496
|
-
frontmatter: {
|
|
2497
|
-
...node.frontmatter,
|
|
2498
|
-
updated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2499
|
-
checksum: void 0
|
|
2500
|
-
}
|
|
2501
|
-
};
|
|
2502
|
-
node = { ...node, frontmatter: stripUndefined(node.frontmatter) };
|
|
2503
|
-
await storage.writeDocument(nodeId, serializeDocument(node));
|
|
2504
|
-
await syncNodeTags(nestId, nodeId, currentTags);
|
|
2505
|
-
let publishedVersion = (node.frontmatter.version || 0) + 1;
|
|
2506
2644
|
const publishNote = patch.changeNote || `Auto-published on edit (${publishReason})`;
|
|
2645
|
+
let publishedVersion;
|
|
2507
2646
|
try {
|
|
2508
|
-
const result = await
|
|
2509
|
-
|
|
2510
|
-
note: publishNote
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2647
|
+
const result = await engineApi.run(
|
|
2648
|
+
"context_update",
|
|
2649
|
+
{ id: nodeId, ...opInput, publish: true, note: publishNote },
|
|
2650
|
+
await opContext(nestId, userEmail)
|
|
2651
|
+
);
|
|
2652
|
+
publishedVersion = result.version;
|
|
2514
2653
|
} catch (err) {
|
|
2515
2654
|
console.error(
|
|
2516
2655
|
"publishDocument failed (node patch auto-publish)",
|
|
@@ -2518,6 +2657,8 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
2518
2657
|
);
|
|
2519
2658
|
throw err;
|
|
2520
2659
|
}
|
|
2660
|
+
node = await storage.readDocument(nodeId);
|
|
2661
|
+
await syncNodeTags(nestId, nodeId, currentTags);
|
|
2521
2662
|
await upsertVersion({
|
|
2522
2663
|
nestId,
|
|
2523
2664
|
nodeId,
|
|
@@ -2685,14 +2826,22 @@ async function deleteNode(nestId, nodeId, userEmail) {
|
|
|
2685
2826
|
const { storage } = await engineCache.get(nestId);
|
|
2686
2827
|
await assertNotReviewLocked(nestId, nodeId, userEmail, "deleting");
|
|
2687
2828
|
try {
|
|
2688
|
-
await
|
|
2829
|
+
await engineApi.run(
|
|
2830
|
+
"context_delete",
|
|
2831
|
+
{ id: nodeId },
|
|
2832
|
+
await opContext(nestId, userEmail)
|
|
2833
|
+
);
|
|
2689
2834
|
} catch {
|
|
2690
2835
|
throw new NotFoundError(`Node not found: ${nodeId}`);
|
|
2691
2836
|
}
|
|
2692
2837
|
await removeNodeFromTagIndex(nestId, nodeId);
|
|
2693
2838
|
const derivedId = derivedIdOf(nodeId);
|
|
2694
2839
|
try {
|
|
2695
|
-
await
|
|
2840
|
+
await engineApi.run(
|
|
2841
|
+
"context_delete",
|
|
2842
|
+
{ id: derivedId },
|
|
2843
|
+
await opContext(nestId, userEmail)
|
|
2844
|
+
);
|
|
2696
2845
|
await removeNodeFromTagIndex(nestId, derivedId);
|
|
2697
2846
|
} catch {
|
|
2698
2847
|
}
|
|
@@ -2738,8 +2887,9 @@ async function deleteFolder(nestId, folder, userEmail) {
|
|
|
2738
2887
|
}
|
|
2739
2888
|
|
|
2740
2889
|
// src/nests/unsynced-service.ts
|
|
2741
|
-
import { readdirSync, readFileSync,
|
|
2890
|
+
import { readdirSync, readFileSync, statSync } from "fs";
|
|
2742
2891
|
import { join as join3, relative, resolve } from "path";
|
|
2892
|
+
import { rm } from "fs/promises";
|
|
2743
2893
|
var RESERVED = /* @__PURE__ */ new Set(["nests"]);
|
|
2744
2894
|
function isNestStorageRoot(absPath) {
|
|
2745
2895
|
return resolve(absPath) === resolve(nestStorageRoot());
|
|
@@ -2907,23 +3057,31 @@ async function syncUnsyncedFolder(userId, folderName, callerEmail) {
|
|
|
2907
3057
|
`[unsynced] name "${baseName}" already in use, using "${nestName2}" instead`
|
|
2908
3058
|
);
|
|
2909
3059
|
}
|
|
2910
|
-
const nest = await importNest(userId, nestName2
|
|
3060
|
+
const nest = await importNest(userId, nestName2);
|
|
2911
3061
|
console.log(
|
|
2912
3062
|
`[unsynced] nest created id=${nest.id} name="${nest.name}" from folder="${folderName}"`
|
|
2913
3063
|
);
|
|
2914
|
-
|
|
3064
|
+
let documents;
|
|
3065
|
+
try {
|
|
3066
|
+
await stageImportedFiles(nest.id, files, callerEmail);
|
|
3067
|
+
documents = await registerImportedDocuments(nest.id, callerEmail);
|
|
3068
|
+
} catch (err) {
|
|
3069
|
+
await deleteNest(nest.id).catch(() => {
|
|
3070
|
+
});
|
|
3071
|
+
throw err;
|
|
3072
|
+
}
|
|
2915
3073
|
console.log(
|
|
2916
3074
|
`[unsynced] registered ${documents} document(s) for nest ${nest.id}`
|
|
2917
3075
|
);
|
|
2918
3076
|
try {
|
|
2919
|
-
|
|
3077
|
+
await rm(src, { recursive: true, force: true });
|
|
2920
3078
|
console.log(`[unsynced] removed source folder ${src}`);
|
|
2921
3079
|
} catch (err) {
|
|
2922
3080
|
console.error("[unsynced] failed to remove source folder", src, err);
|
|
2923
3081
|
}
|
|
2924
3082
|
return { nest, documents };
|
|
2925
3083
|
}
|
|
2926
|
-
function deleteUnsyncedFolder(folderName) {
|
|
3084
|
+
async function deleteUnsyncedFolder(folderName) {
|
|
2927
3085
|
console.log(`[unsynced] delete requested folder="${folderName}"`);
|
|
2928
3086
|
assertSafeFolderName(folderName);
|
|
2929
3087
|
const src = join3(config.DATA_ROOT, folderName);
|
|
@@ -2939,21 +3097,27 @@ function deleteUnsyncedFolder(folderName) {
|
|
|
2939
3097
|
if (!stat.isDirectory()) {
|
|
2940
3098
|
throw new ValidationError(`Not a directory: ${folderName}`);
|
|
2941
3099
|
}
|
|
2942
|
-
|
|
3100
|
+
await rm(src, { recursive: true, force: true });
|
|
2943
3101
|
console.log(`[unsynced] deleted folder ${src}`);
|
|
2944
3102
|
}
|
|
2945
3103
|
|
|
2946
3104
|
// src/nests/routes.ts
|
|
2947
|
-
async function
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
3105
|
+
async function effectiveAccess(nestId, userId) {
|
|
3106
|
+
const access = await resolveNestAccess(nestId, userId);
|
|
3107
|
+
if (config.AUTH_MODE === "open" && access.ownerId === ANON_USER_ID) {
|
|
3108
|
+
return { permission: "owner", access };
|
|
3109
|
+
}
|
|
3110
|
+
const permission = access.permission;
|
|
3111
|
+
if (permission === "none" && access.hasGrant) {
|
|
3112
|
+
return { permission: "read", access };
|
|
2955
3113
|
}
|
|
2956
|
-
return
|
|
3114
|
+
return { permission, access };
|
|
3115
|
+
}
|
|
3116
|
+
async function effectivePermission(nestId, userId) {
|
|
3117
|
+
return (await effectiveAccess(nestId, userId)).permission;
|
|
3118
|
+
}
|
|
3119
|
+
function isGrantOnly(access) {
|
|
3120
|
+
return access.permission === "none" && access.hasGrant;
|
|
2957
3121
|
}
|
|
2958
3122
|
var nestRoutes = new Hono3();
|
|
2959
3123
|
nestRoutes.get("/", async (c) => {
|
|
@@ -2964,8 +3128,31 @@ nestRoutes.get("/", async (c) => {
|
|
|
2964
3128
|
const db = getDb();
|
|
2965
3129
|
const ownerEmailSql = "SELECT email FROM users WHERE id = ?";
|
|
2966
3130
|
const callerEmail = await resolveCallerEmail(userId);
|
|
3131
|
+
const myGrantRows = await db.all(
|
|
3132
|
+
"SELECT nest_id, target FROM grants WHERE user_id = ?",
|
|
3133
|
+
[userId]
|
|
3134
|
+
);
|
|
3135
|
+
const grantTargetsByNest = /* @__PURE__ */ new Map();
|
|
3136
|
+
for (const r of myGrantRows) {
|
|
3137
|
+
const arr = grantTargetsByNest.get(r.nest_id) ?? [];
|
|
3138
|
+
arr.push({ target: r.target });
|
|
3139
|
+
grantTargetsByNest.set(r.nest_id, arr);
|
|
3140
|
+
}
|
|
2967
3141
|
const annotate = async (n) => {
|
|
2968
|
-
const permission = await
|
|
3142
|
+
const { permission, access } = await effectiveAccess(n.id, userId);
|
|
3143
|
+
if (permission !== "owner" && // open-mode commons promotion outranks a grant
|
|
3144
|
+
isGrantOnly(access)) {
|
|
3145
|
+
const owner_email2 = n.user_id === ANON_USER_ID ? null : (await db.get(ownerEmailSql, [n.user_id]))?.email ?? null;
|
|
3146
|
+
return {
|
|
3147
|
+
...n,
|
|
3148
|
+
permission: "read",
|
|
3149
|
+
is_owner: false,
|
|
3150
|
+
owner_email: owner_email2,
|
|
3151
|
+
roles: ["viewer"],
|
|
3152
|
+
grant_only: true,
|
|
3153
|
+
stewardship_enabled: !!n.stewardship_enabled
|
|
3154
|
+
};
|
|
3155
|
+
}
|
|
2969
3156
|
const is_owner = permission === "owner";
|
|
2970
3157
|
const needOwnerEmail = !is_owner && n.user_id !== ANON_USER_ID;
|
|
2971
3158
|
const [roles, owner_email] = await Promise.all([
|
|
@@ -2980,6 +3167,7 @@ nestRoutes.get("/", async (c) => {
|
|
|
2980
3167
|
is_owner,
|
|
2981
3168
|
owner_email,
|
|
2982
3169
|
roles,
|
|
3170
|
+
grant_only: false,
|
|
2983
3171
|
stewardship_enabled: !!n.stewardship_enabled
|
|
2984
3172
|
};
|
|
2985
3173
|
};
|
|
@@ -3023,14 +3211,38 @@ nestRoutes.get("/", async (c) => {
|
|
|
3023
3211
|
if (arr.length < 8) arr.push(r.email);
|
|
3024
3212
|
collabEmailsByNest.set(r.nest_id, arr);
|
|
3025
3213
|
}
|
|
3026
|
-
const
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3214
|
+
const grantOnlyIds = out.filter((n) => n.grant_only).map((n) => n.id);
|
|
3215
|
+
const grantedDocsByNest = /* @__PURE__ */ new Map();
|
|
3216
|
+
if (grantOnlyIds.length) {
|
|
3217
|
+
const rows = await db.all(
|
|
3218
|
+
`SELECT DISTINCT nest_id, node_id FROM node_versions
|
|
3219
|
+
WHERE nest_id IN (${grantOnlyIds.map(() => "?").join(",")})`,
|
|
3220
|
+
grantOnlyIds
|
|
3221
|
+
);
|
|
3222
|
+
for (const r of rows) {
|
|
3223
|
+
const targets = grantTargetsByNest.get(r.nest_id) ?? [];
|
|
3224
|
+
if (grantCoversNode(targets, r.node_id)) {
|
|
3225
|
+
grantedDocsByNest.set(r.nest_id, (grantedDocsByNest.get(r.nest_id) ?? 0) + 1);
|
|
3226
|
+
}
|
|
3227
|
+
}
|
|
3228
|
+
}
|
|
3229
|
+
const withMeta = out.map(
|
|
3230
|
+
(n) => n.grant_only ? {
|
|
3231
|
+
...n,
|
|
3232
|
+
document_count: grantedDocsByNest.get(n.id) ?? 0,
|
|
3233
|
+
collaborator_count: 0,
|
|
3234
|
+
steward_count: 0,
|
|
3235
|
+
steward_emails: [],
|
|
3236
|
+
collaborator_emails: []
|
|
3237
|
+
} : {
|
|
3238
|
+
...n,
|
|
3239
|
+
document_count: docByNest.get(n.id) ?? 0,
|
|
3240
|
+
collaborator_count: collabByNest.get(n.id) ?? 0,
|
|
3241
|
+
steward_count: stewardByNest.get(n.id) ?? 0,
|
|
3242
|
+
steward_emails: stewardEmailsByNest.get(n.id) ?? [],
|
|
3243
|
+
collaborator_emails: collabEmailsByNest.get(n.id) ?? []
|
|
3244
|
+
}
|
|
3245
|
+
);
|
|
3034
3246
|
return c.json({ nests: withMeta });
|
|
3035
3247
|
});
|
|
3036
3248
|
nestRoutes.post("/", async (c) => {
|
|
@@ -3046,42 +3258,50 @@ nestRoutes.post("/:nestId", async (c) => {
|
|
|
3046
3258
|
throw new NotFoundError("Not found");
|
|
3047
3259
|
}
|
|
3048
3260
|
const body = await c.req.json();
|
|
3261
|
+
const files = Array.isArray(body.files) ? body.files : [];
|
|
3262
|
+
const userId = c.get("userId");
|
|
3263
|
+
if (body.nestId) {
|
|
3264
|
+
const target = body.nestId;
|
|
3265
|
+
const nest2 = await getNest(target);
|
|
3266
|
+
if (!nest2 || !nest2.is_imported || await effectivePermission(target, userId) !== "owner") {
|
|
3267
|
+
throw new NotFoundError("Nest not found");
|
|
3268
|
+
}
|
|
3269
|
+
const email2 = await resolveCallerEmail(userId);
|
|
3270
|
+
if (body.finalize) {
|
|
3271
|
+
return c.json({ nest: nest2, documents: await registerImportedDocuments(target, email2) });
|
|
3272
|
+
}
|
|
3273
|
+
const registered = await getDb().get(
|
|
3274
|
+
"SELECT COUNT(*) AS c FROM node_versions WHERE nest_id = ?",
|
|
3275
|
+
[target]
|
|
3276
|
+
);
|
|
3277
|
+
if (Number(registered?.c ?? 0) > 0) {
|
|
3278
|
+
throw new ValidationError(
|
|
3279
|
+
"This nest has already been imported. Start a new import instead of adding files to a finished one."
|
|
3280
|
+
);
|
|
3281
|
+
}
|
|
3282
|
+
console.log(`[import] chunk: nest=${target} files=${files.length}`);
|
|
3283
|
+
return c.json(await stageImportedFiles(target, files, email2));
|
|
3284
|
+
}
|
|
3049
3285
|
if (!body.name) {
|
|
3050
3286
|
throw new ValidationError("name is required");
|
|
3051
3287
|
}
|
|
3052
|
-
const files = Array.isArray(body.files) ? body.files : [];
|
|
3053
|
-
const userId = c.get("userId");
|
|
3054
3288
|
console.log(
|
|
3055
|
-
`[import] request: name=${JSON.stringify(body.name)} files=${files.length}`
|
|
3289
|
+
`[import] request: name=${JSON.stringify(body.name)} files=${files.length}` + (body.partial ? " (first chunk)" : "")
|
|
3056
3290
|
);
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
await stream.writeln(JSON.stringify({ phase: "register", done, total }));
|
|
3069
|
-
}
|
|
3070
|
-
);
|
|
3071
|
-
await stream.writeln(JSON.stringify({ complete: true, nest: nest2, documents: documents2 }));
|
|
3072
|
-
} catch (err) {
|
|
3073
|
-
await stream.writeln(
|
|
3074
|
-
JSON.stringify({ error: err?.message || "Import failed" })
|
|
3075
|
-
);
|
|
3076
|
-
}
|
|
3291
|
+
const email = await resolveCallerEmail(userId);
|
|
3292
|
+
const nest = await importNest(userId, body.name);
|
|
3293
|
+
try {
|
|
3294
|
+
const { rejected } = await stageImportedFiles(nest.id, files, email);
|
|
3295
|
+
if (body.partial) {
|
|
3296
|
+
return c.json({ nest, rejected }, 201);
|
|
3297
|
+
}
|
|
3298
|
+
const documents = await registerImportedDocuments(nest.id, email);
|
|
3299
|
+
return c.json({ nest, documents, rejected }, 201);
|
|
3300
|
+
} catch (err) {
|
|
3301
|
+
await deleteNest(nest.id).catch(() => {
|
|
3077
3302
|
});
|
|
3303
|
+
throw err;
|
|
3078
3304
|
}
|
|
3079
|
-
const nest = await importNest(userId, body.name, files);
|
|
3080
|
-
const documents = await registerImportedDocuments(
|
|
3081
|
-
nest.id,
|
|
3082
|
-
await resolveCallerEmail(userId)
|
|
3083
|
-
);
|
|
3084
|
-
return c.json({ nest, documents }, 201);
|
|
3085
3305
|
});
|
|
3086
3306
|
async function handleUnsyncedList(c) {
|
|
3087
3307
|
const userId = c.get("userId");
|
|
@@ -3121,14 +3341,14 @@ nestRoutes.delete("/:nestId/sync", async (c) => {
|
|
|
3121
3341
|
if (!body?.name) {
|
|
3122
3342
|
throw new ValidationError("name is required");
|
|
3123
3343
|
}
|
|
3124
|
-
deleteUnsyncedFolder(body.name);
|
|
3344
|
+
await deleteUnsyncedFolder(body.name);
|
|
3125
3345
|
return c.json({ ok: true });
|
|
3126
3346
|
});
|
|
3127
3347
|
nestRoutes.get("/:nestId", async (c) => {
|
|
3128
3348
|
const nestId = c.req.param("nestId");
|
|
3129
3349
|
if (nestId === "unsynced") return handleUnsyncedList(c);
|
|
3130
3350
|
const userId = c.get("userId");
|
|
3131
|
-
const permission = await
|
|
3351
|
+
const { permission, access } = await effectiveAccess(nestId, userId);
|
|
3132
3352
|
if (permission === "none") {
|
|
3133
3353
|
throw new NotFoundError("Nest not found");
|
|
3134
3354
|
}
|
|
@@ -3138,8 +3358,15 @@ nestRoutes.get("/:nestId", async (c) => {
|
|
|
3138
3358
|
roles = ["owner", ...roles];
|
|
3139
3359
|
}
|
|
3140
3360
|
const myStewards = await getStewardsForUser(nestId, email);
|
|
3361
|
+
const grant_only = roles.length === 0 && isGrantOnly(access);
|
|
3141
3362
|
const nest = await getNest(nestId);
|
|
3142
|
-
return c.json({
|
|
3363
|
+
return c.json({
|
|
3364
|
+
nest,
|
|
3365
|
+
permission,
|
|
3366
|
+
roles: grant_only ? ["viewer"] : roles,
|
|
3367
|
+
myStewards,
|
|
3368
|
+
grant_only
|
|
3369
|
+
});
|
|
3143
3370
|
});
|
|
3144
3371
|
nestRoutes.patch("/:nestId", async (c) => {
|
|
3145
3372
|
const nestId = c.req.param("nestId");
|
|
@@ -3838,27 +4065,62 @@ teamRoutes.delete("/:teamId/members/:userId", async (c) => {
|
|
|
3838
4065
|
return c.json({ team });
|
|
3839
4066
|
});
|
|
3840
4067
|
|
|
4068
|
+
// src/nodes/search.ts
|
|
4069
|
+
async function discoverForSearch(storage) {
|
|
4070
|
+
return storage.discoverDocuments({ includeRetired: true });
|
|
4071
|
+
}
|
|
4072
|
+
function titleScore(title, query) {
|
|
4073
|
+
const t = (title || "").toLowerCase();
|
|
4074
|
+
const q = query.trim().toLowerCase();
|
|
4075
|
+
if (!q) return 0;
|
|
4076
|
+
if (t === q) return 4;
|
|
4077
|
+
let midWord = false;
|
|
4078
|
+
for (let i = t.indexOf(q); i !== -1; i = t.indexOf(q, i + 1)) {
|
|
4079
|
+
if (i === 0) return 3;
|
|
4080
|
+
if (!/[a-z0-9]/.test(t[i - 1])) return 2;
|
|
4081
|
+
midWord = true;
|
|
4082
|
+
}
|
|
4083
|
+
if (midWord) return 1;
|
|
4084
|
+
const terms = q.split(/\s+/).filter(Boolean);
|
|
4085
|
+
if (terms.length > 1 && terms.every((term) => t.includes(term))) return 1;
|
|
4086
|
+
return 0;
|
|
4087
|
+
}
|
|
4088
|
+
function matchDocuments(documents, query) {
|
|
4089
|
+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
4090
|
+
if (terms.length === 0) return [];
|
|
4091
|
+
return documents.filter((node) => {
|
|
4092
|
+
const haystack = [
|
|
4093
|
+
node.frontmatter.title,
|
|
4094
|
+
node.body || "",
|
|
4095
|
+
node.frontmatter.type || "",
|
|
4096
|
+
...node.frontmatter.tags || []
|
|
4097
|
+
].join(" ").toLowerCase();
|
|
4098
|
+
return terms.every((term) => haystack.includes(term));
|
|
4099
|
+
}).sort(
|
|
4100
|
+
(a, b) => titleScore(b.frontmatter.title, query) - titleScore(a.frontmatter.title, query)
|
|
4101
|
+
);
|
|
4102
|
+
}
|
|
4103
|
+
function bodySnippet(body, query) {
|
|
4104
|
+
const flat = body.replace(/\s+/g, " ").trim();
|
|
4105
|
+
const terms = (query || "").toLowerCase().split(/\s+/).filter(Boolean);
|
|
4106
|
+
const at = terms.map((t) => flat.toLowerCase().indexOf(t)).filter((i) => i >= 0).sort((a, b) => a - b)[0];
|
|
4107
|
+
if (at === void 0 || at < 80) return flat.slice(0, 200);
|
|
4108
|
+
const start = Math.max(0, at - 60);
|
|
4109
|
+
return `\u2026${flat.slice(start, start + 200)}`;
|
|
4110
|
+
}
|
|
4111
|
+
function toSearchHit(node, query) {
|
|
4112
|
+
return {
|
|
4113
|
+
id: node.id,
|
|
4114
|
+
title: node.frontmatter.title,
|
|
4115
|
+
type: node.frontmatter.type || "document",
|
|
4116
|
+
tags: node.frontmatter.tags || [],
|
|
4117
|
+
snippet: bodySnippet(node.body || "", query)
|
|
4118
|
+
};
|
|
4119
|
+
}
|
|
4120
|
+
|
|
3841
4121
|
// src/nodes/routes.ts
|
|
3842
4122
|
import { Hono as Hono8 } from "hono";
|
|
3843
4123
|
|
|
3844
|
-
// src/shared/artifact-sandbox.ts
|
|
3845
|
-
var ARTIFACT_SANDBOX_CSP = [
|
|
3846
|
-
"sandbox allow-scripts allow-popups allow-modals",
|
|
3847
|
-
"default-src 'none'",
|
|
3848
|
-
"script-src 'unsafe-inline'",
|
|
3849
|
-
"style-src 'unsafe-inline'",
|
|
3850
|
-
"img-src data: blob:",
|
|
3851
|
-
"font-src data:",
|
|
3852
|
-
"connect-src 'none'",
|
|
3853
|
-
"base-uri 'none'",
|
|
3854
|
-
"form-action 'none'"
|
|
3855
|
-
].join("; ");
|
|
3856
|
-
function applyArtifactSandboxHeaders(c) {
|
|
3857
|
-
c.header("Content-Security-Policy", ARTIFACT_SANDBOX_CSP);
|
|
3858
|
-
c.header("X-Content-Type-Options", "nosniff");
|
|
3859
|
-
c.header("Referrer-Policy", "no-referrer");
|
|
3860
|
-
}
|
|
3861
|
-
|
|
3862
4124
|
// src/nodes/markdown-export.ts
|
|
3863
4125
|
import { parseDocument as parseDocument3 } from "@promptowl/contextnest-engine";
|
|
3864
4126
|
function nodeToMarkdown(node) {
|
|
@@ -3933,7 +4195,17 @@ function rawNodeBody(c, response, storedRaw) {
|
|
|
3933
4195
|
nodeRoutes.get("/", async (c) => {
|
|
3934
4196
|
const nestId = c.req.param("nestId");
|
|
3935
4197
|
const userId = c.get("userId");
|
|
3936
|
-
const
|
|
4198
|
+
const q = c.req.query();
|
|
4199
|
+
const limit = q.limit ? parseInt(q.limit, 10) : void 0;
|
|
4200
|
+
const nodes = await listNodesForCaller(nestId, userId, {
|
|
4201
|
+
...q.type ? { type: q.type } : {},
|
|
4202
|
+
...q.tag ? { tag: q.tag } : {},
|
|
4203
|
+
...q.status ? { status: q.status } : {},
|
|
4204
|
+
// Lets an owner preview what making the nest public would expose.
|
|
4205
|
+
...q.approved_only === "1" || q.approved_only === "true" ? { approvedOnly: true } : {},
|
|
4206
|
+
// Ignore junk rather than 400 — a bad ?limit should not fail a listing.
|
|
4207
|
+
...limit !== void 0 && Number.isFinite(limit) && limit > 0 ? { limit } : {}
|
|
4208
|
+
});
|
|
3937
4209
|
return c.json({ count: nodes.length, nodes });
|
|
3938
4210
|
});
|
|
3939
4211
|
nodeRoutes.post("/", async (c) => {
|
|
@@ -3974,7 +4246,7 @@ nodeRoutes.post("/", async (c) => {
|
|
|
3974
4246
|
nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
|
|
3975
4247
|
const nestId = c.req.param("nestId");
|
|
3976
4248
|
const nodeId = c.req.param("nodeId");
|
|
3977
|
-
const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-
|
|
4249
|
+
const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-OHTTZFND.js");
|
|
3978
4250
|
const { stewards, fallbackToOwner, ownerEmail } = await resolveStewardsWithFallback2(
|
|
3979
4251
|
nestId,
|
|
3980
4252
|
nodeId
|
|
@@ -3995,7 +4267,7 @@ nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
|
|
|
3995
4267
|
nodeRoutes.get("/:nodeId{.+}/reviews", async (c) => {
|
|
3996
4268
|
const nestId = c.req.param("nestId");
|
|
3997
4269
|
const nodeId = c.req.param("nodeId");
|
|
3998
|
-
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-
|
|
4270
|
+
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-3CCKDO5N.js");
|
|
3999
4271
|
const history = await getReviewHistory2(nestId, nodeId);
|
|
4000
4272
|
return c.json({ reviews: history });
|
|
4001
4273
|
});
|
|
@@ -4076,26 +4348,21 @@ nodeRoutes.delete("/:nodeId{.+}/watchers", async (c) => {
|
|
|
4076
4348
|
if (!await removeWatcher(nestId, nodeId, email)) {
|
|
4077
4349
|
throw new NotFoundError("Not watching");
|
|
4078
4350
|
}
|
|
4079
|
-
return c.json({ watchers: await listWatchers(nestId, nodeId) });
|
|
4080
|
-
});
|
|
4081
|
-
nodeRoutes.get("/:nodeId{.+}", async (c) => {
|
|
4082
|
-
const nestId = c.req.param("nestId");
|
|
4083
|
-
const nodeId = c.req.param("nodeId");
|
|
4084
|
-
const { storage, versions: versionManager } = await engineCache.get(nestId);
|
|
4085
|
-
const userId = c.get("userId");
|
|
4086
|
-
const userEmail = await resolveCallerEmail(userId);
|
|
4087
|
-
|
|
4088
|
-
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
try {
|
|
4095
|
-
node = await storage.readDocument(nodeId, { verifyChecksum: true });
|
|
4096
|
-
} catch {
|
|
4097
|
-
throw new NotFoundError(`Node not found: ${nodeId}`);
|
|
4098
|
-
}
|
|
4351
|
+
return c.json({ watchers: await listWatchers(nestId, nodeId) });
|
|
4352
|
+
});
|
|
4353
|
+
nodeRoutes.get("/:nodeId{.+}", async (c) => {
|
|
4354
|
+
const nestId = c.req.param("nestId");
|
|
4355
|
+
const nodeId = c.req.param("nodeId");
|
|
4356
|
+
const { storage, versions: versionManager } = await engineCache.get(nestId);
|
|
4357
|
+
const userId = c.get("userId");
|
|
4358
|
+
const userEmail = await resolveCallerEmail(userId);
|
|
4359
|
+
let node = await getNodeForCaller(
|
|
4360
|
+
nestId,
|
|
4361
|
+
{ id: nodeId },
|
|
4362
|
+
userId,
|
|
4363
|
+
userEmail,
|
|
4364
|
+
{ verifyChecksum: true }
|
|
4365
|
+
);
|
|
4099
4366
|
if (node.pendingChange) {
|
|
4100
4367
|
try {
|
|
4101
4368
|
await scanDocumentForDrift(nestId, nodeId, userEmail || "system:read");
|
|
@@ -4124,6 +4391,7 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
|
|
|
4124
4391
|
response.collaborators = people.collaborators;
|
|
4125
4392
|
}
|
|
4126
4393
|
}
|
|
4394
|
+
response.myGrant = await resolveNodeGrant(nestId, userId, nodeId);
|
|
4127
4395
|
if (publicReader) {
|
|
4128
4396
|
const approved = await getApprovedVersion(nestId, nodeId);
|
|
4129
4397
|
if (approved != null) {
|
|
@@ -5611,30 +5879,14 @@ queryRoutes.get("/search", async (c) => {
|
|
|
5611
5879
|
}
|
|
5612
5880
|
const nestId = c.req.param("nestId");
|
|
5613
5881
|
const { storage } = await engineCache.get(nestId);
|
|
5614
|
-
const
|
|
5615
|
-
const terms = q.toLowerCase().split(/\s+/).filter(Boolean);
|
|
5616
|
-
const matches = documents.filter((node) => {
|
|
5617
|
-
const haystack = [
|
|
5618
|
-
node.frontmatter.title,
|
|
5619
|
-
node.body || "",
|
|
5620
|
-
node.frontmatter.type || "",
|
|
5621
|
-
...node.frontmatter.tags || []
|
|
5622
|
-
].join(" ").toLowerCase();
|
|
5623
|
-
return terms.every((term) => haystack.includes(term));
|
|
5624
|
-
});
|
|
5882
|
+
const matches = matchDocuments(await discoverForSearch(storage), q);
|
|
5625
5883
|
const userId = c.get("userId");
|
|
5626
5884
|
const userEmail = await resolveCallerEmail(userId);
|
|
5627
5885
|
const accessible = await filterAccessible(nestId, userId, userEmail, matches);
|
|
5628
5886
|
return c.json({
|
|
5629
5887
|
query: q,
|
|
5630
5888
|
count: accessible.length,
|
|
5631
|
-
nodes: accessible.map((n) => (
|
|
5632
|
-
id: n.id,
|
|
5633
|
-
title: n.frontmatter.title,
|
|
5634
|
-
type: n.frontmatter.type || "document",
|
|
5635
|
-
tags: n.frontmatter.tags || [],
|
|
5636
|
-
snippet: (n.body || "").slice(0, 200).replace(/\n/g, " ")
|
|
5637
|
-
}))
|
|
5889
|
+
nodes: accessible.map((n) => toSearchHit(n, q))
|
|
5638
5890
|
});
|
|
5639
5891
|
});
|
|
5640
5892
|
queryRoutes.get("/overview", async (c) => {
|
|
@@ -5674,10 +5926,6 @@ queryRoutes.get("/context", async (c) => {
|
|
|
5674
5926
|
queryRoutes.get("/export", async (c) => {
|
|
5675
5927
|
const nestId = c.req.param("nestId");
|
|
5676
5928
|
if (c.req.query("format") === "bundle") {
|
|
5677
|
-
const perm = await resolveNestPermission(nestId, c.get("userId"));
|
|
5678
|
-
if (perm !== "owner" && perm !== "admin") {
|
|
5679
|
-
throw new ForbiddenError("Only the nest owner or an admin can export a bundle");
|
|
5680
|
-
}
|
|
5681
5929
|
const draftsParam = c.req.query("includeDrafts");
|
|
5682
5930
|
const includeDrafts = draftsParam === "1" || draftsParam === "true";
|
|
5683
5931
|
const zip = await exportNestBundle(nestId, includeDrafts);
|
|
@@ -5793,6 +6041,7 @@ queryRoutes.post("/publish", async (c) => {
|
|
|
5793
6041
|
await syncNodeTags(nestId, id, tags);
|
|
5794
6042
|
created.push(id);
|
|
5795
6043
|
}
|
|
6044
|
+
if (created.length > 0 || body.context_md) await storage.regenerateIndex();
|
|
5796
6045
|
trackEvent("nest.publish", { nestId, count: created.length });
|
|
5797
6046
|
return c.json(
|
|
5798
6047
|
{
|
|
@@ -5809,6 +6058,30 @@ import { Hono as Hono12 } from "hono";
|
|
|
5809
6058
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5810
6059
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
5811
6060
|
|
|
6061
|
+
// src/shared/node-id.ts
|
|
6062
|
+
function assertSafeNodeId(rawId) {
|
|
6063
|
+
let id = rawId;
|
|
6064
|
+
try {
|
|
6065
|
+
id = decodeURIComponent(rawId);
|
|
6066
|
+
} catch {
|
|
6067
|
+
}
|
|
6068
|
+
if (!id || id.length > 512) {
|
|
6069
|
+
throw new ValidationError("invalid node id");
|
|
6070
|
+
}
|
|
6071
|
+
if (id.includes("\0") || id.includes("\\")) {
|
|
6072
|
+
throw new ValidationError("invalid node id");
|
|
6073
|
+
}
|
|
6074
|
+
if (id.startsWith("/")) {
|
|
6075
|
+
throw new ValidationError("invalid node id (absolute path)");
|
|
6076
|
+
}
|
|
6077
|
+
for (const seg of id.split("/")) {
|
|
6078
|
+
if (seg === "" || seg === "." || seg === "..") {
|
|
6079
|
+
throw new ValidationError("invalid node id (path traversal)");
|
|
6080
|
+
}
|
|
6081
|
+
}
|
|
6082
|
+
return id;
|
|
6083
|
+
}
|
|
6084
|
+
|
|
5812
6085
|
// src/telemetry/trace-log.ts
|
|
5813
6086
|
var PRUNE_EVERY = 500;
|
|
5814
6087
|
var insertsSincePrune = 0;
|
|
@@ -5887,30 +6160,6 @@ async function listTraceEvents(filters = {}) {
|
|
|
5887
6160
|
// src/mcp/workflow-tools.ts
|
|
5888
6161
|
import { v4 as uuid8 } from "uuid";
|
|
5889
6162
|
|
|
5890
|
-
// src/shared/node-id.ts
|
|
5891
|
-
function assertSafeNodeId(rawId) {
|
|
5892
|
-
let id = rawId;
|
|
5893
|
-
try {
|
|
5894
|
-
id = decodeURIComponent(rawId);
|
|
5895
|
-
} catch {
|
|
5896
|
-
}
|
|
5897
|
-
if (!id || id.length > 512) {
|
|
5898
|
-
throw new ValidationError("invalid node id");
|
|
5899
|
-
}
|
|
5900
|
-
if (id.includes("\0") || id.includes("\\")) {
|
|
5901
|
-
throw new ValidationError("invalid node id");
|
|
5902
|
-
}
|
|
5903
|
-
if (id.startsWith("/")) {
|
|
5904
|
-
throw new ValidationError("invalid node id (absolute path)");
|
|
5905
|
-
}
|
|
5906
|
-
for (const seg of id.split("/")) {
|
|
5907
|
-
if (seg === "" || seg === "." || seg === "..") {
|
|
5908
|
-
throw new ValidationError("invalid node id (path traversal)");
|
|
5909
|
-
}
|
|
5910
|
-
}
|
|
5911
|
-
return id;
|
|
5912
|
-
}
|
|
5913
|
-
|
|
5914
6163
|
// src/workflow/run-service.ts
|
|
5915
6164
|
import { v4 as uuid6 } from "uuid";
|
|
5916
6165
|
var RUNNABLE_TYPES = ["agent", "skill"];
|
|
@@ -6306,6 +6555,18 @@ async function assertNoFlowCycle(db, nestId, fromNode, toNode) {
|
|
|
6306
6555
|
}
|
|
6307
6556
|
}
|
|
6308
6557
|
}
|
|
6558
|
+
async function assertNoDuplicateEdge(db, nestId, fromNode, toNode, type, excludeId) {
|
|
6559
|
+
const row = await db.get(
|
|
6560
|
+
`SELECT id FROM edges
|
|
6561
|
+
WHERE nest_id = ? AND from_node = ? AND to_node = ? AND type_id = ?` + (excludeId ? " AND id != ?" : ""),
|
|
6562
|
+
excludeId ? [nestId, fromNode, toNode, type.id, excludeId] : [nestId, fromNode, toNode, type.id]
|
|
6563
|
+
);
|
|
6564
|
+
if (row) {
|
|
6565
|
+
throw new ConflictError(
|
|
6566
|
+
`a "${type.name}" connection from "${fromNode}" to "${toNode}" already exists`
|
|
6567
|
+
);
|
|
6568
|
+
}
|
|
6569
|
+
}
|
|
6309
6570
|
var edgeRoutes = new Hono11();
|
|
6310
6571
|
edgeRoutes.get("/", requireWorkflowPlane, async (c) => {
|
|
6311
6572
|
const nestId = c.req.param("nestId");
|
|
@@ -6376,6 +6637,7 @@ edgeRoutes.post("/", requireWorkflowPlane, async (c) => {
|
|
|
6376
6637
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
6377
6638
|
const id = uuid7();
|
|
6378
6639
|
await db.transaction(async (tx) => {
|
|
6640
|
+
await assertNoDuplicateEdge(tx, nestId, fromNode, toNode, type);
|
|
6379
6641
|
if (type.is_flow) {
|
|
6380
6642
|
await assertNoFlowCycle(tx, nestId, fromNode, toNode);
|
|
6381
6643
|
}
|
|
@@ -6403,6 +6665,8 @@ edgeRoutes.patch("/:id", requireWorkflowPlane, async (c) => {
|
|
|
6403
6665
|
"SELECT * FROM edge_types WHERE id = ?",
|
|
6404
6666
|
[row.type_id]
|
|
6405
6667
|
);
|
|
6668
|
+
const wasFlow = !!type.is_flow;
|
|
6669
|
+
let retype = null;
|
|
6406
6670
|
if (typeof body.type === "string" && body.type.trim()) {
|
|
6407
6671
|
const next = await db.get(
|
|
6408
6672
|
"SELECT * FROM edge_types WHERE nest_id = ? AND (id = ? OR LOWER(name) = LOWER(?))",
|
|
@@ -6413,9 +6677,7 @@ edgeRoutes.patch("/:id", requireWorkflowPlane, async (c) => {
|
|
|
6413
6677
|
`unknown edge type "${body.type}" \u2014 define it in the registry first`
|
|
6414
6678
|
);
|
|
6415
6679
|
}
|
|
6416
|
-
if (next.
|
|
6417
|
-
await assertNoFlowCycle(db, nestId, row.from_node, row.to_node);
|
|
6418
|
-
}
|
|
6680
|
+
if (next.id !== type.id) retype = next;
|
|
6419
6681
|
type = next;
|
|
6420
6682
|
}
|
|
6421
6683
|
let mode = row.condition_mode;
|
|
@@ -6429,11 +6691,19 @@ edgeRoutes.patch("/:id", requireWorkflowPlane, async (c) => {
|
|
|
6429
6691
|
mode = v.mode;
|
|
6430
6692
|
stored = v.stored;
|
|
6431
6693
|
}
|
|
6432
|
-
await db.
|
|
6433
|
-
|
|
6434
|
-
|
|
6435
|
-
|
|
6436
|
-
|
|
6694
|
+
await db.transaction(async (tx) => {
|
|
6695
|
+
if (retype) {
|
|
6696
|
+
await assertNoDuplicateEdge(tx, nestId, row.from_node, row.to_node, retype, id);
|
|
6697
|
+
if (retype.is_flow && !wasFlow) {
|
|
6698
|
+
await assertNoFlowCycle(tx, nestId, row.from_node, row.to_node);
|
|
6699
|
+
}
|
|
6700
|
+
}
|
|
6701
|
+
await tx.run(
|
|
6702
|
+
`UPDATE edges SET type_id = ?, condition_mode = ?, condition = ?, updated_at = ?
|
|
6703
|
+
WHERE id = ?`,
|
|
6704
|
+
[type.id, mode, stored, (/* @__PURE__ */ new Date()).toISOString(), id]
|
|
6705
|
+
);
|
|
6706
|
+
});
|
|
6437
6707
|
const updated = await db.get("SELECT * FROM edges WHERE id = ?", [id]);
|
|
6438
6708
|
return c.json({ edge: toResponse(updated, type) });
|
|
6439
6709
|
});
|
|
@@ -6768,6 +7038,7 @@ async function createEdge(ctx, args) {
|
|
|
6768
7038
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
6769
7039
|
const id = uuid8();
|
|
6770
7040
|
await db.transaction(async (tx) => {
|
|
7041
|
+
await assertNoDuplicateEdge(tx, ctx.nestId, fromNode, toNode, type);
|
|
6771
7042
|
if (type.is_flow) {
|
|
6772
7043
|
await assertNoFlowCycle(tx, ctx.nestId, fromNode, toNode);
|
|
6773
7044
|
}
|
|
@@ -6868,6 +7139,30 @@ function normalizeHops(raw) {
|
|
|
6868
7139
|
if (!Number.isFinite(n)) return 2;
|
|
6869
7140
|
return Math.max(0, Math.min(MAX_HOPS, Math.floor(n)));
|
|
6870
7141
|
}
|
|
7142
|
+
function toolError(text, code) {
|
|
7143
|
+
return { text, isError: true, data: { code, message: text } };
|
|
7144
|
+
}
|
|
7145
|
+
function toCallToolResult(out) {
|
|
7146
|
+
if (typeof out === "string") {
|
|
7147
|
+
return { content: [{ type: "text", text: out }] };
|
|
7148
|
+
}
|
|
7149
|
+
return {
|
|
7150
|
+
content: [{ type: "text", text: out.text }],
|
|
7151
|
+
structuredContent: out.data,
|
|
7152
|
+
...out.isError ? { isError: true } : {}
|
|
7153
|
+
};
|
|
7154
|
+
}
|
|
7155
|
+
function summarizeNode(node, body) {
|
|
7156
|
+
return {
|
|
7157
|
+
id: node.id,
|
|
7158
|
+
title: node.frontmatter.title,
|
|
7159
|
+
description: node.frontmatter.description,
|
|
7160
|
+
type: node.frontmatter.type || "document",
|
|
7161
|
+
status: node.frontmatter.status || "draft",
|
|
7162
|
+
tags: node.frontmatter.tags,
|
|
7163
|
+
body: body ?? void 0
|
|
7164
|
+
};
|
|
7165
|
+
}
|
|
6871
7166
|
var TOOL_DEFINITIONS = [
|
|
6872
7167
|
{
|
|
6873
7168
|
name: "context_init",
|
|
@@ -6936,12 +7231,23 @@ var TOOL_DEFINITIONS = [
|
|
|
6936
7231
|
},
|
|
6937
7232
|
{
|
|
6938
7233
|
name: "context_list",
|
|
6939
|
-
description: "Browse vault contents with optional type, tag, or limit filters.",
|
|
7234
|
+
description: "Browse vault contents with optional type, tag, status, or limit filters.",
|
|
6940
7235
|
inputSchema: {
|
|
6941
7236
|
type: "object",
|
|
6942
7237
|
properties: {
|
|
6943
7238
|
type: { type: "string", description: "Filter by node type" },
|
|
6944
|
-
tag: {
|
|
7239
|
+
tag: {
|
|
7240
|
+
type: "string",
|
|
7241
|
+
description: "Filter by tag (leading # optional, case-insensitive)"
|
|
7242
|
+
},
|
|
7243
|
+
status: {
|
|
7244
|
+
type: "string",
|
|
7245
|
+
description: "Filter by status: draft | pending_review | approved | published | rejected. Aliases normalized. Unlike the CLI and the OSS server, retired nodes stay in this listing whether or not you ask \u2014 here they are unpublished documents their stewards may still act on."
|
|
7246
|
+
},
|
|
7247
|
+
approved_only: {
|
|
7248
|
+
type: "boolean",
|
|
7249
|
+
description: "Return only nodes with an approved version, each served at that version rather than the live file \u2014 the view a public nest gives a stranger."
|
|
7250
|
+
},
|
|
6945
7251
|
limit: { type: "number", description: "Max nodes to return" }
|
|
6946
7252
|
}
|
|
6947
7253
|
}
|
|
@@ -7023,13 +7329,20 @@ var TOOL_DEFINITIONS = [
|
|
|
7023
7329
|
},
|
|
7024
7330
|
{
|
|
7025
7331
|
name: "context_update",
|
|
7026
|
-
description: "Update an existing node \u2014 append,
|
|
7332
|
+
description: "Update an existing node \u2014 replace or append content, set tags, move it through its lifecycle. Whether the edit publishes is decided by this nest's stewardship rules, not by the caller.",
|
|
7027
7333
|
inputSchema: {
|
|
7028
7334
|
type: "object",
|
|
7029
7335
|
properties: {
|
|
7030
7336
|
title: {
|
|
7337
|
+
// Selects the node; it does NOT rename it. Titles are unique here
|
|
7338
|
+
// precisely so MCP callers can address a document this way. Renaming
|
|
7339
|
+
// is not reachable from this tool.
|
|
7340
|
+
type: "string",
|
|
7341
|
+
description: "Title of node to update (selects it; does not rename)"
|
|
7342
|
+
},
|
|
7343
|
+
id: {
|
|
7031
7344
|
type: "string",
|
|
7032
|
-
description: "
|
|
7345
|
+
description: 'Node id / path (e.g. "nodes/gtm/deals/win-loss-log"). Use instead of title when two nodes share a title or the title is unknown.'
|
|
7033
7346
|
},
|
|
7034
7347
|
content: {
|
|
7035
7348
|
type: "string",
|
|
@@ -7041,9 +7354,19 @@ var TOOL_DEFINITIONS = [
|
|
|
7041
7354
|
items: { type: "string" },
|
|
7042
7355
|
description: "Tags to add"
|
|
7043
7356
|
},
|
|
7044
|
-
|
|
7045
|
-
|
|
7046
|
-
|
|
7357
|
+
status: {
|
|
7358
|
+
type: "string",
|
|
7359
|
+
description: "New lifecycle status: draft | pending_review | approved | published | rejected. Aliases are normalized."
|
|
7360
|
+
},
|
|
7361
|
+
changeNote: {
|
|
7362
|
+
type: "string",
|
|
7363
|
+
description: "Why this changed \u2014 recorded in version history"
|
|
7364
|
+
}
|
|
7365
|
+
}
|
|
7366
|
+
// Neither is required alone — one of them is (the engine catalog's
|
|
7367
|
+
// `.refine(id || title)`, which JSON Schema can't express here).
|
|
7368
|
+
// `title` was required, which made this tool unreachable for `ctx`:
|
|
7369
|
+
// it addresses nodes by id and never sends a title.
|
|
7047
7370
|
}
|
|
7048
7371
|
},
|
|
7049
7372
|
{
|
|
@@ -7110,7 +7433,7 @@ var TOOL_DEFINITIONS = [
|
|
|
7110
7433
|
// repo's governance tables, which the engine doesn't own. Adopt an engine
|
|
7111
7434
|
// descriptor here if the operation is ever promoted upstream.
|
|
7112
7435
|
name: "context_request_deletion",
|
|
7113
|
-
description: "Ask for something to be deleted when you can't delete it yourself. Flags a document, a folder (with everything in it), or the whole nest for a nest owner/admin to action. Nothing is removed by this call \u2014 it opens a request, and the person who resolves it either deletes the target or
|
|
7436
|
+
description: "Ask for something to be deleted when you can't delete it yourself. Flags a document, a folder (with everything in it), or the whole nest for a nest owner/admin to action. Nothing is removed by this call \u2014 it opens a request, and the person who resolves it either deletes the target or rejects it with a note. Use this instead of context_delete when that returns a permission error, or when the document belongs to someone else.",
|
|
7114
7437
|
inputSchema: {
|
|
7115
7438
|
type: "object",
|
|
7116
7439
|
properties: {
|
|
@@ -7143,14 +7466,14 @@ var TOOL_DEFINITIONS = [
|
|
|
7143
7466
|
properties: {
|
|
7144
7467
|
status: {
|
|
7145
7468
|
type: "string",
|
|
7146
|
-
description: "pending (default) or declined \u2014
|
|
7469
|
+
description: '"pending" (default) or "declined" \u2014 past rejections and their notes.'
|
|
7147
7470
|
}
|
|
7148
7471
|
}
|
|
7149
7472
|
}
|
|
7150
7473
|
},
|
|
7151
7474
|
{
|
|
7152
7475
|
name: "context_resolve_deletion",
|
|
7153
|
-
description: "Action a deletion request: delete the flagged document/folder/nest, or
|
|
7476
|
+
description: "Action a deletion request: delete the flagged document/folder/nest, or reject it with a note explaining why it stays. Owner/admin only. Rejecting tells the requester the verdict; deleting is irreversible.",
|
|
7154
7477
|
inputSchema: {
|
|
7155
7478
|
type: "object",
|
|
7156
7479
|
properties: {
|
|
@@ -7160,11 +7483,11 @@ var TOOL_DEFINITIONS = [
|
|
|
7160
7483
|
},
|
|
7161
7484
|
action: {
|
|
7162
7485
|
type: "string",
|
|
7163
|
-
description: '"delete" to honour it, "decline" to keep the target'
|
|
7486
|
+
description: '"delete" to honour it, "decline" to reject it and keep the target'
|
|
7164
7487
|
},
|
|
7165
7488
|
note: {
|
|
7166
7489
|
type: "string",
|
|
7167
|
-
description:
|
|
7490
|
+
description: 'Why you rejected it. Required when action is "decline".'
|
|
7168
7491
|
}
|
|
7169
7492
|
},
|
|
7170
7493
|
required: ["request_id", "action"]
|
|
@@ -7234,9 +7557,13 @@ var TOOL_DEFINITIONS = [
|
|
|
7234
7557
|
inputSchema: {
|
|
7235
7558
|
type: "object",
|
|
7236
7559
|
properties: {
|
|
7237
|
-
title: { type: "string", description: "Title of the node" }
|
|
7238
|
-
|
|
7239
|
-
|
|
7560
|
+
title: { type: "string", description: "Title of the node" },
|
|
7561
|
+
id: {
|
|
7562
|
+
type: "string",
|
|
7563
|
+
description: 'Node id / path (e.g. "nodes/gtm/deals/win-loss-log"). Use instead of title when two nodes share a title or the title is unknown.'
|
|
7564
|
+
}
|
|
7565
|
+
}
|
|
7566
|
+
// One of id or title — see context_update.
|
|
7240
7567
|
}
|
|
7241
7568
|
},
|
|
7242
7569
|
{
|
|
@@ -7347,7 +7674,7 @@ async function resolveTargetNode(storage, args) {
|
|
|
7347
7674
|
if (!args.id && !args.title) return "Provide a title or an id.";
|
|
7348
7675
|
if (args.id) {
|
|
7349
7676
|
try {
|
|
7350
|
-
return await storage.readDocument(args.id);
|
|
7677
|
+
return await storage.readDocument(assertSafeNodeId(args.id));
|
|
7351
7678
|
} catch {
|
|
7352
7679
|
return `Node not found: ${args.id}`;
|
|
7353
7680
|
}
|
|
@@ -7365,36 +7692,47 @@ async function runTool(toolName, args, ctx) {
|
|
|
7365
7692
|
const { storage, queryEngine, versionManager, nestId, userId, userEmail } = ctx;
|
|
7366
7693
|
switch (toolName) {
|
|
7367
7694
|
case "context_init": {
|
|
7368
|
-
const
|
|
7369
|
-
|
|
7695
|
+
const { context_md } = await engineApi.run(
|
|
7696
|
+
"context_init",
|
|
7697
|
+
{},
|
|
7698
|
+
await opContext(nestId, userEmail)
|
|
7699
|
+
);
|
|
7700
|
+
if (!context_md)
|
|
7370
7701
|
return "No CONTEXT.md found. Use context_overview to see what's available.";
|
|
7371
7702
|
return `# Vault Instructions (CONTEXT.md)
|
|
7372
7703
|
|
|
7373
|
-
${
|
|
7704
|
+
${context_md}`;
|
|
7374
7705
|
}
|
|
7375
7706
|
case "context_overview": {
|
|
7376
|
-
const
|
|
7707
|
+
const { total } = await engineApi.run(
|
|
7708
|
+
"context_init",
|
|
7709
|
+
{},
|
|
7710
|
+
await opContext(nestId, userEmail)
|
|
7711
|
+
);
|
|
7712
|
+
const visible = await listNodesForCallerByEmail(nestId, userEmail, {});
|
|
7377
7713
|
const types = {};
|
|
7378
7714
|
const tags = {};
|
|
7379
|
-
for (const n of
|
|
7380
|
-
|
|
7381
|
-
|
|
7382
|
-
|
|
7383
|
-
}
|
|
7384
|
-
const
|
|
7385
|
-
|
|
7386
|
-
|
|
7387
|
-
(n, i) => `${i + 1}. **${n.frontmatter.title}** [${n.frontmatter.type || "document"}] ${(n.frontmatter.tags || []).slice(0, 4).join(" ")}
|
|
7388
|
-
${(n.body || "").slice(0, 120).replace(/\n/g, " ")}`
|
|
7715
|
+
for (const n of visible) {
|
|
7716
|
+
types[n.type] = (types[n.type] || 0) + 1;
|
|
7717
|
+
for (const tag of n.tags || []) tags[tag] = (tags[tag] || 0) + 1;
|
|
7718
|
+
}
|
|
7719
|
+
const fmt = (counts) => Object.entries(counts).sort((a, b) => b[1] - a[1]).map(([k, c]) => ` ${k}: ${c}`).join("\n");
|
|
7720
|
+
const nodeList = visible.map(
|
|
7721
|
+
(n, i) => `${i + 1}. **${n.title}** [${n.type}] ${(n.tags || []).slice(0, 4).join(" ")}
|
|
7722
|
+
${(n.content || "").slice(0, 120).replace(/\n/g, " ")}`
|
|
7389
7723
|
).join("\n\n");
|
|
7724
|
+
const withheld = total - visible.length;
|
|
7725
|
+
const note = withheld > 0 ? `
|
|
7726
|
+
_${withheld} node(s) not visible to you._
|
|
7727
|
+
` : "";
|
|
7390
7728
|
return `# Vault Overview
|
|
7391
|
-
**Total nodes:** ${
|
|
7392
|
-
|
|
7729
|
+
**Total nodes:** ${visible.length}
|
|
7730
|
+
${note}
|
|
7393
7731
|
## Types
|
|
7394
|
-
${
|
|
7732
|
+
${fmt(types)}
|
|
7395
7733
|
|
|
7396
7734
|
## Tags
|
|
7397
|
-
${
|
|
7735
|
+
${fmt(tags)}
|
|
7398
7736
|
|
|
7399
7737
|
## All Nodes
|
|
7400
7738
|
|
|
@@ -7416,14 +7754,26 @@ ${nodeList}`;
|
|
|
7416
7754
|
].join(" ").toLowerCase();
|
|
7417
7755
|
return terms.every((t) => hay.includes(t));
|
|
7418
7756
|
});
|
|
7419
|
-
if (!matches.length)
|
|
7757
|
+
if (!matches.length)
|
|
7758
|
+
return {
|
|
7759
|
+
text: `No nodes matched search: "${args.query}"`,
|
|
7760
|
+
data: { results: [] }
|
|
7761
|
+
};
|
|
7420
7762
|
const results = matches.map(
|
|
7421
7763
|
({ node: n, body }, i) => `${i + 1}. **${n.frontmatter.title}** [${n.frontmatter.type || "document"}] ${(n.frontmatter.tags || []).join(" ")}
|
|
7422
7764
|
${(body || "").slice(0, 200).replace(/\n/g, " ")}`
|
|
7423
7765
|
).join("\n\n");
|
|
7424
|
-
return
|
|
7425
|
-
|
|
7426
|
-
|
|
7766
|
+
return {
|
|
7767
|
+
text: `Found ${matches.length} node(s) matching "${args.query}":
|
|
7768
|
+
|
|
7769
|
+
${results}`,
|
|
7770
|
+
// Same gated bodies the prose is built from — `resolveLlmBody`
|
|
7771
|
+
// already refused everything this caller may not read. No `score`:
|
|
7772
|
+
// this is a term-containment filter, not a ranked search.
|
|
7773
|
+
data: {
|
|
7774
|
+
results: matches.map(({ node: n, body }) => summarizeNode(n, body))
|
|
7775
|
+
}
|
|
7776
|
+
};
|
|
7427
7777
|
}
|
|
7428
7778
|
case "context_table_query": {
|
|
7429
7779
|
const result = await queryTableNode(
|
|
@@ -7440,61 +7790,119 @@ ${results}`;
|
|
|
7440
7790
|
${lines.join("\n")}`;
|
|
7441
7791
|
}
|
|
7442
7792
|
case "context_query": {
|
|
7443
|
-
const
|
|
7444
|
-
|
|
7445
|
-
|
|
7793
|
+
const { documents, traversal } = await engineApi.run(
|
|
7794
|
+
"context_query",
|
|
7795
|
+
{ query: args.query, hops: normalizeHops(args.hops) },
|
|
7796
|
+
await opContext(nestId, userEmail)
|
|
7797
|
+
);
|
|
7446
7798
|
const visibility = await Promise.all(
|
|
7447
|
-
|
|
7448
|
-
|
|
7449
|
-
|
|
7799
|
+
documents.map(async (d) => ({
|
|
7800
|
+
doc: d,
|
|
7801
|
+
body: await resolveLlmBody(ctx, {
|
|
7802
|
+
id: d.id,
|
|
7803
|
+
body: d.body ?? "",
|
|
7804
|
+
frontmatter: { title: d.title }
|
|
7805
|
+
})
|
|
7450
7806
|
}))
|
|
7451
7807
|
);
|
|
7452
|
-
const
|
|
7453
|
-
|
|
7454
|
-
|
|
7455
|
-
|
|
7456
|
-
|
|
7457
|
-
|
|
7458
|
-
|
|
7459
|
-
${
|
|
7808
|
+
const visible = visibility.filter((e) => e.body !== null);
|
|
7809
|
+
const nodes = visible.map((e) => e.doc);
|
|
7810
|
+
if (!nodes.length)
|
|
7811
|
+
return {
|
|
7812
|
+
text: `No nodes matched: ${args.query}`,
|
|
7813
|
+
data: { documents: [], traversal }
|
|
7814
|
+
};
|
|
7815
|
+
const list = nodes.map((n, i) => `${i + 1}. **${n.title}** [${n.type}] ${(n.tags || []).join(" ")}`).join("\n\n");
|
|
7816
|
+
return {
|
|
7817
|
+
text: `Found ${nodes.length} node(s) for \`${args.query}\`:
|
|
7818
|
+
|
|
7819
|
+
${list}`,
|
|
7820
|
+
// The engine already hands back catalog `nodeSummary` shapes — only
|
|
7821
|
+
// `body` is swapped for the gated one resolveLlmBody returned.
|
|
7822
|
+
// `source_nodes` is deliberately absent: this handler never gates
|
|
7823
|
+
// them, so serving them would hand out content the prose refuses.
|
|
7824
|
+
data: {
|
|
7825
|
+
documents: visible.map((e) => ({ ...e.doc, body: e.body ?? void 0 })),
|
|
7826
|
+
traversal
|
|
7827
|
+
}
|
|
7828
|
+
};
|
|
7460
7829
|
}
|
|
7461
7830
|
case "context_get": {
|
|
7462
|
-
const docs = await storage.discoverDocuments();
|
|
7463
7831
|
let node;
|
|
7464
|
-
|
|
7465
|
-
node =
|
|
7466
|
-
|
|
7832
|
+
try {
|
|
7833
|
+
node = await getNodeForCaller(
|
|
7834
|
+
nestId,
|
|
7835
|
+
args.title ? { title: args.title } : { id: args.id },
|
|
7836
|
+
ctx.userId,
|
|
7837
|
+
userEmail
|
|
7838
|
+
);
|
|
7839
|
+
} catch {
|
|
7840
|
+
return toolError(
|
|
7841
|
+
`Node not found: ${args.title || args.id}`,
|
|
7842
|
+
"DOCUMENT_NOT_FOUND"
|
|
7467
7843
|
);
|
|
7468
|
-
} else if (args.id) {
|
|
7469
|
-
node = docs.find((n) => n.id === args.id);
|
|
7470
7844
|
}
|
|
7471
|
-
if (!node) return `Node not found: ${args.title || args.id}`;
|
|
7472
7845
|
const body = await resolveLlmBody(ctx, node);
|
|
7473
7846
|
if (body === null) {
|
|
7474
|
-
return
|
|
7847
|
+
return toolError(
|
|
7848
|
+
`Node "${node.frontmatter.title}" has no approved version yet \u2014 not available to AI.`,
|
|
7849
|
+
"UNAUTHORIZED_ACTION"
|
|
7850
|
+
);
|
|
7475
7851
|
}
|
|
7476
7852
|
const meta = [
|
|
7477
7853
|
`**Title:** ${node.frontmatter.title}`,
|
|
7478
7854
|
`**Type:** ${node.frontmatter.type || "document"}`,
|
|
7479
7855
|
node.frontmatter.tags?.length ? `**Tags:** ${node.frontmatter.tags.join(" ")}` : null
|
|
7480
7856
|
].filter(Boolean).join("\n");
|
|
7481
|
-
return
|
|
7857
|
+
return {
|
|
7858
|
+
text: `${meta}
|
|
7482
7859
|
|
|
7483
7860
|
---
|
|
7484
7861
|
|
|
7485
|
-
${body || "(no content)"}
|
|
7862
|
+
${body || "(no content)"}`,
|
|
7863
|
+
// `body` is the gated one, not node.body — under stewardship that is
|
|
7864
|
+
// the APPROVED snapshot, which can be older than the live draft this
|
|
7865
|
+
// frontmatter came from. So the payload carries only the fields the
|
|
7866
|
+
// prose above already reports, none of which claim to describe a
|
|
7867
|
+
// specific version. `version`, `status`, `checksum` and the
|
|
7868
|
+
// timestamps are deliberately absent: they describe the draft, and
|
|
7869
|
+
// pairing them with an approved body would misdescribe it (checksum
|
|
7870
|
+
// would also be a draft hash — see context_versions). `raw` is absent
|
|
7871
|
+
// too: no include_raw input here (Phase 2).
|
|
7872
|
+
data: {
|
|
7873
|
+
id: node.id,
|
|
7874
|
+
frontmatter: {
|
|
7875
|
+
title: node.frontmatter.title,
|
|
7876
|
+
description: node.frontmatter.description,
|
|
7877
|
+
type: node.frontmatter.type || "document",
|
|
7878
|
+
tags: node.frontmatter.tags
|
|
7879
|
+
},
|
|
7880
|
+
body
|
|
7881
|
+
}
|
|
7882
|
+
};
|
|
7486
7883
|
}
|
|
7487
7884
|
case "context_list": {
|
|
7488
7885
|
const nodes = await listNodesForCallerByEmail(nestId, userEmail, {
|
|
7489
7886
|
type: args.type,
|
|
7490
7887
|
tag: args.tag,
|
|
7888
|
+
status: args.status,
|
|
7889
|
+
approvedOnly: args.approved_only === true,
|
|
7491
7890
|
limit: args.limit || 50
|
|
7492
7891
|
});
|
|
7493
|
-
|
|
7892
|
+
const documents = nodes.map((n) => ({
|
|
7893
|
+
id: n.id,
|
|
7894
|
+
title: n.title,
|
|
7895
|
+
description: n.description,
|
|
7896
|
+
type: n.type || "document",
|
|
7897
|
+
status: n.status || "draft",
|
|
7898
|
+
tags: n.tags
|
|
7899
|
+
}));
|
|
7900
|
+
if (!nodes.length)
|
|
7901
|
+
return { text: "No nodes found with the given filters.", data: { documents } };
|
|
7494
7902
|
const list = nodes.map((n, i) => `${i + 1}. **${n.title}** [${n.type}]`).join("\n");
|
|
7495
|
-
return `${nodes.length} node(s):
|
|
7903
|
+
return { text: `${nodes.length} node(s):
|
|
7496
7904
|
|
|
7497
|
-
${list}
|
|
7905
|
+
${list}`, data: { documents } };
|
|
7498
7906
|
}
|
|
7499
7907
|
case "context_resolve": {
|
|
7500
7908
|
const result = await queryEngine.query(args.selector, {
|
|
@@ -7592,7 +8000,10 @@ ${sections}`;
|
|
|
7592
8000
|
}
|
|
7593
8001
|
case "context_create": {
|
|
7594
8002
|
if (!await canCreateInNest(nestId, userEmail)) {
|
|
7595
|
-
return
|
|
8003
|
+
return toolError(
|
|
8004
|
+
"You don't have permission to create documents in this nest.",
|
|
8005
|
+
"UNAUTHORIZED_ACTION"
|
|
8006
|
+
);
|
|
7596
8007
|
}
|
|
7597
8008
|
const wanted = String(args.title ?? "").toLowerCase();
|
|
7598
8009
|
return withNodeWriteLock(nodeWriteKey(nestId, "*create*"), async () => {
|
|
@@ -7601,9 +8012,19 @@ ${sections}`;
|
|
|
7601
8012
|
(n) => String(n.frontmatter.title ?? "").toLowerCase() === wanted
|
|
7602
8013
|
);
|
|
7603
8014
|
if (twin) {
|
|
7604
|
-
|
|
8015
|
+
const twinVersions = await getVersions(nestId, twin.id);
|
|
8016
|
+
if (twinVersions.length === 0) {
|
|
8017
|
+
return toolError(
|
|
8018
|
+
`A node titled "${twin.frontmatter.title}" already exists (${twin.id}) but has no version history, which is why it doesn't show up in a read or a draft list. It may be an interrupted create or a document whose import never finished. Write into it with context_update "${twin.id}" to keep whatever is there, or remove it with context_delete "${twin.id}" and create again \u2014 check its content first.`,
|
|
8019
|
+
"DOCUMENT_ALREADY_EXISTS"
|
|
8020
|
+
);
|
|
8021
|
+
}
|
|
8022
|
+
return toolError(
|
|
8023
|
+
`A node titled "${twin.frontmatter.title}" already exists (${twin.id}). Titles must be unique \u2014 MCP tools resolve documents by title, so a second one would be unreachable. Use context_update to change it, context_move to refile it, or create this one under a different title.`,
|
|
8024
|
+
"DOCUMENT_ALREADY_EXISTS"
|
|
8025
|
+
);
|
|
7605
8026
|
}
|
|
7606
|
-
const { node } = await createNode(
|
|
8027
|
+
const { node, version } = await createNode(
|
|
7607
8028
|
nestId,
|
|
7608
8029
|
{
|
|
7609
8030
|
title: args.title,
|
|
@@ -7615,42 +8036,75 @@ ${sections}`;
|
|
|
7615
8036
|
},
|
|
7616
8037
|
userEmail
|
|
7617
8038
|
);
|
|
7618
|
-
return
|
|
8039
|
+
return {
|
|
8040
|
+
text: `Created node: **${args.title}** (${node.id}) \u2014 status: ${node.frontmatter.status}`,
|
|
8041
|
+
// checkpoint is null by construction: this server seals nothing —
|
|
8042
|
+
// a write becomes live through steward review, not a checkpoint.
|
|
8043
|
+
data: {
|
|
8044
|
+
id: node.id,
|
|
8045
|
+
version,
|
|
8046
|
+
status: node.frontmatter.status,
|
|
8047
|
+
checkpoint: null
|
|
8048
|
+
}
|
|
8049
|
+
};
|
|
7619
8050
|
});
|
|
7620
8051
|
}
|
|
7621
8052
|
case "context_update": {
|
|
7622
|
-
const
|
|
7623
|
-
|
|
7624
|
-
|
|
7625
|
-
);
|
|
7626
|
-
if (!node) return `Node not found: ${args.title}`;
|
|
8053
|
+
const node = await resolveTargetNode(storage, args);
|
|
8054
|
+
if (typeof node === "string") return toolError(node, "DOCUMENT_NOT_FOUND");
|
|
8055
|
+
const label = args.id ? node.frontmatter.title : args.title;
|
|
7627
8056
|
const editCheck = await canUserEdit(nestId, node.id, userEmail);
|
|
7628
8057
|
if (!editCheck.allowed) {
|
|
7629
|
-
return
|
|
8058
|
+
return toolError(
|
|
8059
|
+
`You don't have permission to edit "${label}": ${editCheck.reason}`,
|
|
8060
|
+
"UNAUTHORIZED_ACTION"
|
|
8061
|
+
);
|
|
7630
8062
|
}
|
|
7631
|
-
const
|
|
8063
|
+
const mergedTags = args.tags ? [.../* @__PURE__ */ new Set([...node.frontmatter.tags || [], ...args.tags])] : void 0;
|
|
8064
|
+
const { node: updated, version } = await updateNode(
|
|
7632
8065
|
nestId,
|
|
7633
8066
|
node.id,
|
|
7634
8067
|
{
|
|
7635
8068
|
content: args.content,
|
|
7636
8069
|
append: args.append,
|
|
7637
|
-
tags:
|
|
8070
|
+
tags: mergedTags,
|
|
8071
|
+
status: args.status,
|
|
8072
|
+
changeNote: args.changeNote
|
|
7638
8073
|
},
|
|
7639
8074
|
userEmail
|
|
7640
8075
|
);
|
|
7641
|
-
return
|
|
8076
|
+
return {
|
|
8077
|
+
text: `Updated node: **${updated.frontmatter.title}** \u2014 v${version}, status: ${updated.frontmatter.status}`,
|
|
8078
|
+
// checkpoint null — see context_create.
|
|
8079
|
+
data: {
|
|
8080
|
+
id: updated.id,
|
|
8081
|
+
version,
|
|
8082
|
+
status: updated.frontmatter.status,
|
|
8083
|
+
checkpoint: null
|
|
8084
|
+
}
|
|
8085
|
+
};
|
|
7642
8086
|
}
|
|
7643
8087
|
case "context_delete": {
|
|
7644
8088
|
const node = await resolveTargetNode(storage, args);
|
|
7645
|
-
if (typeof node === "string") return node;
|
|
8089
|
+
if (typeof node === "string") return toolError(node, "DOCUMENT_NOT_FOUND");
|
|
7646
8090
|
const deleteCheck = await canUserEdit(nestId, node.id, userEmail);
|
|
7647
8091
|
if (!deleteCheck.allowed) {
|
|
7648
|
-
return
|
|
8092
|
+
return toolError(
|
|
8093
|
+
`You don't have permission to delete "${node.frontmatter.title}": ${deleteCheck.reason}
|
|
7649
8094
|
|
|
7650
|
-
Use context_request_deletion to ask a nest owner/admin to delete it, with a reason
|
|
8095
|
+
Use context_request_deletion to ask a nest owner/admin to delete it, with a reason.`,
|
|
8096
|
+
"UNAUTHORIZED_ACTION"
|
|
8097
|
+
);
|
|
7651
8098
|
}
|
|
7652
8099
|
await deleteNode(nestId, node.id, userEmail);
|
|
7653
|
-
return
|
|
8100
|
+
return {
|
|
8101
|
+
text: `Deleted node: **${node.frontmatter.title}** (${node.id})`,
|
|
8102
|
+
data: {
|
|
8103
|
+
id: node.id,
|
|
8104
|
+
title: node.frontmatter.title,
|
|
8105
|
+
deleted: true
|
|
8106
|
+
}
|
|
8107
|
+
};
|
|
7654
8108
|
}
|
|
7655
8109
|
case "context_move": {
|
|
7656
8110
|
if (typeof args.folder !== "string") {
|
|
@@ -7718,6 +8172,9 @@ ${sections}`;
|
|
|
7718
8172
|
return "reason is required \u2014 say why it should be deleted; that is what the admin judges.";
|
|
7719
8173
|
}
|
|
7720
8174
|
if (args.nest === true) {
|
|
8175
|
+
if (!await canRequestDeletion({ nestId, userId, userEmail, nodeId: "" })) {
|
|
8176
|
+
return "Only an editor, a nest admin, or the nest owner can request the deletion of a nest.";
|
|
8177
|
+
}
|
|
7721
8178
|
const request2 = await requestDeletion({
|
|
7722
8179
|
nestId,
|
|
7723
8180
|
nodeId: nestId,
|
|
@@ -7726,10 +8183,13 @@ ${sections}`;
|
|
|
7726
8183
|
reason,
|
|
7727
8184
|
baseUrl: ctx.baseUrl
|
|
7728
8185
|
});
|
|
7729
|
-
return `Requested deletion of the **entire nest**. A nest owner or admin will delete it or
|
|
8186
|
+
return `Requested deletion of the **entire nest**. A nest owner or admin will delete it or reject the request.
|
|
7730
8187
|
Request id: \`${request2.id}\``;
|
|
7731
8188
|
}
|
|
7732
8189
|
if (typeof args.folder === "string" && args.folder.trim()) {
|
|
8190
|
+
if (!await canRequestDeletion({ nestId, userId, userEmail, nodeId: "" })) {
|
|
8191
|
+
return "Only an editor, a nest admin, or the nest owner can request the deletion of a folder.";
|
|
8192
|
+
}
|
|
7733
8193
|
const folder = args.folder.trim().replace(/^\/+|\/+$/g, "").replace(/^nodes\//, "");
|
|
7734
8194
|
const docs = await storage.discoverDocuments();
|
|
7735
8195
|
const inside = docs.filter((d) => isIdUnderFolder(d.id, folder));
|
|
@@ -7744,7 +8204,7 @@ Request id: \`${request2.id}\``;
|
|
|
7744
8204
|
reason,
|
|
7745
8205
|
baseUrl: ctx.baseUrl
|
|
7746
8206
|
});
|
|
7747
|
-
return `Requested deletion of folder **${folder}** (${inside.length} document(s) inside). A nest owner or admin will delete it or
|
|
8207
|
+
return `Requested deletion of folder **${folder}** (${inside.length} document(s) inside). A nest owner or admin will delete it or reject the request.
|
|
7748
8208
|
Request id: \`${request2.id}\``;
|
|
7749
8209
|
}
|
|
7750
8210
|
const node = await resolveTargetNode(storage, args);
|
|
@@ -7752,6 +8212,14 @@ Request id: \`${request2.id}\``;
|
|
|
7752
8212
|
if (!await canReadNode(nestId, node.id, userId, userEmail)) {
|
|
7753
8213
|
return `Node not found: ${node.id}`;
|
|
7754
8214
|
}
|
|
8215
|
+
if (!await canRequestDeletion({
|
|
8216
|
+
nestId,
|
|
8217
|
+
userId,
|
|
8218
|
+
userEmail,
|
|
8219
|
+
nodeId: node.id
|
|
8220
|
+
})) {
|
|
8221
|
+
return "Only an editor, a nest admin, or the nest owner can request the deletion of a document.";
|
|
8222
|
+
}
|
|
7755
8223
|
const request = await requestDeletion({
|
|
7756
8224
|
nestId,
|
|
7757
8225
|
nodeId: node.id,
|
|
@@ -7760,19 +8228,19 @@ Request id: \`${request2.id}\``;
|
|
|
7760
8228
|
reason,
|
|
7761
8229
|
baseUrl: ctx.baseUrl
|
|
7762
8230
|
});
|
|
7763
|
-
return `Requested deletion of **${node.frontmatter.title}**. A nest owner or admin will delete it or
|
|
8231
|
+
return `Requested deletion of **${node.frontmatter.title}**. A nest owner or admin will delete it or reject the request \u2014 nothing is removed yet.
|
|
7764
8232
|
Request id: \`${request.id}\``;
|
|
7765
8233
|
}
|
|
7766
8234
|
case "context_deletion_queue": {
|
|
7767
8235
|
const status = args.status === "declined" ? "declined" : "pending";
|
|
7768
8236
|
const requests = await listDeletionRequests({ nestId, status });
|
|
7769
8237
|
if (requests.length === 0) {
|
|
7770
|
-
return status === "pending" ? "No deletion requests waiting. All caught up!" : "No
|
|
8238
|
+
return status === "pending" ? "No deletion requests waiting. All caught up!" : "No rejected deletion requests.";
|
|
7771
8239
|
}
|
|
7772
8240
|
const list = requests.map((r, i) => {
|
|
7773
8241
|
const kind = r.targetType === "document" ? "" : `${r.targetType}: `;
|
|
7774
8242
|
const verdict = r.status === "declined" ? `
|
|
7775
|
-
|
|
8243
|
+
Rejected by ${r.resolvedBy}: "${r.resolutionNote}"` : "";
|
|
7776
8244
|
return `${i + 1}. **${kind}${r.title || r.nodeId}**
|
|
7777
8245
|
Asked by ${r.requestedBy}: "${r.reason}"
|
|
7778
8246
|
Request id: \`${r.id}\`${verdict}`;
|
|
@@ -7800,7 +8268,7 @@ ${list}`;
|
|
|
7800
8268
|
if (action === "decline") {
|
|
7801
8269
|
const note = typeof args.note === "string" ? args.note.trim() : "";
|
|
7802
8270
|
if (!note) {
|
|
7803
|
-
return "note is required when
|
|
8271
|
+
return "note is required when rejecting \u2014 the requester is told why it stays.";
|
|
7804
8272
|
}
|
|
7805
8273
|
await declineDeletion({
|
|
7806
8274
|
nestId,
|
|
@@ -7809,7 +8277,7 @@ ${list}`;
|
|
|
7809
8277
|
note,
|
|
7810
8278
|
baseUrl: ctx.baseUrl
|
|
7811
8279
|
});
|
|
7812
|
-
return `
|
|
8280
|
+
return `Rejected the deletion request for **${label}**. It stays, and ${request.requestedBy} has been told why.`;
|
|
7813
8281
|
}
|
|
7814
8282
|
if (request.targetType === "nest") {
|
|
7815
8283
|
await deleteNest(nestId);
|
|
@@ -7880,13 +8348,17 @@ Submit one with \`context_submit_review\`.`;
|
|
|
7880
8348
|
const node = docs.find(
|
|
7881
8349
|
(n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
|
|
7882
8350
|
);
|
|
7883
|
-
if (!node) return `Node not found: ${args.title}
|
|
8351
|
+
if (!node) return toolError(`Node not found: ${args.title}`, "DOCUMENT_NOT_FOUND");
|
|
7884
8352
|
const submitCheck = await canUserEdit(ctx.nestId, node.id, userEmail);
|
|
7885
8353
|
if (!submitCheck.allowed) {
|
|
7886
|
-
return
|
|
8354
|
+
return toolError(
|
|
8355
|
+
`You don't have permission to submit "${args.title}" for review: ${submitCheck.reason}`,
|
|
8356
|
+
"UNAUTHORIZED_ACTION"
|
|
8357
|
+
);
|
|
7887
8358
|
}
|
|
7888
8359
|
const currentVersion = await getCurrentVersion(ctx.nestId, node.id);
|
|
7889
|
-
if (currentVersion === 0)
|
|
8360
|
+
if (currentVersion === 0)
|
|
8361
|
+
return toolError(`No versions found for "${args.title}"`, "VERSION_NOT_FOUND");
|
|
7890
8362
|
try {
|
|
7891
8363
|
const request = await submitForReview({
|
|
7892
8364
|
nestId: ctx.nestId,
|
|
@@ -7905,9 +8377,33 @@ Submit one with \`context_submit_review\`.`;
|
|
|
7905
8377
|
|
|
7906
8378
|
**Stewards who can review:**
|
|
7907
8379
|
${resolved.map((r) => `- ${r.steward.userEmail} (${r.source})`).join("\n")}` : "";
|
|
7908
|
-
return
|
|
8380
|
+
return {
|
|
8381
|
+
text: `Submitted "${args.title}" v${currentVersion} for review (${request.priority} priority).${stewardList}`,
|
|
8382
|
+
// No catalog shape to conform to — `context_submit_review` is a
|
|
8383
|
+
// governance-namespace operation and that namespace is declared but
|
|
8384
|
+
// unimplemented in the engine (`api/index.ts`, NAMESPACES). So this
|
|
8385
|
+
// is the review state the handler already computed, and nothing
|
|
8386
|
+
// more: no `version`/`checkpoint` at the top level, because this
|
|
8387
|
+
// operation writes no new version and seals no checkpoint — it
|
|
8388
|
+
// points a review request at the version that already existed.
|
|
8389
|
+
// Built from the same gated values the prose above is built from
|
|
8390
|
+
// (`request`, `resolved`), never re-read past the permission check.
|
|
8391
|
+
data: {
|
|
8392
|
+
id: node.id,
|
|
8393
|
+
submitted: true,
|
|
8394
|
+
review: {
|
|
8395
|
+
id: request.id,
|
|
8396
|
+
version: request.version,
|
|
8397
|
+
status: request.status,
|
|
8398
|
+
priority: request.priority,
|
|
8399
|
+
requested_by: request.requestedBy,
|
|
8400
|
+
requested_at: request.requestedAt
|
|
8401
|
+
},
|
|
8402
|
+
reviewers: resolved.map((r) => r.steward.userEmail)
|
|
8403
|
+
}
|
|
8404
|
+
};
|
|
7909
8405
|
} catch (err) {
|
|
7910
|
-
return `Failed to submit: ${err.message}
|
|
8406
|
+
return toolError(`Failed to submit: ${err.message}`, "INTERNAL");
|
|
7911
8407
|
}
|
|
7912
8408
|
}
|
|
7913
8409
|
case "context_approve": {
|
|
@@ -7951,23 +8447,46 @@ Reason: ${args.note}`;
|
|
|
7951
8447
|
}
|
|
7952
8448
|
}
|
|
7953
8449
|
case "context_versions": {
|
|
7954
|
-
const
|
|
7955
|
-
|
|
7956
|
-
|
|
7957
|
-
);
|
|
7958
|
-
if (!node) return `Node not found: ${args.title}`;
|
|
8450
|
+
const node = await resolveTargetNode(storage, args);
|
|
8451
|
+
if (typeof node === "string") return toolError(node, "DOCUMENT_NOT_FOUND");
|
|
8452
|
+
const label = args.id ? node.frontmatter.title : args.title;
|
|
7959
8453
|
const allVersions = await getVersions(ctx.nestId, node.id);
|
|
7960
8454
|
const approved = await getApprovedVersion(ctx.nestId, node.id);
|
|
7961
8455
|
if (allVersions.length === 0) {
|
|
7962
|
-
return
|
|
8456
|
+
return {
|
|
8457
|
+
text: `No version history for "${label}".`,
|
|
8458
|
+
data: { id: node.id, approved_version: approved ?? null, versions: [] }
|
|
8459
|
+
};
|
|
7963
8460
|
}
|
|
7964
8461
|
const list = allVersions.map(
|
|
7965
8462
|
(v) => `- **v${v.version}** ${v.status}${v.version === approved ? " (AI-active)" : ""} \u2014 by ${v.editedBy} at ${v.editedAt}${v.changeNote ? ` "${v.changeNote}"` : ""}`
|
|
7966
8463
|
).join("\n");
|
|
7967
|
-
return
|
|
8464
|
+
return {
|
|
8465
|
+
text: `# Version History: ${label}
|
|
7968
8466
|
**Current:** v${allVersions[0]?.version || 0} | **AI-active:** ${approved ? `v${approved}` : "none"}
|
|
7969
8467
|
|
|
7970
|
-
${list}
|
|
8468
|
+
${list}`,
|
|
8469
|
+
// Five catalog fields have no equivalent here and are omitted rather
|
|
8470
|
+
// than faked: `keyframe_interval` and `keyframe` (content lives on
|
|
8471
|
+
// disk — there is no keyframe+diff model), `chain_hash` (integrity is
|
|
8472
|
+
// server-side, not a per-version chain), `content_hash` (the column
|
|
8473
|
+
// exists but is conflict-detection state this tool has never served,
|
|
8474
|
+
// and it is ungated), and `published_at` (a version carries a
|
|
8475
|
+
// lifecycle status, not a publish timestamp). `status` and
|
|
8476
|
+
// `approved_version` are the community equivalent — the same two
|
|
8477
|
+
// facts the prose above reports.
|
|
8478
|
+
data: {
|
|
8479
|
+
id: node.id,
|
|
8480
|
+
approved_version: approved ?? null,
|
|
8481
|
+
versions: allVersions.map((v) => ({
|
|
8482
|
+
version: v.version,
|
|
8483
|
+
status: v.status,
|
|
8484
|
+
edited_by: v.editedBy,
|
|
8485
|
+
edited_at: v.editedAt,
|
|
8486
|
+
note: v.changeNote
|
|
8487
|
+
}))
|
|
8488
|
+
}
|
|
8489
|
+
};
|
|
7971
8490
|
}
|
|
7972
8491
|
case "context_assign_steward": {
|
|
7973
8492
|
const scope = args.scope;
|
|
@@ -8025,8 +8544,8 @@ ${list}`;
|
|
|
8025
8544
|
if (!target) return "target is required (a node id or folder prefix).";
|
|
8026
8545
|
if (!["read", "write"].includes(role)) return "role must be read or write.";
|
|
8027
8546
|
try {
|
|
8028
|
-
const { createGrant: createGrant2 } = await import("./grants-service-
|
|
8029
|
-
const db = (await import("./client-
|
|
8547
|
+
const { createGrant: createGrant2 } = await import("./grants-service-4NXPBYWD.js");
|
|
8548
|
+
const db = (await import("./client-OBS5HOTA.js")).getDb();
|
|
8030
8549
|
const { normalizeEmail: normalizeEmail2 } = await import("./email-R7DFS6E5.js");
|
|
8031
8550
|
const e = normalizeEmail2(String(args.email || ""));
|
|
8032
8551
|
if (!e) return "email is required.";
|
|
@@ -8119,7 +8638,7 @@ function createMcpServerForNest(nestId, userId, userEmail, baseUrl) {
|
|
|
8119
8638
|
}
|
|
8120
8639
|
server.tool(tool.name, tool.description, shape, async (args) => {
|
|
8121
8640
|
const engine = await engineCache.get(nestId);
|
|
8122
|
-
const
|
|
8641
|
+
const out = await handleToolCall(tool.name, args, {
|
|
8123
8642
|
storage: engine.storage,
|
|
8124
8643
|
queryEngine: engine.query,
|
|
8125
8644
|
versionManager: engine.versions,
|
|
@@ -8128,7 +8647,7 @@ function createMcpServerForNest(nestId, userId, userEmail, baseUrl) {
|
|
|
8128
8647
|
userEmail,
|
|
8129
8648
|
baseUrl
|
|
8130
8649
|
});
|
|
8131
|
-
return
|
|
8650
|
+
return toCallToolResult(out);
|
|
8132
8651
|
});
|
|
8133
8652
|
}
|
|
8134
8653
|
return server;
|
|
@@ -9487,7 +10006,7 @@ function createServerMcp(userId, userEmail, nestScope, baseUrl) {
|
|
|
9487
10006
|
shape[key] = field;
|
|
9488
10007
|
}
|
|
9489
10008
|
server.tool(tool.name, tool.description, shape, async (args) => {
|
|
9490
|
-
const
|
|
10009
|
+
const out = await handleServerToolCall(
|
|
9491
10010
|
tool.name,
|
|
9492
10011
|
args,
|
|
9493
10012
|
userId,
|
|
@@ -9495,7 +10014,7 @@ function createServerMcp(userId, userEmail, nestScope, baseUrl) {
|
|
|
9495
10014
|
nestScope,
|
|
9496
10015
|
baseUrl
|
|
9497
10016
|
);
|
|
9498
|
-
return
|
|
10017
|
+
return toCallToolResult(out);
|
|
9499
10018
|
});
|
|
9500
10019
|
}
|
|
9501
10020
|
return server;
|
|
@@ -9615,6 +10134,11 @@ Streamable HTTP, stateless. Two endpoints:
|
|
|
9615
10134
|
(new document in a writable nest; a \`folder\` path creates the folder).
|
|
9616
10135
|
- \`${B}/nests/<nest-id>/mcp\` \u2014 per-nest, full toolset including writes and
|
|
9617
10136
|
governance (create/update, submit/approve/reject, stewards, sharing).
|
|
10137
|
+
Sharing a slice is one call: \`context_grant\` with
|
|
10138
|
+
\`{ "email", "target_type": "document" | "folder", "target", "role": "read" | "write" }\`
|
|
10139
|
+
\u2014 \`target\` is a node id (\`nodes/win-loss-log\`) or a folder prefix
|
|
10140
|
+
(\`nodes/gtm/deals\`). The recipient reaches exactly that document, or
|
|
10141
|
+
everything under that folder, and nothing else in the nest. Owner/admin only.
|
|
9618
10142
|
|
|
9619
10143
|
Claude Code / Cursor / VS Code connect natively via HTTP with the
|
|
9620
10144
|
\`Authorization: Bearer\` header. Claude Desktop needs the mcp-remote bridge:
|
|
@@ -9623,20 +10147,9 @@ Claude Code / Cursor / VS Code connect natively via HTTP with the
|
|
|
9623
10147
|
npx -y mcp-remote ${B}/mcp --header "Authorization: Bearer <api-key>"
|
|
9624
10148
|
\`\`\`
|
|
9625
10149
|
|
|
9626
|
-
## ctx CLI (npm: @promptowl/contextnest-cli)
|
|
9627
|
-
|
|
9628
|
-
\`npm i -g @promptowl/contextnest-cli\`
|
|
9629
|
-
|
|
9630
|
-
- \`ctx push --server ${B} --nest <nest-id> --key <api-key>\` \u2014 upload a local
|
|
9631
|
-
vault into a nest (add \`--include-drafts\` for unpublished nodes).
|
|
9632
|
-
- All other ctx commands (\`query\`, \`add\`, \`search\`, \`checkpoint\`) operate on
|
|
9633
|
-
a LOCAL vault directory only. For remote reads use the API or MCP above \u2014
|
|
9634
|
-
there is currently no remote \`ctx query\`.
|
|
9635
|
-
|
|
9636
10150
|
## npm packages
|
|
9637
10151
|
|
|
9638
10152
|
- \`@promptowl/contextnest-community\` \u2014 this server
|
|
9639
|
-
- \`@promptowl/contextnest-cli\` \u2014 the ctx CLI
|
|
9640
10153
|
- \`@promptowl/contextnest-engine\` \u2014 vault storage + selector/graph engine
|
|
9641
10154
|
|
|
9642
10155
|
## Conventions
|
|
@@ -10215,6 +10728,25 @@ function denyUnlessNestManager(c, what) {
|
|
|
10215
10728
|
403
|
|
10216
10729
|
);
|
|
10217
10730
|
}
|
|
10731
|
+
async function mayRequestDeletion(c, nestId, nodeId) {
|
|
10732
|
+
return canRequestDeletion({
|
|
10733
|
+
nestId,
|
|
10734
|
+
userId: c.get("userId"),
|
|
10735
|
+
userEmail: await getUserEmail4(c),
|
|
10736
|
+
nodeId,
|
|
10737
|
+
// The middleware promotes open-mode callers; resolving fresh would miss it.
|
|
10738
|
+
permission: c.get("nestPermission")
|
|
10739
|
+
});
|
|
10740
|
+
}
|
|
10741
|
+
function denyUnlessEditor(allowed, c, what) {
|
|
10742
|
+
if (allowed) return null;
|
|
10743
|
+
return c.json(
|
|
10744
|
+
{
|
|
10745
|
+
error: `Only an editor, a nest admin, or the nest owner can request the deletion of ${what}.`
|
|
10746
|
+
},
|
|
10747
|
+
403
|
|
10748
|
+
);
|
|
10749
|
+
}
|
|
10218
10750
|
governanceRoutes.post("/delete-folder", async (c) => {
|
|
10219
10751
|
const nestId = c.req.param("nestId");
|
|
10220
10752
|
const denied = denyUnlessNestManager(c, "a folder");
|
|
@@ -10232,6 +10764,12 @@ governanceRoutes.post("/delete-folder", async (c) => {
|
|
|
10232
10764
|
});
|
|
10233
10765
|
governanceRoutes.post("/request-deletion", async (c) => {
|
|
10234
10766
|
const nestId = c.req.param("nestId");
|
|
10767
|
+
const denied = denyUnlessEditor(
|
|
10768
|
+
await mayRequestDeletion(c, nestId, ""),
|
|
10769
|
+
c,
|
|
10770
|
+
"a folder or a nest"
|
|
10771
|
+
);
|
|
10772
|
+
if (denied) return denied;
|
|
10235
10773
|
const body = await c.req.json().catch(() => ({}));
|
|
10236
10774
|
const reason = body.reason?.trim();
|
|
10237
10775
|
if (!reason) {
|
|
@@ -10322,6 +10860,16 @@ governanceRoutes.post("/deletion-requests/:id/decline", async (c) => {
|
|
|
10322
10860
|
if (!request) return c.json({ error: "Deletion request not found" }, 404);
|
|
10323
10861
|
return c.json({ request });
|
|
10324
10862
|
});
|
|
10863
|
+
governanceRoutes.post("/deletion-requests/:id/withdraw", async (c) => {
|
|
10864
|
+
const nestId = c.req.param("nestId");
|
|
10865
|
+
const request = await withdrawDeletionRequest({
|
|
10866
|
+
nestId,
|
|
10867
|
+
id: c.req.param("id"),
|
|
10868
|
+
withdrawnBy: await getUserEmail4(c)
|
|
10869
|
+
});
|
|
10870
|
+
if (!request) return c.json({ error: "Deletion request not found or not yours" }, 404);
|
|
10871
|
+
return c.json({ withdrawn: true, request });
|
|
10872
|
+
});
|
|
10325
10873
|
governanceRoutes.get("/feed", async (c) => {
|
|
10326
10874
|
const nestId = c.req.param("nestId");
|
|
10327
10875
|
const filterRaw = c.req.query("filter");
|
|
@@ -10424,7 +10972,14 @@ governanceNodeRoutes.get("/:nodeId{.+}/versions", async (c) => {
|
|
|
10424
10972
|
return c.json({
|
|
10425
10973
|
versions: enriched,
|
|
10426
10974
|
approvedVersion: approved,
|
|
10427
|
-
currentVersion: allVersions[0]?.version || 0
|
|
10975
|
+
currentVersion: allVersions[0]?.version || 0,
|
|
10976
|
+
// Verdicts on out-of-band edits. A rejection commits no content, so it has
|
|
10977
|
+
// no version row to hang off — it lives in the engine's chain-event log and
|
|
10978
|
+
// rides alongside the version list rather than inside it.
|
|
10979
|
+
externalEditVerdicts: await listExternalEditVerdicts(
|
|
10980
|
+
nestId,
|
|
10981
|
+
nodeId
|
|
10982
|
+
)
|
|
10428
10983
|
});
|
|
10429
10984
|
});
|
|
10430
10985
|
governanceNodeRoutes.get("/:nodeId{.+}/versions/:version{[0-9]+}", async (c) => {
|
|
@@ -10572,6 +11127,12 @@ governanceNodeRoutes.post("/:nodeId{.+}/request-deletion", async (c) => {
|
|
|
10572
11127
|
if (!await canReadNode(nestId, nodeId, c.get("userId"), userEmail)) {
|
|
10573
11128
|
return c.json({ error: `Node not found: ${nodeId}` }, 404);
|
|
10574
11129
|
}
|
|
11130
|
+
const denied = denyUnlessEditor(
|
|
11131
|
+
await mayRequestDeletion(c, nestId, nodeId),
|
|
11132
|
+
c,
|
|
11133
|
+
"a document"
|
|
11134
|
+
);
|
|
11135
|
+
if (denied) return denied;
|
|
10575
11136
|
const { storage } = await engineCache.get(nestId);
|
|
10576
11137
|
try {
|
|
10577
11138
|
await storage.readDocument(nodeId);
|
|
@@ -10606,7 +11167,9 @@ governanceNodeRoutes.post(
|
|
|
10606
11167
|
return c.json({
|
|
10607
11168
|
approved: true,
|
|
10608
11169
|
version: result.versionEntry.version,
|
|
10609
|
-
chainEvent: result.chainEvent.event_id
|
|
11170
|
+
chainEvent: result.chainEvent.event_id,
|
|
11171
|
+
// As on reject: false means the verdict never reached the audit log.
|
|
11172
|
+
historyRecorded: result.historyRecorded
|
|
10610
11173
|
});
|
|
10611
11174
|
} catch (err) {
|
|
10612
11175
|
console.error(
|
|
@@ -10641,7 +11204,10 @@ governanceNodeRoutes.post(
|
|
|
10641
11204
|
});
|
|
10642
11205
|
return c.json({
|
|
10643
11206
|
rejected: true,
|
|
10644
|
-
chainEvent: result.chainEvent.event_id
|
|
11207
|
+
chainEvent: result.chainEvent.event_id,
|
|
11208
|
+
// False means the edit was rejected but the audit entry did not land —
|
|
11209
|
+
// the caller is not shown a clean success (server log carries why).
|
|
11210
|
+
historyRecorded: result.historyRecorded
|
|
10645
11211
|
});
|
|
10646
11212
|
} catch (err) {
|
|
10647
11213
|
return c.json({ error: err.message }, 400);
|
|
@@ -10886,6 +11452,13 @@ function createApp() {
|
|
|
10886
11452
|
"*",
|
|
10887
11453
|
corsOrigins === "*" ? cors({ origin: "*" }) : cors({ origin: corsOrigins, credentials: true })
|
|
10888
11454
|
);
|
|
11455
|
+
const frameAncestors = `frame-ancestors ${config.FRAME_ANCESTORS}`;
|
|
11456
|
+
app.use("*", async (c, next) => {
|
|
11457
|
+
await next();
|
|
11458
|
+
const existing = c.res.headers.get("Content-Security-Policy");
|
|
11459
|
+
const merged = mergeFrameAncestors(existing, frameAncestors);
|
|
11460
|
+
if (merged !== existing) c.header("Content-Security-Policy", merged);
|
|
11461
|
+
});
|
|
10889
11462
|
const ASSET_UPLOAD_PATH = /^\/nests\/[^/]+\/assets\/?$/;
|
|
10890
11463
|
app.use("*", async (c, next) => {
|
|
10891
11464
|
const lenStr = c.req.header("Content-Length");
|
|
@@ -11666,6 +12239,38 @@ function createApp() {
|
|
|
11666
12239
|
users: usersRow.c
|
|
11667
12240
|
});
|
|
11668
12241
|
});
|
|
12242
|
+
app.use("/search", flexAuthMiddleware);
|
|
12243
|
+
app.get("/search", async (c) => {
|
|
12244
|
+
const q = (c.req.query("q") || "").trim();
|
|
12245
|
+
if (!q) throw new ValidationError("q query parameter is required");
|
|
12246
|
+
const limit = Math.min(Math.max(Number(c.req.query("limit")) || 20, 1), 50);
|
|
12247
|
+
const userId = c.get("userId");
|
|
12248
|
+
const userEmail = await resolveCallerEmail(userId);
|
|
12249
|
+
const perNest = await Promise.all(
|
|
12250
|
+
(await listVisibleNests(userId, { readable: true })).map(async (nest) => {
|
|
12251
|
+
try {
|
|
12252
|
+
const { storage } = await engineCache.get(nest.id);
|
|
12253
|
+
const matches = matchDocuments(await discoverForSearch(storage), q);
|
|
12254
|
+
if (matches.length === 0) return [];
|
|
12255
|
+
const accessible = await filterAccessible(nest.id, userId, userEmail, matches);
|
|
12256
|
+
return accessible.map((n) => ({
|
|
12257
|
+
...toSearchHit(n, q),
|
|
12258
|
+
nest_id: nest.id,
|
|
12259
|
+
nest_name: nest.name ?? nest.id
|
|
12260
|
+
}));
|
|
12261
|
+
} catch (e) {
|
|
12262
|
+
console.error("[search] skipped nest", nest.id, e);
|
|
12263
|
+
return [];
|
|
12264
|
+
}
|
|
12265
|
+
})
|
|
12266
|
+
);
|
|
12267
|
+
const hits = perNest.flat().sort((a, b) => titleScore(b.title, q) - titleScore(a.title, q));
|
|
12268
|
+
return c.json({
|
|
12269
|
+
query: q,
|
|
12270
|
+
count: hits.length,
|
|
12271
|
+
nodes: hits.slice(0, limit)
|
|
12272
|
+
});
|
|
12273
|
+
});
|
|
11669
12274
|
app.use("/runs/*", flexAuthMiddleware);
|
|
11670
12275
|
app.route("/runs", runDetailRoutes);
|
|
11671
12276
|
app.route("/hooks", hookFireRoutes);
|
|
@@ -11735,7 +12340,8 @@ function createApp() {
|
|
|
11735
12340
|
c.set("nestPermission", "owner");
|
|
11736
12341
|
return next();
|
|
11737
12342
|
}
|
|
11738
|
-
const
|
|
12343
|
+
const access = await resolveNestAccess(nestId, userId);
|
|
12344
|
+
const permission = access.permission;
|
|
11739
12345
|
const isReadQuery = parts.length === 2 && ["context", "query", "search", "export"].includes(parts[1]);
|
|
11740
12346
|
let grantRole = null;
|
|
11741
12347
|
let grantNestVisible = false;
|
|
@@ -11748,11 +12354,13 @@ function createApp() {
|
|
|
11748
12354
|
}
|
|
11749
12355
|
grantRole = await resolveNodeGrant(nestId, userId, targetTail);
|
|
11750
12356
|
} else if (parts.length === 1 && c.req.method === "GET") {
|
|
11751
|
-
grantNestVisible =
|
|
12357
|
+
grantNestVisible = access.hasGrant;
|
|
11752
12358
|
} else if (parts[1] === "nodes" && parts.length === 2 && c.req.method === "GET") {
|
|
11753
|
-
grantNestVisible =
|
|
12359
|
+
grantNestVisible = access.hasGrant;
|
|
11754
12360
|
} else if (isReadQuery) {
|
|
11755
|
-
grantNestVisible =
|
|
12361
|
+
grantNestVisible = access.hasGrant;
|
|
12362
|
+
} else if (parts[1] === "definitions" && parts.length === 2 && c.req.method === "GET") {
|
|
12363
|
+
grantNestVisible = access.hasGrant;
|
|
11756
12364
|
}
|
|
11757
12365
|
if (!grantRole && !grantNestVisible) {
|
|
11758
12366
|
return deny(404, "Nest not found", {
|
|
@@ -11765,7 +12373,7 @@ function createApp() {
|
|
|
11765
12373
|
const isStewardActionPath = path.includes("/approve") || path.includes("/reject") || path.includes("/submit-review") || path.includes("/cancel-review");
|
|
11766
12374
|
const isAnnotationAction = /\/annotations$/.test(path) || /\/annotations\/[^/]+\/(comments|resolve|reopen)$/.test(path);
|
|
11767
12375
|
const isCommentAction = /\/comments$/.test(path) || /\/comments\/[^/]+\/resolve$/.test(path);
|
|
11768
|
-
const isDeletionRequestAction = /\/request-deletion$/.test(path);
|
|
12376
|
+
const isDeletionRequestAction = /\/request-deletion$/.test(path) || /\/deletion-requests\/[^/]+\/withdraw$/.test(path);
|
|
11769
12377
|
const isStewardRoster = path.includes("/stewards") && !path.includes("/nodes/");
|
|
11770
12378
|
if (isStewardRoster && permission !== "owner" && permission !== "admin") {
|
|
11771
12379
|
return c.json(
|
|
@@ -11877,6 +12485,7 @@ function createApp() {
|
|
|
11877
12485
|
nestsApp.route("/:nestId/assets", assetRoutes);
|
|
11878
12486
|
nestsApp.route("/:nestId/mcp", mcpRoutes);
|
|
11879
12487
|
app.route("/nests", nestsApp);
|
|
12488
|
+
app.all("/.well-known/*", (c) => c.json({ error: "Not found" }, 404));
|
|
11880
12489
|
app.use(
|
|
11881
12490
|
"/assets/*",
|
|
11882
12491
|
serveStatic({
|
|
@@ -11884,14 +12493,20 @@ function createApp() {
|
|
|
11884
12493
|
onFound: (_p, c) => c.header("Cache-Control", "public, max-age=31536000, immutable")
|
|
11885
12494
|
})
|
|
11886
12495
|
);
|
|
12496
|
+
const isDocumentNavigation = (c) => c.req.header("Sec-Fetch-Mode") === "navigate" || (c.req.header("Accept") || "").includes("text/html");
|
|
11887
12497
|
app.get(
|
|
11888
12498
|
"*",
|
|
12499
|
+
async (c, next) => {
|
|
12500
|
+
if (isDocumentNavigation(c)) return next();
|
|
12501
|
+
return c.json({ error: "Not found" }, 404);
|
|
12502
|
+
},
|
|
11889
12503
|
serveStatic({
|
|
11890
12504
|
root: UI_DIR_REL,
|
|
11891
12505
|
path: "index.html",
|
|
11892
12506
|
onFound: (_p, c) => c.header("Cache-Control", "no-cache")
|
|
11893
12507
|
})
|
|
11894
12508
|
);
|
|
12509
|
+
app.notFound((c) => c.json({ error: "Not found" }, 404));
|
|
11895
12510
|
app.onError((err, c) => {
|
|
11896
12511
|
if (err instanceof AppError) {
|
|
11897
12512
|
return c.json({ error: err.message }, err.statusCode);
|
|
@@ -12327,6 +12942,7 @@ async function main() {
|
|
|
12327
12942
|
POST /nests/:id/query Selector query
|
|
12328
12943
|
POST /nests/:id/context One-call context retrieval
|
|
12329
12944
|
GET /nests/:id/search?q= Full-text search
|
|
12945
|
+
GET /search?q= Search every nest you can see
|
|
12330
12946
|
POST /nests/:id/publish Bulk publish
|
|
12331
12947
|
POST /nests/:id/mcp MCP endpoint
|
|
12332
12948
|
GET /health Health check
|