@promptowl/contextnest-community 1.6.0 → 1.8.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 +1 -0
- package/dist/{chunk-2TUMMVBG.js → chunk-PJTAUVD7.js} +15 -5
- package/dist/{chunk-7V33Z6CS.js → chunk-PLAHCDQG.js} +184 -1
- package/dist/{chunk-T5L4LYU4.js → chunk-XNGOO6RI.js} +1 -1
- package/dist/{chunk-MZGFKBOK.js → chunk-Z3Y46ZGU.js} +62 -3
- package/dist/index.js +1523 -522
- package/dist/{migrations.postgres-NVJAGBSF.js → migrations.postgres-VCG45LMK.js} +41 -1
- package/dist/{review-service-QJ3FBOML.js → review-service-G5US3SMG.js} +4 -4
- package/dist/{stewardship-service-VIKF3VZB.js → stewardship-service-ZXAMFTXO.js} +2 -2
- package/dist/{version-service-MUZYTYMW.js → version-service-B3SDOJQE.js} +2 -2
- package/dist/web3/assets/index-Den-5nzY.js +904 -0
- package/dist/web3/assets/index-Jfj-2MHm.css +1 -0
- package/dist/web3/index.html +2 -2
- package/package.json +2 -2
- package/dist/web3/assets/index-C2Tf2ZFz.js +0 -887
- package/dist/web3/assets/index-C2dfT3Et.css +0 -1
package/dist/index.js
CHANGED
|
@@ -13,10 +13,11 @@ import {
|
|
|
13
13
|
getPendingReview,
|
|
14
14
|
getReviewHistory,
|
|
15
15
|
getReviewQueue,
|
|
16
|
+
notifySlackForNest,
|
|
16
17
|
reject,
|
|
17
18
|
safePublishDocument,
|
|
18
19
|
submitForReview
|
|
19
|
-
} from "./chunk-
|
|
20
|
+
} from "./chunk-Z3Y46ZGU.js";
|
|
20
21
|
import {
|
|
21
22
|
checkConflict,
|
|
22
23
|
createVersion,
|
|
@@ -25,7 +26,7 @@ import {
|
|
|
25
26
|
getDisplayStatus,
|
|
26
27
|
getVersions,
|
|
27
28
|
setApprovedVersion
|
|
28
|
-
} from "./chunk-
|
|
29
|
+
} from "./chunk-XNGOO6RI.js";
|
|
29
30
|
import {
|
|
30
31
|
AppError,
|
|
31
32
|
ConflictError,
|
|
@@ -35,10 +36,10 @@ import {
|
|
|
35
36
|
ValidationError,
|
|
36
37
|
canCreateInNest,
|
|
37
38
|
canManageStewards,
|
|
38
|
-
canManageWith,
|
|
39
39
|
canUserAccess,
|
|
40
40
|
canUserApprove,
|
|
41
41
|
canUserEdit,
|
|
42
|
+
collabPermToRole,
|
|
42
43
|
createNest,
|
|
43
44
|
createStewardRecord,
|
|
44
45
|
deleteNest,
|
|
@@ -84,14 +85,14 @@ import {
|
|
|
84
85
|
updateSteward,
|
|
85
86
|
upsertEnvVar,
|
|
86
87
|
validateLicense
|
|
87
|
-
} from "./chunk-
|
|
88
|
+
} from "./chunk-PJTAUVD7.js";
|
|
88
89
|
import {
|
|
89
90
|
config,
|
|
90
91
|
getDb,
|
|
91
92
|
initDb,
|
|
92
93
|
insertOrIgnore,
|
|
93
94
|
nowExpr
|
|
94
|
-
} from "./chunk-
|
|
95
|
+
} from "./chunk-PLAHCDQG.js";
|
|
95
96
|
import {
|
|
96
97
|
ANON_EMAIL,
|
|
97
98
|
ANON_USER_ID
|
|
@@ -101,7 +102,8 @@ import {
|
|
|
101
102
|
import { serve } from "@hono/node-server";
|
|
102
103
|
|
|
103
104
|
// src/app.ts
|
|
104
|
-
import { Hono as
|
|
105
|
+
import { Hono as Hono10 } from "hono";
|
|
106
|
+
import { LinearRouter } from "hono/router/linear-router";
|
|
105
107
|
import { createMiddleware as createMiddleware2 } from "hono/factory";
|
|
106
108
|
import { cors } from "hono/cors";
|
|
107
109
|
|
|
@@ -487,7 +489,7 @@ authRoutes.post("/login", async (c) => {
|
|
|
487
489
|
throw new ValidationError("email and password are required");
|
|
488
490
|
}
|
|
489
491
|
const ip = clientIp(c);
|
|
490
|
-
const emailLower = body.email
|
|
492
|
+
const emailLower = normalizeEmail(body.email);
|
|
491
493
|
const hasIp = ip !== "unknown";
|
|
492
494
|
const ipKey = `login:ip:${ip}`;
|
|
493
495
|
const emailKey = `login:email:${emailLower}`;
|
|
@@ -496,7 +498,7 @@ authRoutes.post("/login", async (c) => {
|
|
|
496
498
|
}
|
|
497
499
|
const db = getDb();
|
|
498
500
|
const user = await db.get(
|
|
499
|
-
"SELECT id, email, name, password_hash, is_admin FROM users WHERE LOWER(email) = ?",
|
|
501
|
+
"SELECT id, email, name, password_hash, is_admin, is_invited FROM users WHERE LOWER(email) = ?",
|
|
500
502
|
[emailLower]
|
|
501
503
|
);
|
|
502
504
|
const check = user ? await verifyPassword(body.password, user.password_hash) : { ok: false, needsRehash: false };
|
|
@@ -509,6 +511,12 @@ authRoutes.post("/login", async (c) => {
|
|
|
509
511
|
console.log(`[auth] login OK \u2014 counter reset ip=${ip} email=${emailLower}`);
|
|
510
512
|
if (hasIp) clear(ipKey);
|
|
511
513
|
clear(emailKey);
|
|
514
|
+
if (user.is_invited === 1) {
|
|
515
|
+
try {
|
|
516
|
+
await db.run("UPDATE users SET is_invited = 0 WHERE id = ?", [user.id]);
|
|
517
|
+
} catch {
|
|
518
|
+
}
|
|
519
|
+
}
|
|
512
520
|
if (check.needsRehash) {
|
|
513
521
|
try {
|
|
514
522
|
const newHash = await hashPassword(body.password);
|
|
@@ -810,7 +818,7 @@ authRoutes.post("/password", authMiddleware, async (c) => {
|
|
|
810
818
|
);
|
|
811
819
|
}
|
|
812
820
|
const newHash = await hashPassword(body.next);
|
|
813
|
-
await db.run("UPDATE users SET password_hash =
|
|
821
|
+
await db.run("UPDATE users SET password_hash = ?, is_invited = 0 WHERE id = ?", [
|
|
814
822
|
newHash,
|
|
815
823
|
userId
|
|
816
824
|
]);
|
|
@@ -853,14 +861,13 @@ authRoutes.post("/admin/reset-password/:userId", async (c) => {
|
|
|
853
861
|
newHash,
|
|
854
862
|
target.id
|
|
855
863
|
]);
|
|
856
|
-
await tx.run("DELETE FROM api_keys WHERE user_id = ?", [target.id]);
|
|
857
864
|
});
|
|
858
865
|
await deleteAllSessionsForUser(target.id);
|
|
859
866
|
trackEvent("admin.reset_password", { adminId: callerId, userId: target.id });
|
|
860
867
|
return c.json({
|
|
861
868
|
ok: true,
|
|
862
869
|
email: target.email,
|
|
863
|
-
keys_revoked:
|
|
870
|
+
keys_revoked: false,
|
|
864
871
|
// Plaintext returned ONCE, only when the server generated it.
|
|
865
872
|
temporary_password: generated ?? void 0
|
|
866
873
|
});
|
|
@@ -936,40 +943,40 @@ authRoutes.post("/invite", async (c) => {
|
|
|
936
943
|
403
|
|
937
944
|
);
|
|
938
945
|
}
|
|
939
|
-
|
|
940
|
-
"SELECT id, email FROM users WHERE LOWER(email) = ?",
|
|
946
|
+
const existing = await db.get(
|
|
947
|
+
"SELECT id, email, is_invited FROM users WHERE LOWER(email) = ?",
|
|
941
948
|
[email]
|
|
942
949
|
);
|
|
943
|
-
if (
|
|
944
|
-
|
|
945
|
-
|
|
950
|
+
if (existing && existing.is_invited === 0) {
|
|
951
|
+
return c.json(
|
|
952
|
+
{
|
|
953
|
+
error: "A user with this email already exists. Use Reset password to issue them a new password."
|
|
954
|
+
},
|
|
955
|
+
409
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
const tempPassword = uuid().replace(/-/g, "").slice(0, 16);
|
|
959
|
+
const passwordHash = await hashPassword(tempPassword);
|
|
960
|
+
let userId;
|
|
961
|
+
if (existing) {
|
|
962
|
+
userId = existing.id;
|
|
963
|
+
await db.run("UPDATE users SET password_hash = ? WHERE id = ?", [
|
|
964
|
+
passwordHash,
|
|
965
|
+
userId
|
|
966
|
+
]);
|
|
967
|
+
} else {
|
|
968
|
+
userId = uuid();
|
|
946
969
|
await db.run(
|
|
947
970
|
"INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
|
|
948
|
-
[userId, email, null,
|
|
971
|
+
[userId, email, null, passwordHash]
|
|
949
972
|
);
|
|
950
|
-
user = { id: userId, email };
|
|
951
973
|
}
|
|
952
|
-
|
|
953
|
-
const keyId = uuid();
|
|
954
|
-
await db.transaction(async (tx) => {
|
|
955
|
-
await tx.run("DELETE FROM api_keys WHERE user_id = ?", [user.id]);
|
|
956
|
-
await tx.run(
|
|
957
|
-
"INSERT INTO api_keys (id, user_id, key_hash, key_prefix, label) VALUES (?, ?, ?, ?, ?)",
|
|
958
|
-
[
|
|
959
|
-
keyId,
|
|
960
|
-
user.id,
|
|
961
|
-
hashApiKey(apiKey),
|
|
962
|
-
getKeyPrefix(apiKey),
|
|
963
|
-
body.label || "teammate"
|
|
964
|
-
]
|
|
965
|
-
);
|
|
966
|
-
});
|
|
967
|
-
trackEvent("admin.invite", { adminId: callerId, email: body.email });
|
|
974
|
+
trackEvent("admin.invite", { adminId: callerId, email });
|
|
968
975
|
return c.json(
|
|
969
976
|
{
|
|
970
|
-
|
|
971
|
-
user: { id:
|
|
972
|
-
message: "
|
|
977
|
+
temporary_password: tempPassword,
|
|
978
|
+
user: { id: userId, email },
|
|
979
|
+
message: "Share this temporary password securely. Your teammate signs in with their email + this password, then can change it and create their own API key."
|
|
973
980
|
},
|
|
974
981
|
201
|
|
975
982
|
);
|
|
@@ -1284,7 +1291,7 @@ async function approveExternalEdit(input) {
|
|
|
1284
1291
|
const node = await storage.readDocument(input.documentId);
|
|
1285
1292
|
const versionNum = result.versionEntry.version;
|
|
1286
1293
|
const tags = node.frontmatter.tags || [];
|
|
1287
|
-
const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-
|
|
1294
|
+
const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-B3SDOJQE.js");
|
|
1288
1295
|
await createVersion2({
|
|
1289
1296
|
nestId: input.nestId,
|
|
1290
1297
|
nodeId: input.documentId,
|
|
@@ -1370,6 +1377,10 @@ function bodyOnly(nodeId, raw) {
|
|
|
1370
1377
|
return raw;
|
|
1371
1378
|
}
|
|
1372
1379
|
}
|
|
1380
|
+
var RUNNABLE_NODE_TYPES = ["agent", "skill"];
|
|
1381
|
+
function isRunnableType(type) {
|
|
1382
|
+
return RUNNABLE_NODE_TYPES.includes(type || "");
|
|
1383
|
+
}
|
|
1373
1384
|
function toNodeResponse(node) {
|
|
1374
1385
|
const fm = node.frontmatter;
|
|
1375
1386
|
const title = fm.title === void 0 || fm.title === null ? "" : String(fm.title);
|
|
@@ -1386,14 +1397,20 @@ function toNodeResponse(node) {
|
|
|
1386
1397
|
created_at: fm.created_at,
|
|
1387
1398
|
updated_at: fm.updated_at,
|
|
1388
1399
|
content: node.body || "",
|
|
1389
|
-
pendingChange: node.pendingChange ?? void 0
|
|
1400
|
+
pendingChange: node.pendingChange ?? void 0,
|
|
1401
|
+
// Same YAML-coercion rule as title above: `schedule: 123` parses as a
|
|
1402
|
+
// number — normalize to string at the API boundary.
|
|
1403
|
+
schedule: fm.metadata?.schedule != null ? String(fm.metadata.schedule) : void 0
|
|
1390
1404
|
};
|
|
1391
1405
|
}
|
|
1392
1406
|
async function listNodesForCaller(nestId, userId, filters = {}) {
|
|
1393
1407
|
const { storage, versions: versionManager } = await engineCache.get(nestId);
|
|
1394
1408
|
let documents = await storage.discoverDocuments();
|
|
1395
1409
|
if (filters.type) {
|
|
1396
|
-
|
|
1410
|
+
const wanted = new Set(
|
|
1411
|
+
Array.isArray(filters.type) ? filters.type : [filters.type]
|
|
1412
|
+
);
|
|
1413
|
+
documents = documents.filter((n) => wanted.has(n.frontmatter.type));
|
|
1397
1414
|
}
|
|
1398
1415
|
if (filters.tag) {
|
|
1399
1416
|
const tag = normalizeTag2(filters.tag);
|
|
@@ -1444,10 +1461,26 @@ async function listNodesForCallerByEmail(nestId, userEmail, filters = {}) {
|
|
|
1444
1461
|
}
|
|
1445
1462
|
async function createNode(nestId, input, userEmail) {
|
|
1446
1463
|
const { storage, versions: versionManager } = await engineCache.get(nestId);
|
|
1447
|
-
const
|
|
1448
|
-
const
|
|
1464
|
+
const slugify = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1465
|
+
const slug = slugify(input.title);
|
|
1466
|
+
let id = input.id;
|
|
1467
|
+
if (!id) {
|
|
1468
|
+
const folderSegments = (input.folder ?? "").split("/").map(slugify).filter(Boolean);
|
|
1469
|
+
if (folderSegments.length > 8) {
|
|
1470
|
+
throw new ValidationError("folder may nest at most 8 levels deep");
|
|
1471
|
+
}
|
|
1472
|
+
if ([...folderSegments, slug].some((seg) => seg.length > 100)) {
|
|
1473
|
+
throw new ValidationError("each folder or title segment must be at most 100 characters");
|
|
1474
|
+
}
|
|
1475
|
+
id = folderSegments.length > 0 ? `nodes/${folderSegments.join("/")}/${slug}` : `nodes/${slug}`;
|
|
1476
|
+
}
|
|
1449
1477
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1450
1478
|
const tags = (input.tags || []).map(normalizeTag2);
|
|
1479
|
+
if (input.schedule !== void 0 && !isRunnableType(input.type)) {
|
|
1480
|
+
throw new ValidationError(
|
|
1481
|
+
`schedule is only valid for runnable node types (${RUNNABLE_NODE_TYPES.join(", ")})`
|
|
1482
|
+
);
|
|
1483
|
+
}
|
|
1451
1484
|
const hasStewards = await isStewardshipEnabled(nestId);
|
|
1452
1485
|
const initialStatus = hasStewards ? "draft" : "published";
|
|
1453
1486
|
const initialVersion = hasStewards ? 1 : 0;
|
|
@@ -1462,7 +1495,11 @@ async function createNode(nestId, input, userEmail) {
|
|
|
1462
1495
|
version: initialVersion,
|
|
1463
1496
|
created_at: now,
|
|
1464
1497
|
updated_at: now,
|
|
1465
|
-
metadata: {
|
|
1498
|
+
metadata: {
|
|
1499
|
+
owners: ["*"],
|
|
1500
|
+
scope: input.scope || "team",
|
|
1501
|
+
...input.schedule ? { schedule: input.schedule } : {}
|
|
1502
|
+
}
|
|
1466
1503
|
},
|
|
1467
1504
|
body: input.content,
|
|
1468
1505
|
rawContent: ""
|
|
@@ -1589,6 +1626,17 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
1589
1626
|
if (patch.title) {
|
|
1590
1627
|
node = { ...node, frontmatter: { ...node.frontmatter, title: patch.title } };
|
|
1591
1628
|
}
|
|
1629
|
+
if (patch.schedule !== void 0) {
|
|
1630
|
+
if (!isRunnableType(node.frontmatter.type)) {
|
|
1631
|
+
throw new ValidationError(
|
|
1632
|
+
`schedule is only valid for runnable node types (${RUNNABLE_NODE_TYPES.join(", ")})`
|
|
1633
|
+
);
|
|
1634
|
+
}
|
|
1635
|
+
const metadata = { ...node.frontmatter.metadata };
|
|
1636
|
+
if (patch.schedule === "") delete metadata.schedule;
|
|
1637
|
+
else metadata.schedule = patch.schedule;
|
|
1638
|
+
node = { ...node, frontmatter: { ...node.frontmatter, metadata } };
|
|
1639
|
+
}
|
|
1592
1640
|
const hasStewards = await isStewardshipEnabled(nestId);
|
|
1593
1641
|
const currentTags = node.frontmatter.tags || [];
|
|
1594
1642
|
if (hasStewards && await getPendingReview(nestId, nodeId)) {
|
|
@@ -1881,7 +1929,18 @@ nestRoutes.get("/", async (c) => {
|
|
|
1881
1929
|
const permission = await effectivePermission(n.id, userId);
|
|
1882
1930
|
const is_owner = permission === "owner";
|
|
1883
1931
|
let owner_email = null;
|
|
1884
|
-
|
|
1932
|
+
let roles;
|
|
1933
|
+
if (is_owner) {
|
|
1934
|
+
roles = ["owner"];
|
|
1935
|
+
} else {
|
|
1936
|
+
const grantRole = collabPermToRole(
|
|
1937
|
+
await getCollaboratorRole(n.id, callerEmail)
|
|
1938
|
+
);
|
|
1939
|
+
const stewardRoles = await getStewardRolesForUser(n.id, callerEmail);
|
|
1940
|
+
roles = [
|
|
1941
|
+
...new Set([grantRole, ...stewardRoles].filter(Boolean))
|
|
1942
|
+
];
|
|
1943
|
+
}
|
|
1885
1944
|
if (!is_owner && n.user_id !== ANON_USER_ID) {
|
|
1886
1945
|
const row = await db.get(ownerEmailSql, [n.user_id]);
|
|
1887
1946
|
owner_email = row?.email ?? null;
|
|
@@ -1895,7 +1954,25 @@ nestRoutes.get("/", async (c) => {
|
|
|
1895
1954
|
seen.add(n.id);
|
|
1896
1955
|
out.push(await annotate(n));
|
|
1897
1956
|
}
|
|
1898
|
-
|
|
1957
|
+
const docRows = await db.all(
|
|
1958
|
+
"SELECT nest_id, COUNT(DISTINCT node_id) AS c FROM node_versions GROUP BY nest_id"
|
|
1959
|
+
);
|
|
1960
|
+
const docByNest = new Map(docRows.map((r) => [r.nest_id, r.c]));
|
|
1961
|
+
const collabRows = await db.all(
|
|
1962
|
+
"SELECT nest_id, COUNT(*) AS c FROM nest_collaborators GROUP BY nest_id"
|
|
1963
|
+
);
|
|
1964
|
+
const collabByNest = new Map(collabRows.map((r) => [r.nest_id, r.c]));
|
|
1965
|
+
const stewardRows = await db.all(
|
|
1966
|
+
"SELECT nest_id, COUNT(DISTINCT user_email) AS c FROM stewards WHERE is_active = 1 GROUP BY nest_id"
|
|
1967
|
+
);
|
|
1968
|
+
const stewardByNest = new Map(stewardRows.map((r) => [r.nest_id, r.c]));
|
|
1969
|
+
const withMeta = out.map((n) => ({
|
|
1970
|
+
...n,
|
|
1971
|
+
document_count: docByNest.get(n.id) ?? 0,
|
|
1972
|
+
collaborator_count: collabByNest.get(n.id) ?? 0,
|
|
1973
|
+
steward_count: stewardByNest.get(n.id) ?? 0
|
|
1974
|
+
}));
|
|
1975
|
+
return c.json({ nests: withMeta });
|
|
1899
1976
|
});
|
|
1900
1977
|
nestRoutes.post("/", async (c) => {
|
|
1901
1978
|
const body = await c.req.json();
|
|
@@ -2011,10 +2088,10 @@ nestRoutes.patch("/:nestId/settings", async (c) => {
|
|
|
2011
2088
|
const userId = c.get("userId");
|
|
2012
2089
|
const isServerAdmin = await isLicenseAdminUserId(userId);
|
|
2013
2090
|
const permission = await effectivePermission(nestId, userId);
|
|
2014
|
-
if (!isServerAdmin && permission !== "owner") {
|
|
2091
|
+
if (!isServerAdmin && permission !== "owner" && permission !== "admin") {
|
|
2015
2092
|
return c.json(
|
|
2016
2093
|
{
|
|
2017
|
-
error: "Only the nest owner or the server license-admin can update nest settings."
|
|
2094
|
+
error: "Only a nest admin, the nest owner, or the server license-admin can update nest settings."
|
|
2018
2095
|
},
|
|
2019
2096
|
403
|
|
2020
2097
|
);
|
|
@@ -2025,6 +2102,14 @@ nestRoutes.patch("/:nestId/settings", async (c) => {
|
|
|
2025
2102
|
if (body.stewardship_enabled) {
|
|
2026
2103
|
await setStewardshipEnabled(nestId, true);
|
|
2027
2104
|
} else {
|
|
2105
|
+
if (!isServerAdmin && permission !== "owner") {
|
|
2106
|
+
return c.json(
|
|
2107
|
+
{
|
|
2108
|
+
error: "Only the nest owner or the server license-admin can disable stewardship (this permanently wipes stewards and pending reviews)."
|
|
2109
|
+
},
|
|
2110
|
+
403
|
|
2111
|
+
);
|
|
2112
|
+
}
|
|
2028
2113
|
wiped = await disableStewardshipAndWipeGovernance(nestId);
|
|
2029
2114
|
}
|
|
2030
2115
|
}
|
|
@@ -2112,6 +2197,10 @@ async function addCollaborator(params) {
|
|
|
2112
2197
|
"INSERT INTO nest_collaborators (id, nest_id, user_id, permission, granted_by) VALUES (?, ?, ?, ?, ?)",
|
|
2113
2198
|
[collabId, nestId, userId, params.permission, granterId]
|
|
2114
2199
|
);
|
|
2200
|
+
void notifySlackForNest(
|
|
2201
|
+
nestId,
|
|
2202
|
+
`:key: ${params.email || userId} added as *${params.permission}*${params.grantedByEmail ? ` by ${params.grantedByEmail}` : ""}`
|
|
2203
|
+
);
|
|
2115
2204
|
return await db.get(
|
|
2116
2205
|
"SELECT * FROM nest_collaborators WHERE id = ?",
|
|
2117
2206
|
[collabId]
|
|
@@ -2220,416 +2309,113 @@ function isMarkdownFormat(c) {
|
|
|
2220
2309
|
return c.req.query("format") === "markdown";
|
|
2221
2310
|
}
|
|
2222
2311
|
|
|
2223
|
-
// src/
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2312
|
+
// src/annotations/service.ts
|
|
2313
|
+
import { v4 as uuid3 } from "uuid";
|
|
2314
|
+
|
|
2315
|
+
// src/annotations/projection.ts
|
|
2316
|
+
var MAX_CONTEXT_CHARS = 250;
|
|
2317
|
+
var MAX_QUOTE_CHARS = 1e3;
|
|
2318
|
+
function clampAnchor(raw) {
|
|
2319
|
+
if (!raw) return null;
|
|
2320
|
+
const quote = (raw.quote ?? "").trim().slice(0, MAX_QUOTE_CHARS);
|
|
2321
|
+
if (!quote) return null;
|
|
2322
|
+
const before = (raw.before ?? "").slice(-MAX_CONTEXT_CHARS);
|
|
2323
|
+
const after = (raw.after ?? "").slice(0, MAX_CONTEXT_CHARS);
|
|
2324
|
+
const line = typeof raw.line === "number" && Number.isFinite(raw.line) && raw.line > 0 ? Math.floor(raw.line) : void 0;
|
|
2325
|
+
return { quote, before, after, ...line !== void 0 ? { line } : {} };
|
|
2233
2326
|
}
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
nodeRoutes.post("/", async (c) => {
|
|
2241
|
-
const body = await c.req.json();
|
|
2242
|
-
if (!body.title || !body.content) {
|
|
2243
|
-
throw new ValidationError("title and content are required");
|
|
2327
|
+
function oneLine(s) {
|
|
2328
|
+
return s.replace(/\s+/g, " ").replace(/--+>/g, "-\u2192").trim();
|
|
2329
|
+
}
|
|
2330
|
+
function renderAnchor(anchor) {
|
|
2331
|
+
if (!anchor) {
|
|
2332
|
+
return "<!-- anchor: whole-artifact -->";
|
|
2244
2333
|
}
|
|
2245
|
-
const
|
|
2246
|
-
const
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
}
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
}
|
|
2268
|
-
}
|
|
2269
|
-
|
|
2270
|
-
const
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
nodeId
|
|
2334
|
+
const linePart = anchor.line !== void 0 ? `line ${anchor.line} \xB7 ` : "";
|
|
2335
|
+
const context = `\u2026${oneLine(anchor.before)}\u3008${oneLine(anchor.quote)}\u3009${oneLine(
|
|
2336
|
+
anchor.after
|
|
2337
|
+
)}\u2026`;
|
|
2338
|
+
return `<!-- anchor: ${linePart}quote "${oneLine(anchor.quote)}" \xB7 context ${context} -->`;
|
|
2339
|
+
}
|
|
2340
|
+
function statusHeading(thread) {
|
|
2341
|
+
if (thread.status === "resolved") {
|
|
2342
|
+
const who = thread.resolvedBy ? `${thread.resolvedBy}, ` : "";
|
|
2343
|
+
const when = thread.resolvedAt ? `${thread.resolvedAt}` : "";
|
|
2344
|
+
const meta = who || when ? ` (${who}${when})` : "";
|
|
2345
|
+
return `\u2705 RESOLVED${meta}`;
|
|
2346
|
+
}
|
|
2347
|
+
return "\u{1F7E0} OPEN";
|
|
2348
|
+
}
|
|
2349
|
+
function snapshotLabel(v) {
|
|
2350
|
+
return v == null ? "Unpinned" : `Snapshot v${v}`;
|
|
2351
|
+
}
|
|
2352
|
+
function orderThreads(threads) {
|
|
2353
|
+
return [...threads].sort((a, b) => {
|
|
2354
|
+
if (a.status !== b.status) return a.status === "open" ? -1 : 1;
|
|
2355
|
+
return a.createdAt.localeCompare(b.createdAt);
|
|
2356
|
+
});
|
|
2357
|
+
}
|
|
2358
|
+
function projectThreadsToMarkdown(artifactTitle, threads) {
|
|
2359
|
+
const lines = [];
|
|
2360
|
+
lines.push(`# ${artifactTitle} \u2014 Annotations`);
|
|
2361
|
+
lines.push("");
|
|
2362
|
+
lines.push(
|
|
2363
|
+
"> Auto-generated from review comments, grouped by the artifact snapshot each was anchored to. **Open** threads are unresolved feedback for the next iteration; **resolved** threads were already addressed (don't re-break them)."
|
|
2276
2364
|
);
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2365
|
+
lines.push("");
|
|
2366
|
+
if (threads.length === 0) {
|
|
2367
|
+
lines.push("_No annotations yet._");
|
|
2368
|
+
lines.push("");
|
|
2369
|
+
return lines.join("\n");
|
|
2370
|
+
}
|
|
2371
|
+
const buckets2 = /* @__PURE__ */ new Map();
|
|
2372
|
+
for (const t of threads) {
|
|
2373
|
+
const key = t.snapshotVersion ?? null;
|
|
2374
|
+
(buckets2.get(key) ?? buckets2.set(key, []).get(key)).push(t);
|
|
2375
|
+
}
|
|
2376
|
+
const keys = [...buckets2.keys()].sort((a, b) => {
|
|
2377
|
+
if (a == null) return 1;
|
|
2378
|
+
if (b == null) return -1;
|
|
2379
|
+
return b - a;
|
|
2288
2380
|
});
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2381
|
+
for (const key of keys) {
|
|
2382
|
+
lines.push(`## ${snapshotLabel(key)}`);
|
|
2383
|
+
lines.push("");
|
|
2384
|
+
for (const thread of orderThreads(buckets2.get(key))) {
|
|
2385
|
+
lines.push(`### Thread \xB7 ${statusHeading(thread)}`);
|
|
2386
|
+
lines.push(renderAnchor(thread.anchor));
|
|
2387
|
+
for (const comment of thread.comments) {
|
|
2388
|
+
lines.push(
|
|
2389
|
+
`- **${comment.author}** \xB7 ${comment.createdAt} \u2014 ${comment.body}`
|
|
2390
|
+
);
|
|
2391
|
+
}
|
|
2392
|
+
lines.push("");
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
return lines.join("\n");
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
// src/annotations/service.ts
|
|
2399
|
+
async function loadComments(threadId) {
|
|
2296
2400
|
const db = getDb();
|
|
2297
|
-
const
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
WHERE nest_id = ? AND node_id = ?
|
|
2301
|
-
AND status IN ('approved', 'rejected')
|
|
2302
|
-
AND resolved_by IS NOT NULL
|
|
2303
|
-
ORDER BY resolved_at DESC`,
|
|
2304
|
-
[nestId, nodeId]
|
|
2401
|
+
const rows = await db.all(
|
|
2402
|
+
"SELECT id, author, body, created_at FROM annotation_comments WHERE thread_id = ? ORDER BY created_at ASC, id ASC",
|
|
2403
|
+
[threadId]
|
|
2305
2404
|
);
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
}
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
});
|
|
2321
|
-
});
|
|
2322
|
-
nodeRoutes.get("/:nodeId{.+?}/reviews", async (c) => {
|
|
2323
|
-
const nestId = c.req.param("nestId");
|
|
2324
|
-
const nodeId = c.req.param("nodeId");
|
|
2325
|
-
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-QJ3FBOML.js");
|
|
2326
|
-
const history = await getReviewHistory2(nestId, nodeId);
|
|
2327
|
-
return c.json({ reviews: history });
|
|
2328
|
-
});
|
|
2329
|
-
nodeRoutes.post("/:nodeId{.+}/revert", async (c) => {
|
|
2330
|
-
const nestId = c.req.param("nestId");
|
|
2331
|
-
const nodeId = c.req.param("nodeId");
|
|
2332
|
-
const { versions: versionManager } = await engineCache.get(nestId);
|
|
2333
|
-
const userId = c.get("userId");
|
|
2334
|
-
const userEmail = await resolveCallerEmail(userId);
|
|
2335
|
-
if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
2336
|
-
return c.json(
|
|
2337
|
-
{ error: "Access denied \u2014 no steward assignment for this node" },
|
|
2338
|
-
403
|
|
2339
|
-
);
|
|
2340
|
-
}
|
|
2341
|
-
const body = await c.req.json().catch(() => ({}));
|
|
2342
|
-
const targetVersion = Number(body.targetVersion);
|
|
2343
|
-
if (!Number.isInteger(targetVersion) || targetVersion < 1) {
|
|
2344
|
-
throw new ValidationError("targetVersion (a positive integer) is required");
|
|
2345
|
-
}
|
|
2346
|
-
let raw;
|
|
2347
|
-
try {
|
|
2348
|
-
raw = await versionManager.reconstructVersion(nodeId, targetVersion);
|
|
2349
|
-
} catch {
|
|
2350
|
-
throw new NotFoundError(
|
|
2351
|
-
`Version ${targetVersion} not found for ${nodeId}`
|
|
2352
|
-
);
|
|
2353
|
-
}
|
|
2354
|
-
const content = bodyOnly(nodeId, raw);
|
|
2355
|
-
const { node, version } = await updateNode(
|
|
2356
|
-
nestId,
|
|
2357
|
-
nodeId,
|
|
2358
|
-
{ content, changeNote: `Restored from version ${targetVersion}` },
|
|
2359
|
-
userEmail
|
|
2360
|
-
);
|
|
2361
|
-
await trackEvent("node.revert", { nestId, nodeId, targetVersion });
|
|
2362
|
-
return c.json({ ok: true, version, node: toNodeResponse(node) });
|
|
2363
|
-
});
|
|
2364
|
-
nodeRoutes.get("/:nodeId{.+}", async (c) => {
|
|
2365
|
-
const nestId = c.req.param("nestId");
|
|
2366
|
-
const nodeId = c.req.param("nodeId");
|
|
2367
|
-
const { storage, versions: versionManager } = await engineCache.get(nestId);
|
|
2368
|
-
const userId = c.get("userId");
|
|
2369
|
-
const userEmail = await resolveCallerEmail(userId);
|
|
2370
|
-
if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
2371
|
-
return c.json(
|
|
2372
|
-
{ error: "Access denied \u2014 no steward assignment for this node" },
|
|
2373
|
-
403
|
|
2374
|
-
);
|
|
2375
|
-
}
|
|
2376
|
-
let node;
|
|
2377
|
-
try {
|
|
2378
|
-
node = await storage.readDocument(nodeId, { verifyChecksum: true });
|
|
2379
|
-
} catch {
|
|
2380
|
-
throw new NotFoundError(`Node not found: ${nodeId}`);
|
|
2381
|
-
}
|
|
2382
|
-
if (node.pendingChange) {
|
|
2383
|
-
try {
|
|
2384
|
-
await scanDocumentForDrift(nestId, nodeId, userEmail || "system:read");
|
|
2385
|
-
const refreshed = await getPendingChange(nestId, nodeId);
|
|
2386
|
-
if (refreshed) node.pendingChange = refreshed;
|
|
2387
|
-
} catch (err) {
|
|
2388
|
-
console.error("[external-edit] stage-on-read failed:", err);
|
|
2389
|
-
}
|
|
2390
|
-
try {
|
|
2391
|
-
const latest = await loadLatestApprovedNode(nestId, nodeId);
|
|
2392
|
-
if (latest) {
|
|
2393
|
-
node = { ...latest, pendingChange: node.pendingChange };
|
|
2394
|
-
}
|
|
2395
|
-
} catch (err) {
|
|
2396
|
-
console.error("[external-edit] reconstruct-latest failed:", err);
|
|
2397
|
-
}
|
|
2398
|
-
}
|
|
2399
|
-
const response = toNodeResponse(node);
|
|
2400
|
-
if (await isPublicReader(nestId, userId)) {
|
|
2401
|
-
const approved = await getApprovedVersion(nestId, nodeId);
|
|
2402
|
-
if (approved != null) {
|
|
2403
|
-
try {
|
|
2404
|
-
const raw = await versionManager.reconstructVersion(
|
|
2405
|
-
nodeId,
|
|
2406
|
-
approved
|
|
2407
|
-
);
|
|
2408
|
-
response.content = bodyOnly(nodeId, raw);
|
|
2409
|
-
} catch (err) {
|
|
2410
|
-
console.error(
|
|
2411
|
-
"reconstructVersion failed (public single)",
|
|
2412
|
-
nodeId,
|
|
2413
|
-
approved,
|
|
2414
|
-
err
|
|
2415
|
-
);
|
|
2416
|
-
response.content = "";
|
|
2417
|
-
}
|
|
2418
|
-
response.version = approved;
|
|
2419
|
-
response.status = "published";
|
|
2420
|
-
if (isMarkdownFormat(c)) {
|
|
2421
|
-
return c.body(nodeAsMarkdown(response, nodeId), 200, {
|
|
2422
|
-
"Content-Type": "text/markdown; charset=utf-8"
|
|
2423
|
-
});
|
|
2424
|
-
}
|
|
2425
|
-
return c.json({ node: response });
|
|
2426
|
-
}
|
|
2427
|
-
}
|
|
2428
|
-
response.status = node.pendingChange ? "external_edit_pending" : await getDisplayStatus(nestId, nodeId);
|
|
2429
|
-
if (response.status === "pending_review") {
|
|
2430
|
-
const pending = await getPendingReview(nestId, nodeId);
|
|
2431
|
-
response.pendingReviewBy = pending?.requestedBy ?? null;
|
|
2432
|
-
}
|
|
2433
|
-
if (isMarkdownFormat(c)) {
|
|
2434
|
-
return c.body(nodeAsMarkdown(response, nodeId), 200, {
|
|
2435
|
-
"Content-Type": "text/markdown; charset=utf-8"
|
|
2436
|
-
});
|
|
2437
|
-
}
|
|
2438
|
-
return c.json({ node: response });
|
|
2439
|
-
});
|
|
2440
|
-
nodeRoutes.patch("/:nodeId{.+}", async (c) => {
|
|
2441
|
-
const nestId = c.req.param("nestId");
|
|
2442
|
-
const nodeId = c.req.param("nodeId");
|
|
2443
|
-
const body = await c.req.json();
|
|
2444
|
-
const baseVersionHeader = c.req.header("X-Base-Version");
|
|
2445
|
-
if (baseVersionHeader) {
|
|
2446
|
-
const baseVersion = parseInt(baseVersionHeader, 10);
|
|
2447
|
-
const conflict = await checkConflict(nestId, nodeId, baseVersion);
|
|
2448
|
-
if (conflict.conflict) {
|
|
2449
|
-
return c.json(
|
|
2450
|
-
{
|
|
2451
|
-
error: "Version conflict",
|
|
2452
|
-
your_version: baseVersion,
|
|
2453
|
-
current_version: conflict.currentVersion,
|
|
2454
|
-
updated_by: conflict.updatedBy,
|
|
2455
|
-
updated_at: conflict.updatedAt,
|
|
2456
|
-
rejected_content: body.content || body.append || null
|
|
2457
|
-
},
|
|
2458
|
-
409
|
|
2459
|
-
);
|
|
2460
|
-
}
|
|
2461
|
-
}
|
|
2462
|
-
const authorEmail = await getUserEmail(c);
|
|
2463
|
-
const { node, version: responseVersion } = await updateNode(
|
|
2464
|
-
nestId,
|
|
2465
|
-
nodeId,
|
|
2466
|
-
{
|
|
2467
|
-
content: body.content,
|
|
2468
|
-
append: body.append,
|
|
2469
|
-
tags: body.tags,
|
|
2470
|
-
title: body.title,
|
|
2471
|
-
status: body.status,
|
|
2472
|
-
changeNote: body.changeNote
|
|
2473
|
-
},
|
|
2474
|
-
authorEmail
|
|
2475
|
-
);
|
|
2476
|
-
return c.json({ node: toNodeResponse(node), version: responseVersion });
|
|
2477
|
-
});
|
|
2478
|
-
nodeRoutes.delete("/:nodeId{.+}", async (c) => {
|
|
2479
|
-
const nestId = c.req.param("nestId");
|
|
2480
|
-
const nodeId = c.req.param("nodeId");
|
|
2481
|
-
const { storage } = await engineCache.get(nestId);
|
|
2482
|
-
if (await isStewardshipEnabled(nestId) && await getPendingReview(nestId, nodeId)) {
|
|
2483
|
-
throw new LockedError(
|
|
2484
|
-
"This document is awaiting steward review and is locked. Approve or reject the pending review before deleting."
|
|
2485
|
-
);
|
|
2486
|
-
}
|
|
2487
|
-
try {
|
|
2488
|
-
await storage.deleteDocument(nodeId);
|
|
2489
|
-
} catch {
|
|
2490
|
-
throw new NotFoundError(`Node not found: ${nodeId}`);
|
|
2491
|
-
}
|
|
2492
|
-
await removeNodeFromTagIndex(nestId, nodeId);
|
|
2493
|
-
const db = getDb();
|
|
2494
|
-
await db.transaction(async (tx) => {
|
|
2495
|
-
await tx.run(
|
|
2496
|
-
"DELETE FROM node_versions WHERE nest_id = ? AND node_id = ?",
|
|
2497
|
-
[nestId, nodeId]
|
|
2498
|
-
);
|
|
2499
|
-
await tx.run(
|
|
2500
|
-
"DELETE FROM review_requests WHERE nest_id = ? AND node_id = ?",
|
|
2501
|
-
[nestId, nodeId]
|
|
2502
|
-
);
|
|
2503
|
-
await tx.run(
|
|
2504
|
-
"DELETE FROM approved_versions WHERE nest_id = ? AND node_id = ?",
|
|
2505
|
-
[nestId, nodeId]
|
|
2506
|
-
);
|
|
2507
|
-
await tx.run(
|
|
2508
|
-
`DELETE FROM stewards
|
|
2509
|
-
WHERE nest_id = ? AND scope = 'document' AND node_pattern = ?`,
|
|
2510
|
-
[nestId, nodeId]
|
|
2511
|
-
);
|
|
2512
|
-
});
|
|
2513
|
-
await trackEvent("node.delete", { nestId, nodeId });
|
|
2514
|
-
return c.json({ deleted: true });
|
|
2515
|
-
});
|
|
2516
|
-
async function getUserEmail(c) {
|
|
2517
|
-
const userId = c.get("userId");
|
|
2518
|
-
const db = getDb();
|
|
2519
|
-
const user = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
|
|
2520
|
-
return user?.email || "anonymous@localhost";
|
|
2521
|
-
}
|
|
2522
|
-
|
|
2523
|
-
// src/annotations/routes.ts
|
|
2524
|
-
import { Hono as Hono5 } from "hono";
|
|
2525
|
-
|
|
2526
|
-
// src/annotations/service.ts
|
|
2527
|
-
import { v4 as uuid3 } from "uuid";
|
|
2528
|
-
|
|
2529
|
-
// src/annotations/projection.ts
|
|
2530
|
-
var MAX_CONTEXT_CHARS = 250;
|
|
2531
|
-
var MAX_QUOTE_CHARS = 1e3;
|
|
2532
|
-
function clampAnchor(raw) {
|
|
2533
|
-
if (!raw) return null;
|
|
2534
|
-
const quote = (raw.quote ?? "").trim().slice(0, MAX_QUOTE_CHARS);
|
|
2535
|
-
if (!quote) return null;
|
|
2536
|
-
const before = (raw.before ?? "").slice(-MAX_CONTEXT_CHARS);
|
|
2537
|
-
const after = (raw.after ?? "").slice(0, MAX_CONTEXT_CHARS);
|
|
2538
|
-
const line = typeof raw.line === "number" && Number.isFinite(raw.line) && raw.line > 0 ? Math.floor(raw.line) : void 0;
|
|
2539
|
-
return { quote, before, after, ...line !== void 0 ? { line } : {} };
|
|
2540
|
-
}
|
|
2541
|
-
function oneLine(s) {
|
|
2542
|
-
return s.replace(/\s+/g, " ").replace(/--+>/g, "-\u2192").trim();
|
|
2543
|
-
}
|
|
2544
|
-
function renderAnchor(anchor) {
|
|
2545
|
-
if (!anchor) {
|
|
2546
|
-
return "<!-- anchor: whole-artifact -->";
|
|
2547
|
-
}
|
|
2548
|
-
const linePart = anchor.line !== void 0 ? `line ${anchor.line} \xB7 ` : "";
|
|
2549
|
-
const context = `\u2026${oneLine(anchor.before)}\u3008${oneLine(anchor.quote)}\u3009${oneLine(
|
|
2550
|
-
anchor.after
|
|
2551
|
-
)}\u2026`;
|
|
2552
|
-
return `<!-- anchor: ${linePart}quote "${oneLine(anchor.quote)}" \xB7 context ${context} -->`;
|
|
2553
|
-
}
|
|
2554
|
-
function statusHeading(thread) {
|
|
2555
|
-
if (thread.status === "resolved") {
|
|
2556
|
-
const who = thread.resolvedBy ? `${thread.resolvedBy}, ` : "";
|
|
2557
|
-
const when = thread.resolvedAt ? `${thread.resolvedAt}` : "";
|
|
2558
|
-
const meta = who || when ? ` (${who}${when})` : "";
|
|
2559
|
-
return `\u2705 RESOLVED${meta}`;
|
|
2560
|
-
}
|
|
2561
|
-
return "\u{1F7E0} OPEN";
|
|
2562
|
-
}
|
|
2563
|
-
function snapshotLabel(v) {
|
|
2564
|
-
return v == null ? "Unpinned" : `Snapshot v${v}`;
|
|
2565
|
-
}
|
|
2566
|
-
function orderThreads(threads) {
|
|
2567
|
-
return [...threads].sort((a, b) => {
|
|
2568
|
-
if (a.status !== b.status) return a.status === "open" ? -1 : 1;
|
|
2569
|
-
return a.createdAt.localeCompare(b.createdAt);
|
|
2570
|
-
});
|
|
2571
|
-
}
|
|
2572
|
-
function projectThreadsToMarkdown(artifactTitle, threads) {
|
|
2573
|
-
const lines = [];
|
|
2574
|
-
lines.push(`# ${artifactTitle} \u2014 Annotations`);
|
|
2575
|
-
lines.push("");
|
|
2576
|
-
lines.push(
|
|
2577
|
-
"> Auto-generated from review comments, grouped by the artifact snapshot each was anchored to. **Open** threads are unresolved feedback for the next iteration; **resolved** threads were already addressed (don't re-break them)."
|
|
2578
|
-
);
|
|
2579
|
-
lines.push("");
|
|
2580
|
-
if (threads.length === 0) {
|
|
2581
|
-
lines.push("_No annotations yet._");
|
|
2582
|
-
lines.push("");
|
|
2583
|
-
return lines.join("\n");
|
|
2584
|
-
}
|
|
2585
|
-
const buckets2 = /* @__PURE__ */ new Map();
|
|
2586
|
-
for (const t of threads) {
|
|
2587
|
-
const key = t.snapshotVersion ?? null;
|
|
2588
|
-
(buckets2.get(key) ?? buckets2.set(key, []).get(key)).push(t);
|
|
2589
|
-
}
|
|
2590
|
-
const keys = [...buckets2.keys()].sort((a, b) => {
|
|
2591
|
-
if (a == null) return 1;
|
|
2592
|
-
if (b == null) return -1;
|
|
2593
|
-
return b - a;
|
|
2594
|
-
});
|
|
2595
|
-
for (const key of keys) {
|
|
2596
|
-
lines.push(`## ${snapshotLabel(key)}`);
|
|
2597
|
-
lines.push("");
|
|
2598
|
-
for (const thread of orderThreads(buckets2.get(key))) {
|
|
2599
|
-
lines.push(`### Thread \xB7 ${statusHeading(thread)}`);
|
|
2600
|
-
lines.push(renderAnchor(thread.anchor));
|
|
2601
|
-
for (const comment of thread.comments) {
|
|
2602
|
-
lines.push(
|
|
2603
|
-
`- **${comment.author}** \xB7 ${comment.createdAt} \u2014 ${comment.body}`
|
|
2604
|
-
);
|
|
2605
|
-
}
|
|
2606
|
-
lines.push("");
|
|
2607
|
-
}
|
|
2608
|
-
}
|
|
2609
|
-
return lines.join("\n");
|
|
2610
|
-
}
|
|
2611
|
-
|
|
2612
|
-
// src/annotations/service.ts
|
|
2613
|
-
async function loadComments(threadId) {
|
|
2614
|
-
const db = getDb();
|
|
2615
|
-
const rows = await db.all(
|
|
2616
|
-
"SELECT id, author, body, created_at FROM annotation_comments WHERE thread_id = ? ORDER BY created_at ASC, rowid ASC",
|
|
2617
|
-
[threadId]
|
|
2618
|
-
);
|
|
2619
|
-
return rows.map((r) => ({
|
|
2620
|
-
id: r.id,
|
|
2621
|
-
author: r.author,
|
|
2622
|
-
body: r.body,
|
|
2623
|
-
createdAt: r.created_at
|
|
2624
|
-
}));
|
|
2625
|
-
}
|
|
2626
|
-
async function rowToThread(row) {
|
|
2627
|
-
let anchor = null;
|
|
2628
|
-
if (row.anchor_json) {
|
|
2629
|
-
try {
|
|
2630
|
-
anchor = JSON.parse(row.anchor_json);
|
|
2631
|
-
} catch {
|
|
2632
|
-
anchor = null;
|
|
2405
|
+
return rows.map((r) => ({
|
|
2406
|
+
id: r.id,
|
|
2407
|
+
author: r.author,
|
|
2408
|
+
body: r.body,
|
|
2409
|
+
createdAt: r.created_at
|
|
2410
|
+
}));
|
|
2411
|
+
}
|
|
2412
|
+
async function rowToThread(row) {
|
|
2413
|
+
let anchor = null;
|
|
2414
|
+
if (row.anchor_json) {
|
|
2415
|
+
try {
|
|
2416
|
+
anchor = JSON.parse(row.anchor_json);
|
|
2417
|
+
} catch {
|
|
2418
|
+
anchor = null;
|
|
2633
2419
|
}
|
|
2634
2420
|
}
|
|
2635
2421
|
return {
|
|
@@ -2649,9 +2435,21 @@ async function rowToThread(row) {
|
|
|
2649
2435
|
async function getThreadRow(threadId) {
|
|
2650
2436
|
return await getDb().get("SELECT * FROM annotation_threads WHERE id = ?", [threadId]);
|
|
2651
2437
|
}
|
|
2438
|
+
async function countThreadsByNode(nestId) {
|
|
2439
|
+
const rows = await getDb().all(
|
|
2440
|
+
`SELECT node_id, CAST(COUNT(*) AS INTEGER) AS count, MAX(created_at) AS last_at
|
|
2441
|
+
FROM annotation_threads
|
|
2442
|
+
WHERE nest_id = ?
|
|
2443
|
+
GROUP BY node_id`,
|
|
2444
|
+
[nestId]
|
|
2445
|
+
);
|
|
2446
|
+
const out = {};
|
|
2447
|
+
for (const r of rows) out[r.node_id] = { count: r.count, lastAt: r.last_at };
|
|
2448
|
+
return out;
|
|
2449
|
+
}
|
|
2652
2450
|
async function listThreads(nestId, nodeId) {
|
|
2653
2451
|
const rows = await getDb().all(
|
|
2654
|
-
"SELECT * FROM annotation_threads WHERE nest_id = ? AND node_id = ? ORDER BY created_at ASC,
|
|
2452
|
+
"SELECT * FROM annotation_threads WHERE nest_id = ? AND node_id = ? ORDER BY created_at ASC, id ASC",
|
|
2655
2453
|
[nestId, nodeId]
|
|
2656
2454
|
);
|
|
2657
2455
|
return Promise.all(rows.map(rowToThread));
|
|
@@ -2758,44 +2556,389 @@ function derivedAnnotationsId(sourceNodeId) {
|
|
|
2758
2556
|
}
|
|
2759
2557
|
async function syncAnnotationsNode(nestId, nodeId, userEmail) {
|
|
2760
2558
|
try {
|
|
2761
|
-
let title = nodeId;
|
|
2559
|
+
let title = nodeId;
|
|
2560
|
+
let type = null;
|
|
2561
|
+
try {
|
|
2562
|
+
const { storage } = await engineCache.get(nestId);
|
|
2563
|
+
const node = await storage.readDocument(nodeId);
|
|
2564
|
+
title = node.frontmatter?.title || nodeId;
|
|
2565
|
+
type = node.frontmatter?.type || "document";
|
|
2566
|
+
} catch {
|
|
2567
|
+
}
|
|
2568
|
+
if (type !== null && type !== "artifact") return;
|
|
2569
|
+
const threads = await listThreads(nestId, nodeId);
|
|
2570
|
+
const derivedTitle = `${title} \u2014 Annotations`;
|
|
2571
|
+
const markdown = projectThreadsToMarkdown(title, threads);
|
|
2572
|
+
const derivedId = derivedAnnotationsId(nodeId);
|
|
2573
|
+
try {
|
|
2574
|
+
await updateNode(nestId, derivedId, { content: markdown }, userEmail);
|
|
2575
|
+
} catch (err) {
|
|
2576
|
+
if (err instanceof NotFoundError) {
|
|
2577
|
+
await createNode(
|
|
2578
|
+
nestId,
|
|
2579
|
+
{
|
|
2580
|
+
id: derivedId,
|
|
2581
|
+
title: derivedTitle,
|
|
2582
|
+
content: markdown,
|
|
2583
|
+
type: "document",
|
|
2584
|
+
tags: ["annotations"]
|
|
2585
|
+
},
|
|
2586
|
+
userEmail
|
|
2587
|
+
);
|
|
2588
|
+
} else {
|
|
2589
|
+
throw err;
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2592
|
+
} catch (err) {
|
|
2593
|
+
console.warn(
|
|
2594
|
+
`[annotations] failed to sync derived node for ${nestId}/${nodeId}:`,
|
|
2595
|
+
err
|
|
2596
|
+
);
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2600
|
+
// src/nodes/routes.ts
|
|
2601
|
+
var nodeRoutes = new Hono4();
|
|
2602
|
+
function nodeAsMarkdown(response, nodeId) {
|
|
2603
|
+
return nodeToMarkdown({
|
|
2604
|
+
id: nodeId,
|
|
2605
|
+
title: response.title,
|
|
2606
|
+
tags: response.tags,
|
|
2607
|
+
status: response.status,
|
|
2608
|
+
body: response.content
|
|
2609
|
+
});
|
|
2610
|
+
}
|
|
2611
|
+
nodeRoutes.get("/", async (c) => {
|
|
2612
|
+
const nestId = c.req.param("nestId");
|
|
2613
|
+
const userId = c.get("userId");
|
|
2614
|
+
const nodes = await listNodesForCaller(nestId, userId);
|
|
2615
|
+
return c.json({ count: nodes.length, nodes });
|
|
2616
|
+
});
|
|
2617
|
+
nodeRoutes.post("/", async (c) => {
|
|
2618
|
+
const body = await c.req.json();
|
|
2619
|
+
if (!body.title || !body.content) {
|
|
2620
|
+
throw new ValidationError("title and content are required");
|
|
2621
|
+
}
|
|
2622
|
+
if (body.folder !== void 0 && typeof body.folder !== "string") {
|
|
2623
|
+
throw new ValidationError('folder must be a string path like "gtm/deals"');
|
|
2624
|
+
}
|
|
2625
|
+
const nestId = c.req.param("nestId");
|
|
2626
|
+
const authorEmail = await getUserEmail(c);
|
|
2627
|
+
const { node } = await createNode(
|
|
2628
|
+
nestId,
|
|
2629
|
+
{
|
|
2630
|
+
title: body.title,
|
|
2631
|
+
content: body.content,
|
|
2632
|
+
type: body.type,
|
|
2633
|
+
tags: body.tags,
|
|
2634
|
+
scope: body.scope,
|
|
2635
|
+
status: body.status,
|
|
2636
|
+
schedule: body.schedule,
|
|
2637
|
+
folder: body.folder
|
|
2638
|
+
},
|
|
2639
|
+
authorEmail
|
|
2640
|
+
);
|
|
2641
|
+
const resolved = await resolveStewardsForNode(nestId, node.id);
|
|
2642
|
+
return c.json({
|
|
2643
|
+
node: toNodeResponse(node),
|
|
2644
|
+
stewards: resolved.length > 0 ? resolved.map((r) => ({
|
|
2645
|
+
email: r.steward.userEmail,
|
|
2646
|
+
role: r.steward.role,
|
|
2647
|
+
source: r.source
|
|
2648
|
+
})) : void 0
|
|
2649
|
+
}, 201);
|
|
2650
|
+
});
|
|
2651
|
+
nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
|
|
2652
|
+
const nestId = c.req.param("nestId");
|
|
2653
|
+
const nodeId = c.req.param("nodeId");
|
|
2654
|
+
const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-ZXAMFTXO.js");
|
|
2655
|
+
const { stewards, fallbackToOwner, ownerEmail } = await resolveStewardsWithFallback2(
|
|
2656
|
+
nestId,
|
|
2657
|
+
nodeId
|
|
2658
|
+
);
|
|
2659
|
+
return c.json({
|
|
2660
|
+
nodeId,
|
|
2661
|
+
stewards: stewards.map((r) => ({
|
|
2662
|
+
email: r.steward.userEmail,
|
|
2663
|
+
role: r.steward.role,
|
|
2664
|
+
scope: r.steward.scope,
|
|
2665
|
+
source: r.source,
|
|
2666
|
+
priority: r.priority
|
|
2667
|
+
})),
|
|
2668
|
+
fallbackToOwner,
|
|
2669
|
+
ownerEmail
|
|
2670
|
+
});
|
|
2671
|
+
});
|
|
2672
|
+
nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
|
|
2673
|
+
const nestId = c.req.param("nestId");
|
|
2674
|
+
const nodeId = c.req.param("nodeId");
|
|
2675
|
+
const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-B3SDOJQE.js");
|
|
2676
|
+
const allVersions = await getVersions2(nestId, nodeId);
|
|
2677
|
+
const approved = await getApprovedVersion2(nestId, nodeId);
|
|
2678
|
+
const db = getDb();
|
|
2679
|
+
const resolutions = await db.all(
|
|
2680
|
+
`SELECT version, status, resolved_by, resolved_at
|
|
2681
|
+
FROM review_requests
|
|
2682
|
+
WHERE nest_id = ? AND node_id = ?
|
|
2683
|
+
AND status IN ('approved', 'rejected')
|
|
2684
|
+
AND resolved_by IS NOT NULL
|
|
2685
|
+
ORDER BY resolved_at DESC`,
|
|
2686
|
+
[nestId, nodeId]
|
|
2687
|
+
);
|
|
2688
|
+
const byVersion = /* @__PURE__ */ new Map();
|
|
2689
|
+
for (const r of resolutions) {
|
|
2690
|
+
if (!byVersion.has(r.version)) {
|
|
2691
|
+
byVersion.set(r.version, { status: r.status, resolvedBy: r.resolved_by });
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
const enriched = allVersions.map((v) => {
|
|
2695
|
+
const r = byVersion.get(v.version);
|
|
2696
|
+
return r ? { ...v, resolvedBy: r.resolvedBy, resolutionStatus: r.status } : v;
|
|
2697
|
+
});
|
|
2698
|
+
return c.json({
|
|
2699
|
+
versions: enriched,
|
|
2700
|
+
approvedVersion: approved,
|
|
2701
|
+
currentVersion: allVersions[0]?.version || 0
|
|
2702
|
+
});
|
|
2703
|
+
});
|
|
2704
|
+
nodeRoutes.get("/:nodeId{.+?}/reviews", async (c) => {
|
|
2705
|
+
const nestId = c.req.param("nestId");
|
|
2706
|
+
const nodeId = c.req.param("nodeId");
|
|
2707
|
+
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-G5US3SMG.js");
|
|
2708
|
+
const history = await getReviewHistory2(nestId, nodeId);
|
|
2709
|
+
return c.json({ reviews: history });
|
|
2710
|
+
});
|
|
2711
|
+
nodeRoutes.post("/:nodeId{.+}/revert", async (c) => {
|
|
2712
|
+
const nestId = c.req.param("nestId");
|
|
2713
|
+
const nodeId = c.req.param("nodeId");
|
|
2714
|
+
const { versions: versionManager } = await engineCache.get(nestId);
|
|
2715
|
+
const userId = c.get("userId");
|
|
2716
|
+
const userEmail = await resolveCallerEmail(userId);
|
|
2717
|
+
if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
2718
|
+
return c.json(
|
|
2719
|
+
{ error: "Access denied \u2014 no steward assignment for this node" },
|
|
2720
|
+
403
|
|
2721
|
+
);
|
|
2722
|
+
}
|
|
2723
|
+
const body = await c.req.json().catch(() => ({}));
|
|
2724
|
+
const targetVersion = Number(body.targetVersion);
|
|
2725
|
+
if (!Number.isInteger(targetVersion) || targetVersion < 1) {
|
|
2726
|
+
throw new ValidationError("targetVersion (a positive integer) is required");
|
|
2727
|
+
}
|
|
2728
|
+
let raw;
|
|
2729
|
+
try {
|
|
2730
|
+
raw = await versionManager.reconstructVersion(nodeId, targetVersion);
|
|
2731
|
+
} catch {
|
|
2732
|
+
throw new NotFoundError(
|
|
2733
|
+
`Version ${targetVersion} not found for ${nodeId}`
|
|
2734
|
+
);
|
|
2735
|
+
}
|
|
2736
|
+
const content = bodyOnly(nodeId, raw);
|
|
2737
|
+
const { node, version } = await updateNode(
|
|
2738
|
+
nestId,
|
|
2739
|
+
nodeId,
|
|
2740
|
+
{ content, changeNote: `Restored from version ${targetVersion}` },
|
|
2741
|
+
userEmail
|
|
2742
|
+
);
|
|
2743
|
+
await trackEvent("node.revert", { nestId, nodeId, targetVersion });
|
|
2744
|
+
return c.json({ ok: true, version, node: toNodeResponse(node) });
|
|
2745
|
+
});
|
|
2746
|
+
nodeRoutes.get("/:nodeId{.+}", async (c) => {
|
|
2747
|
+
const nestId = c.req.param("nestId");
|
|
2748
|
+
const nodeId = c.req.param("nodeId");
|
|
2749
|
+
const { storage, versions: versionManager } = await engineCache.get(nestId);
|
|
2750
|
+
const userId = c.get("userId");
|
|
2751
|
+
const userEmail = await resolveCallerEmail(userId);
|
|
2752
|
+
if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
2753
|
+
return c.json(
|
|
2754
|
+
{ error: "Access denied \u2014 no steward assignment for this node" },
|
|
2755
|
+
403
|
|
2756
|
+
);
|
|
2757
|
+
}
|
|
2758
|
+
let node;
|
|
2759
|
+
try {
|
|
2760
|
+
node = await storage.readDocument(nodeId, { verifyChecksum: true });
|
|
2761
|
+
} catch {
|
|
2762
|
+
throw new NotFoundError(`Node not found: ${nodeId}`);
|
|
2763
|
+
}
|
|
2764
|
+
if (node.pendingChange) {
|
|
2762
2765
|
try {
|
|
2763
|
-
|
|
2764
|
-
const
|
|
2765
|
-
|
|
2766
|
-
} catch {
|
|
2766
|
+
await scanDocumentForDrift(nestId, nodeId, userEmail || "system:read");
|
|
2767
|
+
const refreshed = await getPendingChange(nestId, nodeId);
|
|
2768
|
+
if (refreshed) node.pendingChange = refreshed;
|
|
2769
|
+
} catch (err) {
|
|
2770
|
+
console.error("[external-edit] stage-on-read failed:", err);
|
|
2767
2771
|
}
|
|
2768
|
-
const threads = await listThreads(nestId, nodeId);
|
|
2769
|
-
const derivedTitle = `${title} \u2014 Annotations`;
|
|
2770
|
-
const markdown = projectThreadsToMarkdown(title, threads);
|
|
2771
|
-
const derivedId = derivedAnnotationsId(nodeId);
|
|
2772
2772
|
try {
|
|
2773
|
-
await
|
|
2773
|
+
const latest = await loadLatestApprovedNode(nestId, nodeId);
|
|
2774
|
+
if (latest) {
|
|
2775
|
+
node = { ...latest, pendingChange: node.pendingChange };
|
|
2776
|
+
}
|
|
2774
2777
|
} catch (err) {
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2778
|
+
console.error("[external-edit] reconstruct-latest failed:", err);
|
|
2779
|
+
}
|
|
2780
|
+
}
|
|
2781
|
+
const response = toNodeResponse(node);
|
|
2782
|
+
if (await isPublicReader(nestId, userId)) {
|
|
2783
|
+
const approved = await getApprovedVersion(nestId, nodeId);
|
|
2784
|
+
if (approved != null) {
|
|
2785
|
+
try {
|
|
2786
|
+
const raw = await versionManager.reconstructVersion(
|
|
2787
|
+
nodeId,
|
|
2788
|
+
approved
|
|
2786
2789
|
);
|
|
2787
|
-
|
|
2788
|
-
|
|
2790
|
+
response.content = bodyOnly(nodeId, raw);
|
|
2791
|
+
} catch (err) {
|
|
2792
|
+
console.error(
|
|
2793
|
+
"reconstructVersion failed (public single)",
|
|
2794
|
+
nodeId,
|
|
2795
|
+
approved,
|
|
2796
|
+
err
|
|
2797
|
+
);
|
|
2798
|
+
response.content = "";
|
|
2799
|
+
}
|
|
2800
|
+
response.version = approved;
|
|
2801
|
+
response.status = "published";
|
|
2802
|
+
if (isMarkdownFormat(c)) {
|
|
2803
|
+
return c.body(nodeAsMarkdown(response, nodeId), 200, {
|
|
2804
|
+
"Content-Type": "text/markdown; charset=utf-8"
|
|
2805
|
+
});
|
|
2789
2806
|
}
|
|
2807
|
+
return c.json({ node: response });
|
|
2790
2808
|
}
|
|
2791
|
-
}
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2809
|
+
}
|
|
2810
|
+
response.status = node.pendingChange ? "external_edit_pending" : await getDisplayStatus(nestId, nodeId);
|
|
2811
|
+
if (response.status === "pending_review") {
|
|
2812
|
+
const pending = await getPendingReview(nestId, nodeId);
|
|
2813
|
+
response.pendingReviewBy = pending?.requestedBy ?? null;
|
|
2814
|
+
}
|
|
2815
|
+
if (isMarkdownFormat(c)) {
|
|
2816
|
+
return c.body(nodeAsMarkdown(response, nodeId), 200, {
|
|
2817
|
+
"Content-Type": "text/markdown; charset=utf-8"
|
|
2818
|
+
});
|
|
2819
|
+
}
|
|
2820
|
+
return c.json({ node: response });
|
|
2821
|
+
});
|
|
2822
|
+
nodeRoutes.patch("/:nodeId{.+}", async (c) => {
|
|
2823
|
+
const nestId = c.req.param("nestId");
|
|
2824
|
+
const nodeId = c.req.param("nodeId");
|
|
2825
|
+
const body = await c.req.json();
|
|
2826
|
+
const baseVersionHeader = c.req.header("X-Base-Version");
|
|
2827
|
+
if (baseVersionHeader) {
|
|
2828
|
+
const baseVersion = parseInt(baseVersionHeader, 10);
|
|
2829
|
+
const conflict = await checkConflict(nestId, nodeId, baseVersion);
|
|
2830
|
+
if (conflict.conflict) {
|
|
2831
|
+
return c.json(
|
|
2832
|
+
{
|
|
2833
|
+
error: "Version conflict",
|
|
2834
|
+
your_version: baseVersion,
|
|
2835
|
+
current_version: conflict.currentVersion,
|
|
2836
|
+
updated_by: conflict.updatedBy,
|
|
2837
|
+
updated_at: conflict.updatedAt,
|
|
2838
|
+
rejected_content: body.content || body.append || null
|
|
2839
|
+
},
|
|
2840
|
+
409
|
|
2841
|
+
);
|
|
2842
|
+
}
|
|
2843
|
+
}
|
|
2844
|
+
const authorEmail = await getUserEmail(c);
|
|
2845
|
+
const { node, version: responseVersion } = await updateNode(
|
|
2846
|
+
nestId,
|
|
2847
|
+
nodeId,
|
|
2848
|
+
{
|
|
2849
|
+
content: body.content,
|
|
2850
|
+
append: body.append,
|
|
2851
|
+
tags: body.tags,
|
|
2852
|
+
title: body.title,
|
|
2853
|
+
status: body.status,
|
|
2854
|
+
changeNote: body.changeNote,
|
|
2855
|
+
schedule: body.schedule
|
|
2856
|
+
},
|
|
2857
|
+
authorEmail
|
|
2858
|
+
);
|
|
2859
|
+
return c.json({ node: toNodeResponse(node), version: responseVersion });
|
|
2860
|
+
});
|
|
2861
|
+
nodeRoutes.delete("/:nodeId{.+}", async (c) => {
|
|
2862
|
+
const nestId = c.req.param("nestId");
|
|
2863
|
+
const nodeId = c.req.param("nodeId");
|
|
2864
|
+
const { storage } = await engineCache.get(nestId);
|
|
2865
|
+
if (await isStewardshipEnabled(nestId) && await getPendingReview(nestId, nodeId)) {
|
|
2866
|
+
throw new LockedError(
|
|
2867
|
+
"This document is awaiting steward review and is locked. Approve or reject the pending review before deleting."
|
|
2795
2868
|
);
|
|
2796
2869
|
}
|
|
2870
|
+
try {
|
|
2871
|
+
await storage.deleteDocument(nodeId);
|
|
2872
|
+
} catch {
|
|
2873
|
+
throw new NotFoundError(`Node not found: ${nodeId}`);
|
|
2874
|
+
}
|
|
2875
|
+
await removeNodeFromTagIndex(nestId, nodeId);
|
|
2876
|
+
const derivedId = derivedAnnotationsId(nodeId);
|
|
2877
|
+
try {
|
|
2878
|
+
await storage.deleteDocument(derivedId);
|
|
2879
|
+
await removeNodeFromTagIndex(nestId, derivedId);
|
|
2880
|
+
} catch {
|
|
2881
|
+
}
|
|
2882
|
+
const db = getDb();
|
|
2883
|
+
await db.transaction(async (tx) => {
|
|
2884
|
+
await tx.run(
|
|
2885
|
+
"DELETE FROM node_versions WHERE nest_id = ? AND node_id = ?",
|
|
2886
|
+
[nestId, nodeId]
|
|
2887
|
+
);
|
|
2888
|
+
await tx.run(
|
|
2889
|
+
"DELETE FROM review_requests WHERE nest_id = ? AND node_id = ?",
|
|
2890
|
+
[nestId, nodeId]
|
|
2891
|
+
);
|
|
2892
|
+
await tx.run(
|
|
2893
|
+
"DELETE FROM approved_versions WHERE nest_id = ? AND node_id = ?",
|
|
2894
|
+
[nestId, nodeId]
|
|
2895
|
+
);
|
|
2896
|
+
await tx.run(
|
|
2897
|
+
`DELETE FROM stewards
|
|
2898
|
+
WHERE nest_id = ? AND scope = 'document' AND node_pattern = ?`,
|
|
2899
|
+
[nestId, nodeId]
|
|
2900
|
+
);
|
|
2901
|
+
await tx.run(
|
|
2902
|
+
"DELETE FROM annotation_threads WHERE nest_id = ? AND node_id = ?",
|
|
2903
|
+
[nestId, nodeId]
|
|
2904
|
+
);
|
|
2905
|
+
await tx.run(
|
|
2906
|
+
"DELETE FROM node_versions WHERE nest_id = ? AND node_id = ?",
|
|
2907
|
+
[nestId, derivedId]
|
|
2908
|
+
);
|
|
2909
|
+
await tx.run(
|
|
2910
|
+
"DELETE FROM review_requests WHERE nest_id = ? AND node_id = ?",
|
|
2911
|
+
[nestId, derivedId]
|
|
2912
|
+
);
|
|
2913
|
+
await tx.run(
|
|
2914
|
+
"DELETE FROM approved_versions WHERE nest_id = ? AND node_id = ?",
|
|
2915
|
+
[nestId, derivedId]
|
|
2916
|
+
);
|
|
2917
|
+
await tx.run(
|
|
2918
|
+
"DELETE FROM annotation_threads WHERE nest_id = ? AND node_id = ?",
|
|
2919
|
+
[nestId, derivedId]
|
|
2920
|
+
);
|
|
2921
|
+
await tx.run(
|
|
2922
|
+
`INSERT INTO node_deletions (nest_id, node_id, deleted_by, deleted_at)
|
|
2923
|
+
VALUES (?, ?, ?, ?)
|
|
2924
|
+
ON CONFLICT(nest_id, node_id) DO UPDATE
|
|
2925
|
+
SET deleted_by = excluded.deleted_by, deleted_at = excluded.deleted_at`,
|
|
2926
|
+
[nestId, nodeId, await getUserEmail(c), (/* @__PURE__ */ new Date()).toISOString()]
|
|
2927
|
+
);
|
|
2928
|
+
});
|
|
2929
|
+
await trackEvent("node.delete", { nestId, nodeId });
|
|
2930
|
+
return c.json({ deleted: true });
|
|
2931
|
+
});
|
|
2932
|
+
async function getUserEmail(c) {
|
|
2933
|
+
const userId = c.get("userId");
|
|
2934
|
+
const db = getDb();
|
|
2935
|
+
const user = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
|
|
2936
|
+
return user?.email || "anonymous@localhost";
|
|
2797
2937
|
}
|
|
2798
2938
|
|
|
2939
|
+
// src/annotations/routes.ts
|
|
2940
|
+
import { Hono as Hono5 } from "hono";
|
|
2941
|
+
|
|
2799
2942
|
// src/annotations/types.ts
|
|
2800
2943
|
var ARTIFACT_NODE_TYPE = "artifact";
|
|
2801
2944
|
|
|
@@ -3290,6 +3433,100 @@ async function buildNestGraph(nestId, userId, userEmail, canSeeIdentities) {
|
|
|
3290
3433
|
|
|
3291
3434
|
// src/nodes/query-routes.ts
|
|
3292
3435
|
var queryRoutes = new Hono6();
|
|
3436
|
+
queryRoutes.get("/changes", async (c) => {
|
|
3437
|
+
const nestId = c.req.param("nestId");
|
|
3438
|
+
const userId = c.get("userId");
|
|
3439
|
+
const sinceRaw = c.req.query("since");
|
|
3440
|
+
const sinceMs = sinceRaw ? Date.parse(sinceRaw) : NaN;
|
|
3441
|
+
if (!sinceRaw || Number.isNaN(sinceMs)) {
|
|
3442
|
+
throw new ValidationError(
|
|
3443
|
+
"since must be an ISO-8601 date, e.g. ?since=2026-07-01T00:00:00Z"
|
|
3444
|
+
);
|
|
3445
|
+
}
|
|
3446
|
+
const includeContent = ["1", "true"].includes(
|
|
3447
|
+
c.req.query("include_content") || ""
|
|
3448
|
+
);
|
|
3449
|
+
const all = await listNodesForCaller(nestId, userId);
|
|
3450
|
+
const db = getDb();
|
|
3451
|
+
const publicReader = await isPublicReader(nestId, userId);
|
|
3452
|
+
const approvedRows = await db.all(
|
|
3453
|
+
"SELECT node_id, approved_at FROM approved_versions WHERE nest_id = ?",
|
|
3454
|
+
[nestId]
|
|
3455
|
+
);
|
|
3456
|
+
const parseDbTs = (t) => Date.parse(t.includes("T") ? t : `${t.replace(" ", "T")}Z`);
|
|
3457
|
+
const approvedAtMsByNode = new Map(
|
|
3458
|
+
approvedRows.map((r) => [r.node_id, parseDbTs(r.approved_at)])
|
|
3459
|
+
);
|
|
3460
|
+
const publishedSince = new Set(
|
|
3461
|
+
approvedRows.filter((r) => parseDbTs(r.approved_at) > sinceMs).map((r) => r.node_id)
|
|
3462
|
+
);
|
|
3463
|
+
const summarize = (n, updatedOverride) => ({
|
|
3464
|
+
id: n.id,
|
|
3465
|
+
title: n.title,
|
|
3466
|
+
type: n.type,
|
|
3467
|
+
status: n.status,
|
|
3468
|
+
tags: n.tags,
|
|
3469
|
+
created_at: n.created_at,
|
|
3470
|
+
updated_at: updatedOverride ?? n.updated_at,
|
|
3471
|
+
...includeContent ? { content: n.content } : {}
|
|
3472
|
+
});
|
|
3473
|
+
const created = [];
|
|
3474
|
+
const updated = [];
|
|
3475
|
+
for (const n of all) {
|
|
3476
|
+
if (publicReader) {
|
|
3477
|
+
const approvedMs = approvedAtMsByNode.get(n.id);
|
|
3478
|
+
if (approvedMs === void 0) continue;
|
|
3479
|
+
const approvedIso = new Date(approvedMs).toISOString();
|
|
3480
|
+
const createdAt2 = Date.parse(n.created_at || "");
|
|
3481
|
+
if (!Number.isNaN(createdAt2) && createdAt2 > sinceMs) {
|
|
3482
|
+
created.push(summarize(n, approvedIso));
|
|
3483
|
+
} else if (approvedMs > sinceMs) {
|
|
3484
|
+
updated.push(summarize(n, approvedIso));
|
|
3485
|
+
}
|
|
3486
|
+
continue;
|
|
3487
|
+
}
|
|
3488
|
+
const createdAt = Date.parse(n.created_at || "");
|
|
3489
|
+
const updatedAt = Date.parse(n.updated_at || "");
|
|
3490
|
+
if (!Number.isNaN(createdAt) && createdAt > sinceMs) created.push(summarize(n));
|
|
3491
|
+
else if (!Number.isNaN(updatedAt) && updatedAt > sinceMs) updated.push(summarize(n));
|
|
3492
|
+
else if (publishedSince.has(n.id)) updated.push(summarize(n));
|
|
3493
|
+
}
|
|
3494
|
+
const canSeeDeleted = permissionLevel(c.get("nestPermission")) >= permissionLevel("write");
|
|
3495
|
+
let deleted = null;
|
|
3496
|
+
if (canSeeDeleted) {
|
|
3497
|
+
const rows = await db.all(
|
|
3498
|
+
"SELECT node_id, deleted_by, deleted_at FROM node_deletions WHERE nest_id = ? AND deleted_at > ?",
|
|
3499
|
+
[nestId, new Date(sinceMs).toISOString()]
|
|
3500
|
+
);
|
|
3501
|
+
const liveIds = new Set(all.map((n) => n.id));
|
|
3502
|
+
deleted = rows.filter((r) => !liveIds.has(r.node_id)).map((r) => ({ id: r.node_id, deleted_by: r.deleted_by, deleted_at: r.deleted_at }));
|
|
3503
|
+
}
|
|
3504
|
+
return c.json({
|
|
3505
|
+
since: new Date(sinceMs).toISOString(),
|
|
3506
|
+
now: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3507
|
+
created,
|
|
3508
|
+
updated,
|
|
3509
|
+
deleted
|
|
3510
|
+
});
|
|
3511
|
+
});
|
|
3512
|
+
queryRoutes.get("/runnables", async (c) => {
|
|
3513
|
+
const nestId = c.req.param("nestId");
|
|
3514
|
+
const userId = c.get("userId");
|
|
3515
|
+
const all = await listNodesForCaller(nestId, userId, {
|
|
3516
|
+
type: RUNNABLE_NODE_TYPES
|
|
3517
|
+
});
|
|
3518
|
+
const runnables = all.map((n) => ({
|
|
3519
|
+
id: n.id,
|
|
3520
|
+
title: n.title,
|
|
3521
|
+
type: n.type,
|
|
3522
|
+
schedule: n.schedule ?? null,
|
|
3523
|
+
status: n.status,
|
|
3524
|
+
tags: n.tags,
|
|
3525
|
+
updated_at: n.updated_at,
|
|
3526
|
+
content: n.content
|
|
3527
|
+
}));
|
|
3528
|
+
return c.json({ count: runnables.length, runnables });
|
|
3529
|
+
});
|
|
3293
3530
|
queryRoutes.get("/graph", async (c) => {
|
|
3294
3531
|
const nestId = c.req.param("nestId");
|
|
3295
3532
|
const userId = c.get("userId");
|
|
@@ -3322,11 +3559,30 @@ queryRoutes.post("/context", async (c) => {
|
|
|
3322
3559
|
const maxTokens = Math.max(50, body.max_tokens ?? 4e3);
|
|
3323
3560
|
const hops = body.hops ?? 2;
|
|
3324
3561
|
const includeDrafts = body.include_drafts === true;
|
|
3562
|
+
const isVisible = (d) => includeDrafts || d.frontmatter.status === "published";
|
|
3325
3563
|
let selector = body.selector?.trim() || null;
|
|
3326
3564
|
let compileDetail = null;
|
|
3327
3565
|
let titleMatches = [];
|
|
3566
|
+
let resolvedTitleSelector = false;
|
|
3328
3567
|
const allDocs = await storage.discoverDocuments();
|
|
3329
|
-
if (
|
|
3568
|
+
if (selector && selector.includes("[[")) {
|
|
3569
|
+
const parts = selector.split("|").map((p) => p.trim()).filter(Boolean);
|
|
3570
|
+
const wantedTitles = /* @__PURE__ */ new Set();
|
|
3571
|
+
const rest = [];
|
|
3572
|
+
for (const p of parts) {
|
|
3573
|
+
const m = /^\[\[(.+)\]\]$/.exec(p);
|
|
3574
|
+
if (m) wantedTitles.add(m[1].trim().toLowerCase());
|
|
3575
|
+
else rest.push(p);
|
|
3576
|
+
}
|
|
3577
|
+
if (wantedTitles.size > 0) {
|
|
3578
|
+
selector = rest.join("|") || null;
|
|
3579
|
+
resolvedTitleSelector = true;
|
|
3580
|
+
titleMatches = allDocs.filter(
|
|
3581
|
+
(d) => wantedTitles.has(String(d.frontmatter.title || "").toLowerCase()) && isVisible(d)
|
|
3582
|
+
);
|
|
3583
|
+
}
|
|
3584
|
+
}
|
|
3585
|
+
if (!selector && !resolvedTitleSelector && body.prompt) {
|
|
3330
3586
|
const titles = allDocs.map((d) => d.frontmatter.title);
|
|
3331
3587
|
compileDetail = await compilePrompt(body.prompt, nestId, titles);
|
|
3332
3588
|
selector = compileDetail.selector;
|
|
@@ -3352,6 +3608,44 @@ queryRoutes.post("/context", async (c) => {
|
|
|
3352
3608
|
hopsUsed = result.hopsUsed;
|
|
3353
3609
|
nodesTraversed = result.nodesTraversed;
|
|
3354
3610
|
}
|
|
3611
|
+
if (titleMatches.length > 0 && hops > 0) {
|
|
3612
|
+
const byTitle = new Map(
|
|
3613
|
+
allDocs.map((d) => [
|
|
3614
|
+
String(d.frontmatter.title || "").toLowerCase(),
|
|
3615
|
+
d
|
|
3616
|
+
])
|
|
3617
|
+
);
|
|
3618
|
+
const byId = new Map(allDocs.map((d) => [d.id, d]));
|
|
3619
|
+
const adj = /* @__PURE__ */ new Map();
|
|
3620
|
+
const link = (a, b) => {
|
|
3621
|
+
(adj.get(a) ?? adj.set(a, /* @__PURE__ */ new Set()).get(a)).add(b);
|
|
3622
|
+
(adj.get(b) ?? adj.set(b, /* @__PURE__ */ new Set()).get(b)).add(a);
|
|
3623
|
+
};
|
|
3624
|
+
for (const d of allDocs) {
|
|
3625
|
+
for (const target of extractWikiTargets(d.body || "")) {
|
|
3626
|
+
const t = byId.get(target) ?? byTitle.get(target.toLowerCase());
|
|
3627
|
+
if (t && t.id !== d.id) link(d.id, t.id);
|
|
3628
|
+
}
|
|
3629
|
+
}
|
|
3630
|
+
const seen = new Set(titleMatches.map((d) => d.id));
|
|
3631
|
+
let frontier = titleMatches.map((d) => d.id);
|
|
3632
|
+
for (let depth = 0; depth < hops && frontier.length; depth++) {
|
|
3633
|
+
const next = [];
|
|
3634
|
+
for (const id of frontier) {
|
|
3635
|
+
for (const nb of adj.get(id) ?? []) {
|
|
3636
|
+
if (seen.has(nb)) continue;
|
|
3637
|
+
seen.add(nb);
|
|
3638
|
+
const doc = byId.get(nb);
|
|
3639
|
+
if (!doc || !isVisible(doc)) continue;
|
|
3640
|
+
next.push(nb);
|
|
3641
|
+
titleMatches.push(doc);
|
|
3642
|
+
}
|
|
3643
|
+
}
|
|
3644
|
+
frontier = next;
|
|
3645
|
+
hopsUsed = Math.max(hopsUsed, depth + 1);
|
|
3646
|
+
}
|
|
3647
|
+
nodesTraversed += seen.size;
|
|
3648
|
+
}
|
|
3355
3649
|
if (titleMatches.length > 0) {
|
|
3356
3650
|
const seen = new Set(documents.map((d) => d.id));
|
|
3357
3651
|
for (const t of titleMatches) {
|
|
@@ -3500,6 +3794,10 @@ queryRoutes.get("/overview", async (c) => {
|
|
|
3500
3794
|
}))
|
|
3501
3795
|
});
|
|
3502
3796
|
});
|
|
3797
|
+
queryRoutes.get("/comment-counts", async (c) => {
|
|
3798
|
+
const counts = await countThreadsByNode(c.req.param("nestId"));
|
|
3799
|
+
return c.json({ counts });
|
|
3800
|
+
});
|
|
3503
3801
|
queryRoutes.get("/context", async (c) => {
|
|
3504
3802
|
const { storage } = await engineCache.get(c.req.param("nestId"));
|
|
3505
3803
|
const content = await storage.readContextMd();
|
|
@@ -3620,7 +3918,87 @@ import { Hono as Hono7 } from "hono";
|
|
|
3620
3918
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3621
3919
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
3622
3920
|
|
|
3921
|
+
// src/telemetry/trace-log.ts
|
|
3922
|
+
var RETENTION_DAYS = 14;
|
|
3923
|
+
var PRUNE_EVERY = 500;
|
|
3924
|
+
var insertsSincePrune = 0;
|
|
3925
|
+
async function logTraceEvent(e) {
|
|
3926
|
+
try {
|
|
3927
|
+
const db = getDb();
|
|
3928
|
+
await db.run(
|
|
3929
|
+
`INSERT INTO api_events
|
|
3930
|
+
(ts, kind, method, path, tool, nest_id, user_id, user_email, status, duration_ms)
|
|
3931
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
3932
|
+
[
|
|
3933
|
+
(/* @__PURE__ */ new Date()).toISOString(),
|
|
3934
|
+
e.kind,
|
|
3935
|
+
e.method ?? null,
|
|
3936
|
+
e.path ?? null,
|
|
3937
|
+
e.tool ?? null,
|
|
3938
|
+
e.nestId ?? null,
|
|
3939
|
+
e.userId ?? null,
|
|
3940
|
+
e.userEmail ?? null,
|
|
3941
|
+
e.status ?? null,
|
|
3942
|
+
e.durationMs ?? null
|
|
3943
|
+
]
|
|
3944
|
+
);
|
|
3945
|
+
if (++insertsSincePrune >= PRUNE_EVERY) {
|
|
3946
|
+
insertsSincePrune = 0;
|
|
3947
|
+
const cutoff = new Date(
|
|
3948
|
+
Date.now() - RETENTION_DAYS * 864e5
|
|
3949
|
+
).toISOString();
|
|
3950
|
+
await db.run("DELETE FROM api_events WHERE ts < ?", [cutoff]);
|
|
3951
|
+
}
|
|
3952
|
+
} catch {
|
|
3953
|
+
}
|
|
3954
|
+
}
|
|
3955
|
+
async function listTraceEvents(filters = {}) {
|
|
3956
|
+
const db = getDb();
|
|
3957
|
+
const limit = Number.isFinite(filters.limit) ? Math.min(Math.max(filters.limit, 1), 1e3) : 25;
|
|
3958
|
+
const offset = Number.isFinite(filters.offset) ? Math.max(filters.offset, 0) : 0;
|
|
3959
|
+
const from = "FROM api_events e LEFT JOIN users u ON u.id = e.user_id";
|
|
3960
|
+
const where = [];
|
|
3961
|
+
const args = [];
|
|
3962
|
+
if (filters.kind) {
|
|
3963
|
+
where.push("e.kind = ?");
|
|
3964
|
+
args.push(filters.kind);
|
|
3965
|
+
}
|
|
3966
|
+
if (filters.nestId) {
|
|
3967
|
+
where.push("e.nest_id = ?");
|
|
3968
|
+
args.push(filters.nestId);
|
|
3969
|
+
}
|
|
3970
|
+
if (filters.user) {
|
|
3971
|
+
where.push("(COALESCE(e.user_email, u.email) LIKE ? OR e.user_id = ?)");
|
|
3972
|
+
args.push(`%${filters.user}%`, filters.user);
|
|
3973
|
+
}
|
|
3974
|
+
const whereSql = where.length ? ` WHERE ${where.join(" AND ")}` : "";
|
|
3975
|
+
try {
|
|
3976
|
+
const totalRow = await db.get(
|
|
3977
|
+
`SELECT COUNT(*) AS n ${from}${whereSql}`,
|
|
3978
|
+
args
|
|
3979
|
+
);
|
|
3980
|
+
const total = Number(totalRow?.n ?? 0);
|
|
3981
|
+
const events = await db.all(
|
|
3982
|
+
`SELECT e.*, COALESCE(e.user_email, u.email) AS caller
|
|
3983
|
+
${from}${whereSql}
|
|
3984
|
+
ORDER BY e.id DESC
|
|
3985
|
+
LIMIT ? OFFSET ?`,
|
|
3986
|
+
[...args, limit, offset]
|
|
3987
|
+
);
|
|
3988
|
+
return { events, total };
|
|
3989
|
+
} catch {
|
|
3990
|
+
return { events: [], total: 0 };
|
|
3991
|
+
}
|
|
3992
|
+
}
|
|
3993
|
+
|
|
3623
3994
|
// src/mcp/tools.ts
|
|
3995
|
+
var MAX_HOPS = 10;
|
|
3996
|
+
function normalizeHops(raw) {
|
|
3997
|
+
if (raw == null) return 2;
|
|
3998
|
+
const n = Number(raw);
|
|
3999
|
+
if (!Number.isFinite(n)) return 2;
|
|
4000
|
+
return Math.max(0, Math.min(MAX_HOPS, Math.floor(n)));
|
|
4001
|
+
}
|
|
3624
4002
|
var TOOL_DEFINITIONS = [
|
|
3625
4003
|
{
|
|
3626
4004
|
name: "context_init",
|
|
@@ -3649,7 +4027,11 @@ var TOOL_DEFINITIONS = [
|
|
|
3649
4027
|
inputSchema: {
|
|
3650
4028
|
type: "object",
|
|
3651
4029
|
properties: {
|
|
3652
|
-
query: { type: "string", description: "Selector query" }
|
|
4030
|
+
query: { type: "string", description: "Selector query" },
|
|
4031
|
+
hops: {
|
|
4032
|
+
type: "number",
|
|
4033
|
+
description: "Graph traversal depth from the matched nodes (default: 2). Use 1 for just the matches + direct links, higher to pull in more of the neighborhood."
|
|
4034
|
+
}
|
|
3653
4035
|
},
|
|
3654
4036
|
required: ["query"]
|
|
3655
4037
|
}
|
|
@@ -3690,9 +4072,37 @@ var TOOL_DEFINITIONS = [
|
|
|
3690
4072
|
max_tokens: {
|
|
3691
4073
|
type: "number",
|
|
3692
4074
|
description: "Approximate token budget (default: 8000)"
|
|
4075
|
+
},
|
|
4076
|
+
hops: {
|
|
4077
|
+
type: "number",
|
|
4078
|
+
description: "Graph traversal depth from the matched nodes (default: 2)"
|
|
4079
|
+
}
|
|
4080
|
+
},
|
|
4081
|
+
required: ["selector"]
|
|
4082
|
+
}
|
|
4083
|
+
},
|
|
4084
|
+
{
|
|
4085
|
+
name: "context_export",
|
|
4086
|
+
description: "Export the ENTIRE nest as one markdown bundle \u2014 every accessible document's FULL content. Use when you need the whole nest as context rather than a targeted query. Respects an optional token budget.",
|
|
4087
|
+
inputSchema: {
|
|
4088
|
+
type: "object",
|
|
4089
|
+
properties: {
|
|
4090
|
+
max_tokens: {
|
|
4091
|
+
type: "number",
|
|
4092
|
+
description: "Approximate token budget; documents are included until it's reached (omit for no cap)."
|
|
3693
4093
|
}
|
|
3694
|
-
}
|
|
3695
|
-
|
|
4094
|
+
}
|
|
4095
|
+
}
|
|
4096
|
+
},
|
|
4097
|
+
{
|
|
4098
|
+
name: "context_comments",
|
|
4099
|
+
description: "Get the review comments/annotations on a document \u2014 anchored quotes, threaded replies, and open/resolved status \u2014 by title or ID. Use to pull human feedback into context.",
|
|
4100
|
+
inputSchema: {
|
|
4101
|
+
type: "object",
|
|
4102
|
+
properties: {
|
|
4103
|
+
title: { type: "string", description: "Title of the node" },
|
|
4104
|
+
id: { type: "string", description: "ID of the node" }
|
|
4105
|
+
}
|
|
3696
4106
|
}
|
|
3697
4107
|
},
|
|
3698
4108
|
{
|
|
@@ -3715,7 +4125,11 @@ var TOOL_DEFINITIONS = [
|
|
|
3715
4125
|
items: { type: "string" },
|
|
3716
4126
|
description: "Tags"
|
|
3717
4127
|
},
|
|
3718
|
-
scope: { type: "string", description: "Visibility scope" }
|
|
4128
|
+
scope: { type: "string", description: "Visibility scope" },
|
|
4129
|
+
folder: {
|
|
4130
|
+
type: "string",
|
|
4131
|
+
description: 'Folder path under nodes/ (e.g. "gtm/deals"); segments are slugified'
|
|
4132
|
+
}
|
|
3719
4133
|
},
|
|
3720
4134
|
required: ["title", "content"]
|
|
3721
4135
|
}
|
|
@@ -3882,6 +4296,26 @@ async function resolveLlmBody(ctx, node) {
|
|
|
3882
4296
|
}
|
|
3883
4297
|
}
|
|
3884
4298
|
async function handleToolCall(toolName, args, ctx) {
|
|
4299
|
+
const started = Date.now();
|
|
4300
|
+
let status = 200;
|
|
4301
|
+
try {
|
|
4302
|
+
return await runTool(toolName, args, ctx);
|
|
4303
|
+
} catch (err) {
|
|
4304
|
+
status = 500;
|
|
4305
|
+
throw err;
|
|
4306
|
+
} finally {
|
|
4307
|
+
void logTraceEvent({
|
|
4308
|
+
kind: "mcp",
|
|
4309
|
+
tool: toolName,
|
|
4310
|
+
nestId: ctx.nestId,
|
|
4311
|
+
userId: ctx.userId,
|
|
4312
|
+
userEmail: ctx.userEmail,
|
|
4313
|
+
status,
|
|
4314
|
+
durationMs: Date.now() - started
|
|
4315
|
+
});
|
|
4316
|
+
}
|
|
4317
|
+
}
|
|
4318
|
+
async function runTool(toolName, args, ctx) {
|
|
3885
4319
|
const { storage, queryEngine, versionManager, nestId, userId, userEmail } = ctx;
|
|
3886
4320
|
switch (toolName) {
|
|
3887
4321
|
case "context_init": {
|
|
@@ -3946,8 +4380,16 @@ ${nodeList}`;
|
|
|
3946
4380
|
${results}`;
|
|
3947
4381
|
}
|
|
3948
4382
|
case "context_query": {
|
|
3949
|
-
const result = await queryEngine.query(args.query, {
|
|
3950
|
-
|
|
4383
|
+
const result = await queryEngine.query(args.query, {
|
|
4384
|
+
hops: normalizeHops(args.hops)
|
|
4385
|
+
});
|
|
4386
|
+
const visibility = await Promise.all(
|
|
4387
|
+
result.documents.map(async (n) => ({
|
|
4388
|
+
node: n,
|
|
4389
|
+
visible: await resolveLlmBody(ctx, n) !== null
|
|
4390
|
+
}))
|
|
4391
|
+
);
|
|
4392
|
+
const nodes = visibility.filter((e) => e.visible).map((e) => e.node);
|
|
3951
4393
|
if (!nodes.length) return `No nodes matched: ${args.query}`;
|
|
3952
4394
|
const list = nodes.map(
|
|
3953
4395
|
(n, i) => `${i + 1}. **${n.frontmatter.title}** [${n.frontmatter.type || "document"}] ${(n.frontmatter.tags || []).join(" ")}`
|
|
@@ -3995,21 +4437,99 @@ ${body || "(no content)"}`;
|
|
|
3995
4437
|
${list}`;
|
|
3996
4438
|
}
|
|
3997
4439
|
case "context_resolve": {
|
|
3998
|
-
const result = await queryEngine.query(args.selector, {
|
|
4440
|
+
const result = await queryEngine.query(args.selector, {
|
|
4441
|
+
hops: normalizeHops(args.hops)
|
|
4442
|
+
});
|
|
3999
4443
|
const maxTokens = args.max_tokens || 8e3;
|
|
4000
4444
|
const approxChars = maxTokens * 4;
|
|
4445
|
+
const resolvedBodies = await Promise.all(
|
|
4446
|
+
result.documents.map(async (n) => ({
|
|
4447
|
+
node: n,
|
|
4448
|
+
body: await resolveLlmBody(ctx, n)
|
|
4449
|
+
}))
|
|
4450
|
+
);
|
|
4001
4451
|
let total = 0;
|
|
4002
4452
|
const resolved = [];
|
|
4003
|
-
for (const n of
|
|
4453
|
+
for (const { node: n, body } of resolvedBodies) {
|
|
4454
|
+
if (body === null) continue;
|
|
4004
4455
|
const entry = `## ${n.frontmatter.title}
|
|
4005
4456
|
|
|
4006
|
-
${
|
|
4457
|
+
${body}`;
|
|
4007
4458
|
if (total + entry.length > approxChars) break;
|
|
4008
4459
|
resolved.push(entry);
|
|
4009
4460
|
total += entry.length;
|
|
4010
4461
|
}
|
|
4011
4462
|
return resolved.join("\n\n---\n\n") || "No nodes resolved.";
|
|
4012
4463
|
}
|
|
4464
|
+
case "context_export": {
|
|
4465
|
+
const docs = await storage.discoverDocuments();
|
|
4466
|
+
const approxChars = args.max_tokens ? args.max_tokens * 4 : Infinity;
|
|
4467
|
+
const resolved = await Promise.all(
|
|
4468
|
+
docs.map(async (n) => ({ node: n, body: await resolveLlmBody(ctx, n) }))
|
|
4469
|
+
);
|
|
4470
|
+
let total = 0;
|
|
4471
|
+
let budgetHit = false;
|
|
4472
|
+
const parts = [];
|
|
4473
|
+
for (const { node: n, body } of resolved) {
|
|
4474
|
+
if (body === null) continue;
|
|
4475
|
+
const meta = [
|
|
4476
|
+
`**Title:** ${n.frontmatter.title}`,
|
|
4477
|
+
`**Type:** ${n.frontmatter.type || "document"}`,
|
|
4478
|
+
n.frontmatter.tags?.length ? `**Tags:** ${n.frontmatter.tags.join(" ")}` : null
|
|
4479
|
+
].filter(Boolean).join("\n");
|
|
4480
|
+
const entry = `${meta}
|
|
4481
|
+
|
|
4482
|
+
${body || "(no content)"}`;
|
|
4483
|
+
if (total + entry.length > approxChars) {
|
|
4484
|
+
budgetHit = true;
|
|
4485
|
+
break;
|
|
4486
|
+
}
|
|
4487
|
+
parts.push(entry);
|
|
4488
|
+
total += entry.length;
|
|
4489
|
+
}
|
|
4490
|
+
if (!parts.length) {
|
|
4491
|
+
return budgetHit ? "Token budget reached before any document fit \u2014 raise max_tokens, or use context_query/context_resolve to target." : "No documents available to export.";
|
|
4492
|
+
}
|
|
4493
|
+
const note = budgetHit ? `
|
|
4494
|
+
|
|
4495
|
+
_(Token budget reached \u2014 ${parts.length} of ${docs.length} documents included. Raise max_tokens, or use context_query/context_resolve to target.)_` : "";
|
|
4496
|
+
return `# Nest export \u2014 ${parts.length} document(s)
|
|
4497
|
+
|
|
4498
|
+
${parts.join(
|
|
4499
|
+
"\n\n---\n\n"
|
|
4500
|
+
)}${note}`;
|
|
4501
|
+
}
|
|
4502
|
+
case "context_comments": {
|
|
4503
|
+
const docs = await storage.discoverDocuments();
|
|
4504
|
+
let node;
|
|
4505
|
+
if (args.title) {
|
|
4506
|
+
node = docs.find(
|
|
4507
|
+
(n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
|
|
4508
|
+
);
|
|
4509
|
+
} else if (args.id) {
|
|
4510
|
+
node = docs.find((n) => n.id === args.id);
|
|
4511
|
+
}
|
|
4512
|
+
if (!node) return `Node not found: ${args.title || args.id}`;
|
|
4513
|
+
if (await resolveLlmBody(ctx, node) === null) {
|
|
4514
|
+
return `Node "${node.frontmatter.title}" has no approved version yet \u2014 not available to AI.`;
|
|
4515
|
+
}
|
|
4516
|
+
const threads = await listThreads(nestId, node.id);
|
|
4517
|
+
if (!threads.length)
|
|
4518
|
+
return `No comments on "${node.frontmatter.title}".`;
|
|
4519
|
+
const openCount = threads.filter((t) => t.status === "open").length;
|
|
4520
|
+
const sections = threads.map((t, i) => {
|
|
4521
|
+
const quote = t.anchor?.quote ? `> ${t.anchor.quote.replace(/\n/g, " ")}
|
|
4522
|
+
|
|
4523
|
+
` : "";
|
|
4524
|
+
const head = `### ${i + 1}. [${t.status}]${t.anchor?.quote ? "" : " (whole-document)"}`;
|
|
4525
|
+
const comments = t.comments.map((c) => `- **${c.author}** (${c.createdAt}): ${c.body}`).join("\n");
|
|
4526
|
+
return `${head}
|
|
4527
|
+
${quote}${comments}`;
|
|
4528
|
+
}).join("\n\n");
|
|
4529
|
+
return `# Comments on "${node.frontmatter.title}" \u2014 ${threads.length} thread(s), ${openCount} open
|
|
4530
|
+
|
|
4531
|
+
${sections}`;
|
|
4532
|
+
}
|
|
4013
4533
|
case "context_create": {
|
|
4014
4534
|
if (!await canCreateInNest(nestId, userEmail)) {
|
|
4015
4535
|
return "You don't have permission to create documents in this nest.";
|
|
@@ -4021,7 +4541,8 @@ ${n.body || ""}`;
|
|
|
4021
4541
|
content: args.content,
|
|
4022
4542
|
type: args.type,
|
|
4023
4543
|
tags: args.tags,
|
|
4024
|
-
scope: args.scope
|
|
4544
|
+
scope: args.scope,
|
|
4545
|
+
folder: args.folder
|
|
4025
4546
|
},
|
|
4026
4547
|
userEmail
|
|
4027
4548
|
);
|
|
@@ -4068,8 +4589,8 @@ ${n.body || ""}`;
|
|
|
4068
4589
|
|
|
4069
4590
|
${list}`;
|
|
4070
4591
|
}
|
|
4071
|
-
if (!canManageStewards(ctx.
|
|
4072
|
-
return "You don't have permission to list stewards. Only the
|
|
4592
|
+
if (!await canManageStewards(ctx.nestId, ctx.userId)) {
|
|
4593
|
+
return "You don't have permission to list stewards. Only a nest admin, the nest owner, or the server admin can do this.";
|
|
4073
4594
|
}
|
|
4074
4595
|
const allStewards = await getStewardsForNest(ctx.nestId);
|
|
4075
4596
|
if (allStewards.length === 0) {
|
|
@@ -4207,8 +4728,8 @@ ${list}`;
|
|
|
4207
4728
|
if (!["nest", "tag", "document"].includes(scope)) {
|
|
4208
4729
|
return `Invalid scope "${args.scope}". Use: nest, tag, or document.`;
|
|
4209
4730
|
}
|
|
4210
|
-
if (!canManageStewards(ctx.
|
|
4211
|
-
return "You don't have permission to manage stewards. Only the
|
|
4731
|
+
if (!await canManageStewards(ctx.nestId, ctx.userId)) {
|
|
4732
|
+
return "You don't have permission to manage stewards. Only a nest admin, the nest owner, or the server admin can do this.";
|
|
4212
4733
|
}
|
|
4213
4734
|
try {
|
|
4214
4735
|
await createStewardRecord({
|
|
@@ -4226,9 +4747,9 @@ ${list}`;
|
|
|
4226
4747
|
}
|
|
4227
4748
|
}
|
|
4228
4749
|
case "context_share_nest": {
|
|
4229
|
-
const
|
|
4230
|
-
if (
|
|
4231
|
-
return "You don't have permission to share this nest
|
|
4750
|
+
const callerPermission = config.AUTH_MODE === "open" ? "owner" : await resolveNestPermission(ctx.nestId, ctx.userId);
|
|
4751
|
+
if (permissionLevel(callerPermission) < permissionLevel("write")) {
|
|
4752
|
+
return "You don't have permission to share this nest. Sharing needs write access \u2014 ask a nest admin or the owner.";
|
|
4232
4753
|
}
|
|
4233
4754
|
const permission = args.permission || "read";
|
|
4234
4755
|
try {
|
|
@@ -4236,7 +4757,8 @@ ${list}`;
|
|
|
4236
4757
|
nestId: ctx.nestId,
|
|
4237
4758
|
email: args.email,
|
|
4238
4759
|
permission,
|
|
4239
|
-
grantedByEmail: ctx.userEmail
|
|
4760
|
+
grantedByEmail: ctx.userEmail,
|
|
4761
|
+
callerPermission
|
|
4240
4762
|
});
|
|
4241
4763
|
const label = permission === "admin" ? "admin" : permission === "write" ? "editor" : "viewer";
|
|
4242
4764
|
return `Shared this nest with **${args.email}** as ${label}.`;
|
|
@@ -4337,8 +4859,407 @@ mcpRoutes.all("/", async (c) => {
|
|
|
4337
4859
|
}
|
|
4338
4860
|
});
|
|
4339
4861
|
|
|
4340
|
-
// src/
|
|
4862
|
+
// src/mcp/server-routes.ts
|
|
4341
4863
|
import { Hono as Hono8 } from "hono";
|
|
4864
|
+
import { McpServer as McpServer2 } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4865
|
+
import { WebStandardStreamableHTTPServerTransport as WebStandardStreamableHTTPServerTransport2 } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
4866
|
+
import { z as z2 } from "zod";
|
|
4867
|
+
async function getUserEmail3(userId) {
|
|
4868
|
+
const db = getDb();
|
|
4869
|
+
const user = await db.get("SELECT email FROM users WHERE id = ?", [
|
|
4870
|
+
userId
|
|
4871
|
+
]);
|
|
4872
|
+
return user?.email || "anonymous@localhost";
|
|
4873
|
+
}
|
|
4874
|
+
async function canRead(nestId, userId) {
|
|
4875
|
+
if (config.AUTH_MODE === "open") return true;
|
|
4876
|
+
const perm = await resolveNestPermission(nestId, userId);
|
|
4877
|
+
return permissionLevel(perm) >= permissionLevel("read");
|
|
4878
|
+
}
|
|
4879
|
+
async function resolveNestRef(ref, userId) {
|
|
4880
|
+
const db = getDb();
|
|
4881
|
+
const byId = await db.get("SELECT * FROM nests WHERE id = ?", [ref]);
|
|
4882
|
+
if (byId) return byId;
|
|
4883
|
+
const byName = await db.all(
|
|
4884
|
+
"SELECT * FROM nests WHERE LOWER(name) = LOWER(?) OR LOWER(slug) = LOWER(?)",
|
|
4885
|
+
[ref, ref]
|
|
4886
|
+
);
|
|
4887
|
+
if (byName.length <= 1) return byName[0] ?? null;
|
|
4888
|
+
const readable = (await Promise.all(
|
|
4889
|
+
byName.map(async (n) => await canRead(n.id, userId) ? n : null)
|
|
4890
|
+
)).filter((n) => n !== null);
|
|
4891
|
+
return readable.length === 1 ? readable[0] : null;
|
|
4892
|
+
}
|
|
4893
|
+
async function buildNestIndex(userId, nestScope) {
|
|
4894
|
+
const [owned, shared, publicExtras] = await Promise.all([
|
|
4895
|
+
listNests(userId),
|
|
4896
|
+
listSharedNests(userId),
|
|
4897
|
+
listPublicNests(userId)
|
|
4898
|
+
]);
|
|
4899
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4900
|
+
const rows = [];
|
|
4901
|
+
for (const n of [...owned, ...shared, ...publicExtras]) {
|
|
4902
|
+
if (seen.has(n.id)) continue;
|
|
4903
|
+
if (nestScope && n.id !== nestScope) continue;
|
|
4904
|
+
seen.add(n.id);
|
|
4905
|
+
rows.push(n);
|
|
4906
|
+
}
|
|
4907
|
+
return Promise.all(
|
|
4908
|
+
rows.map(async (n) => {
|
|
4909
|
+
let permission = "read";
|
|
4910
|
+
try {
|
|
4911
|
+
permission = config.AUTH_MODE === "open" ? "owner" : await resolveNestPermission(n.id, userId);
|
|
4912
|
+
} catch {
|
|
4913
|
+
}
|
|
4914
|
+
let document_count = null;
|
|
4915
|
+
try {
|
|
4916
|
+
const { storage } = await engineCache.get(n.id);
|
|
4917
|
+
document_count = (await storage.discoverDocuments()).length;
|
|
4918
|
+
} catch {
|
|
4919
|
+
}
|
|
4920
|
+
return {
|
|
4921
|
+
id: n.id,
|
|
4922
|
+
name: n.name,
|
|
4923
|
+
description: n.description,
|
|
4924
|
+
visibility: n.visibility,
|
|
4925
|
+
permission,
|
|
4926
|
+
document_count
|
|
4927
|
+
};
|
|
4928
|
+
})
|
|
4929
|
+
);
|
|
4930
|
+
}
|
|
4931
|
+
function renderNestIndexMarkdown(nests, baseUrl) {
|
|
4932
|
+
const lines = [
|
|
4933
|
+
"# Nest index",
|
|
4934
|
+
"",
|
|
4935
|
+
`${nests.length} nest${nests.length === 1 ? "" : "s"} accessible with these credentials. Full interaction manual: ${baseUrl}/llms.txt`,
|
|
4936
|
+
""
|
|
4937
|
+
];
|
|
4938
|
+
for (const n of nests) {
|
|
4939
|
+
lines.push(`## ${n.name}`);
|
|
4940
|
+
lines.push("");
|
|
4941
|
+
lines.push(`- id: \`${n.id}\``);
|
|
4942
|
+
if (n.description) lines.push(`- description: ${n.description}`);
|
|
4943
|
+
lines.push(`- permission: ${n.permission} \xB7 visibility: ${n.visibility}`);
|
|
4944
|
+
if (n.document_count !== null)
|
|
4945
|
+
lines.push(`- documents: ${n.document_count}`);
|
|
4946
|
+
lines.push(
|
|
4947
|
+
`- retrieve: \`POST ${baseUrl}/nests/${n.id}/context\` \xB7 mcp: \`${baseUrl}/nests/${n.id}/mcp\` \xB7 export: \`${baseUrl}/nests/${n.id}/export?format=markdown\``
|
|
4948
|
+
);
|
|
4949
|
+
lines.push("");
|
|
4950
|
+
}
|
|
4951
|
+
return lines.join("\n");
|
|
4952
|
+
}
|
|
4953
|
+
var NEST_ARG = {
|
|
4954
|
+
nest: {
|
|
4955
|
+
type: "string",
|
|
4956
|
+
description: "Target nest \u2014 id, or exact name/slug (case-insensitive)"
|
|
4957
|
+
}
|
|
4958
|
+
};
|
|
4959
|
+
var SERVER_TOOL_DEFINITIONS = [
|
|
4960
|
+
{
|
|
4961
|
+
name: "nest_index",
|
|
4962
|
+
description: "Grounded index of every nest these credentials can read: name, id, permission, document count. Call this FIRST, then target a nest with the other tools.",
|
|
4963
|
+
inputSchema: { type: "object", properties: {} }
|
|
4964
|
+
},
|
|
4965
|
+
{
|
|
4966
|
+
name: "context_query",
|
|
4967
|
+
description: "Run a structured selector query against one nest. Selector grammar: #tag, type:X, [[Title]], scope:X, combined with +AND, |OR, -NOT.",
|
|
4968
|
+
inputSchema: {
|
|
4969
|
+
type: "object",
|
|
4970
|
+
properties: {
|
|
4971
|
+
...NEST_ARG,
|
|
4972
|
+
query: { type: "string", description: "Selector query" },
|
|
4973
|
+
hops: {
|
|
4974
|
+
type: "number",
|
|
4975
|
+
description: "Graph traversal depth from the matches (default: 2)"
|
|
4976
|
+
}
|
|
4977
|
+
},
|
|
4978
|
+
required: ["nest", "query"]
|
|
4979
|
+
}
|
|
4980
|
+
},
|
|
4981
|
+
{
|
|
4982
|
+
name: "context_search",
|
|
4983
|
+
description: "Full-text keyword search across one nest.",
|
|
4984
|
+
inputSchema: {
|
|
4985
|
+
type: "object",
|
|
4986
|
+
properties: {
|
|
4987
|
+
...NEST_ARG,
|
|
4988
|
+
query: { type: "string", description: "Search terms" }
|
|
4989
|
+
},
|
|
4990
|
+
required: ["nest", "query"]
|
|
4991
|
+
}
|
|
4992
|
+
},
|
|
4993
|
+
{
|
|
4994
|
+
name: "context_get",
|
|
4995
|
+
description: "Get the FULL content of a node in a nest by title or id.",
|
|
4996
|
+
inputSchema: {
|
|
4997
|
+
type: "object",
|
|
4998
|
+
properties: {
|
|
4999
|
+
...NEST_ARG,
|
|
5000
|
+
title: { type: "string", description: "Title of the node" },
|
|
5001
|
+
id: { type: "string", description: "ID of the node" }
|
|
5002
|
+
},
|
|
5003
|
+
required: ["nest"]
|
|
5004
|
+
}
|
|
5005
|
+
},
|
|
5006
|
+
{
|
|
5007
|
+
name: "context_list",
|
|
5008
|
+
description: "Browse one nest's contents with optional type, tag, or limit filters.",
|
|
5009
|
+
inputSchema: {
|
|
5010
|
+
type: "object",
|
|
5011
|
+
properties: {
|
|
5012
|
+
...NEST_ARG,
|
|
5013
|
+
type: { type: "string", description: "Filter by node type" },
|
|
5014
|
+
tag: { type: "string", description: "Filter by tag" },
|
|
5015
|
+
limit: { type: "number", description: "Max nodes to return" }
|
|
5016
|
+
},
|
|
5017
|
+
required: ["nest"]
|
|
5018
|
+
}
|
|
5019
|
+
},
|
|
5020
|
+
{
|
|
5021
|
+
name: "context_overview",
|
|
5022
|
+
description: "Map of one nest: node count, types, tags, title+snippet per node.",
|
|
5023
|
+
inputSchema: {
|
|
5024
|
+
type: "object",
|
|
5025
|
+
properties: { ...NEST_ARG },
|
|
5026
|
+
required: ["nest"]
|
|
5027
|
+
}
|
|
5028
|
+
},
|
|
5029
|
+
{
|
|
5030
|
+
name: "context_resolve",
|
|
5031
|
+
description: "Full deterministic context resolution against one nest \u2014 selector + hops + token budget, complete node bodies.",
|
|
5032
|
+
inputSchema: {
|
|
5033
|
+
type: "object",
|
|
5034
|
+
properties: {
|
|
5035
|
+
...NEST_ARG,
|
|
5036
|
+
selector: { type: "string", description: "Selector query string" },
|
|
5037
|
+
max_tokens: {
|
|
5038
|
+
type: "number",
|
|
5039
|
+
description: "Approximate token budget (default: 8000)"
|
|
5040
|
+
},
|
|
5041
|
+
hops: {
|
|
5042
|
+
type: "number",
|
|
5043
|
+
description: "Graph traversal depth from the matches (default: 2)"
|
|
5044
|
+
}
|
|
5045
|
+
},
|
|
5046
|
+
required: ["nest", "selector"]
|
|
5047
|
+
}
|
|
5048
|
+
}
|
|
5049
|
+
];
|
|
5050
|
+
async function handleServerToolCall(toolName, args, userId, userEmail, nestScope, baseUrl) {
|
|
5051
|
+
if (toolName === "nest_index") {
|
|
5052
|
+
const nests = await buildNestIndex(userId, nestScope);
|
|
5053
|
+
if (!nests.length) return "No nests accessible with these credentials.";
|
|
5054
|
+
return renderNestIndexMarkdown(nests, baseUrl);
|
|
5055
|
+
}
|
|
5056
|
+
const inaccessible = `Nest not found or not accessible: "${args.nest}". Use nest_index to see what these credentials can read.`;
|
|
5057
|
+
const nest = await resolveNestRef(String(args.nest ?? ""), userId);
|
|
5058
|
+
if (!nest) return inaccessible;
|
|
5059
|
+
if (nestScope && nest.id !== nestScope) return inaccessible;
|
|
5060
|
+
if (!await canRead(nest.id, userId)) return inaccessible;
|
|
5061
|
+
const engine = await engineCache.get(nest.id);
|
|
5062
|
+
return handleToolCall(toolName, args, {
|
|
5063
|
+
storage: engine.storage,
|
|
5064
|
+
queryEngine: engine.query,
|
|
5065
|
+
versionManager: engine.versions,
|
|
5066
|
+
nestId: nest.id,
|
|
5067
|
+
userId,
|
|
5068
|
+
userEmail
|
|
5069
|
+
});
|
|
5070
|
+
}
|
|
5071
|
+
function createServerMcp(userId, userEmail, nestScope, baseUrl) {
|
|
5072
|
+
const server = new McpServer2(
|
|
5073
|
+
{ name: "contextnest-server", version: "1.0.0" },
|
|
5074
|
+
{ capabilities: { tools: {} } }
|
|
5075
|
+
);
|
|
5076
|
+
for (const tool of SERVER_TOOL_DEFINITIONS) {
|
|
5077
|
+
const props = tool.inputSchema.properties || {};
|
|
5078
|
+
const required = tool.inputSchema.required || [];
|
|
5079
|
+
const shape = {};
|
|
5080
|
+
for (const [key, def] of Object.entries(props)) {
|
|
5081
|
+
let field;
|
|
5082
|
+
if (def.type === "string") field = z2.string();
|
|
5083
|
+
else if (def.type === "number") field = z2.number();
|
|
5084
|
+
else if (def.type === "array") field = z2.array(z2.string());
|
|
5085
|
+
else field = z2.any();
|
|
5086
|
+
if (!required.includes(key)) field = field.optional();
|
|
5087
|
+
shape[key] = field;
|
|
5088
|
+
}
|
|
5089
|
+
server.tool(tool.name, tool.description, shape, async (args) => {
|
|
5090
|
+
const text = await handleServerToolCall(
|
|
5091
|
+
tool.name,
|
|
5092
|
+
args,
|
|
5093
|
+
userId,
|
|
5094
|
+
userEmail,
|
|
5095
|
+
nestScope,
|
|
5096
|
+
baseUrl
|
|
5097
|
+
);
|
|
5098
|
+
return { content: [{ type: "text", text }] };
|
|
5099
|
+
});
|
|
5100
|
+
}
|
|
5101
|
+
return server;
|
|
5102
|
+
}
|
|
5103
|
+
function requestBaseUrl(reqUrl) {
|
|
5104
|
+
if (config.PUBLIC_BASE_URL) return config.PUBLIC_BASE_URL;
|
|
5105
|
+
try {
|
|
5106
|
+
return new URL(reqUrl).origin;
|
|
5107
|
+
} catch {
|
|
5108
|
+
return "";
|
|
5109
|
+
}
|
|
5110
|
+
}
|
|
5111
|
+
var serverMcpRoutes = new Hono8();
|
|
5112
|
+
serverMcpRoutes.all("/", async (c) => {
|
|
5113
|
+
const userId = c.get("userId");
|
|
5114
|
+
const userEmail = await getUserEmail3(userId);
|
|
5115
|
+
const server = createServerMcp(
|
|
5116
|
+
userId,
|
|
5117
|
+
userEmail,
|
|
5118
|
+
c.get("nestScope"),
|
|
5119
|
+
requestBaseUrl(c.req.url)
|
|
5120
|
+
);
|
|
5121
|
+
const transport = new WebStandardStreamableHTTPServerTransport2({
|
|
5122
|
+
sessionIdGenerator: void 0,
|
|
5123
|
+
enableJsonResponse: true
|
|
5124
|
+
});
|
|
5125
|
+
await server.server.connect(transport);
|
|
5126
|
+
try {
|
|
5127
|
+
return await transport.handleRequest(c.req.raw);
|
|
5128
|
+
} finally {
|
|
5129
|
+
await transport.close();
|
|
5130
|
+
await server.server.close();
|
|
5131
|
+
}
|
|
5132
|
+
});
|
|
5133
|
+
var nestIndexRoutes = new Hono8();
|
|
5134
|
+
nestIndexRoutes.get("/", async (c) => {
|
|
5135
|
+
const userId = c.get("userId");
|
|
5136
|
+
const nests = await buildNestIndex(userId, c.get("nestScope"));
|
|
5137
|
+
if (c.req.query("format") === "json") {
|
|
5138
|
+
return c.json({ count: nests.length, nests });
|
|
5139
|
+
}
|
|
5140
|
+
const md = renderNestIndexMarkdown(nests, requestBaseUrl(c.req.url));
|
|
5141
|
+
return c.text(md, 200, { "Content-Type": "text/markdown; charset=utf-8" });
|
|
5142
|
+
});
|
|
5143
|
+
function renderLlmsTxt(baseUrl) {
|
|
5144
|
+
const B = baseUrl || "<server-url>";
|
|
5145
|
+
return `# ContextNest Community Server
|
|
5146
|
+
|
|
5147
|
+
Self-hosted context governance server. Nests are versioned knowledge vaults
|
|
5148
|
+
of markdown documents with tags, wiki-links ([[Title]]), and steward
|
|
5149
|
+
governance. This file is the complete interaction manual for agents.
|
|
5150
|
+
|
|
5151
|
+
## Authentication
|
|
5152
|
+
|
|
5153
|
+
Every request (except this file and /health): \`Authorization: Bearer cnst_<api-key>\`.
|
|
5154
|
+
Keys are minted in the web UI (Connect dialog \u2192 Generate Key). A key is
|
|
5155
|
+
either user-level (sees every nest the user can read) or scoped to one nest.
|
|
5156
|
+
Servers in "open" auth mode need no key.
|
|
5157
|
+
|
|
5158
|
+
## Start here
|
|
5159
|
+
|
|
5160
|
+
1. \`GET ${B}/index\` \u2014 your grounded nest index (markdown; \`?format=json\` for JSON).
|
|
5161
|
+
One entry per accessible nest: name, id, permission, document count, and
|
|
5162
|
+
the per-nest URLs below.
|
|
5163
|
+
2. Pick a nest id, then retrieve with the API or MCP.
|
|
5164
|
+
|
|
5165
|
+
## Deterministic retrieval API
|
|
5166
|
+
|
|
5167
|
+
Same inputs \u2192 same context, every time. The primary call:
|
|
5168
|
+
|
|
5169
|
+
\`\`\`
|
|
5170
|
+
POST ${B}/nests/<nest-id>/context
|
|
5171
|
+
Content-Type: application/json
|
|
5172
|
+
{ "selector": "#gtm", "hops": 2, "max_tokens": 4000, "include_drafts": false }
|
|
5173
|
+
\`\`\`
|
|
5174
|
+
|
|
5175
|
+
Returns \`{ context, nodes[], trace }\` \u2014 assembled markdown plus a trace with
|
|
5176
|
+
\`hops_used\` and \`nodes_traversed\` so you can audit exactly what was pulled.
|
|
5177
|
+
|
|
5178
|
+
Selector grammar:
|
|
5179
|
+
- \`#tag\` \u2014 nodes carrying a tag
|
|
5180
|
+
- \`type:document\` \u2014 nodes of a type
|
|
5181
|
+
- \`[[Title]]\` \u2014 a node by exact title
|
|
5182
|
+
- \`scope:team\` \u2014 nodes by visibility scope
|
|
5183
|
+
- Combine: \`+AND\`, \`|OR\`, \`-NOT\` (e.g. \`#gtm+type:document-#draft\`)
|
|
5184
|
+
|
|
5185
|
+
Graph-hop logic: \`hops\` is the traversal depth over wiki-links from the
|
|
5186
|
+
selector matches. \`hops: 0\` = exactly the matches; \`hops: 1\` = matches plus
|
|
5187
|
+
directly linked nodes; \`hops: 2\` (default) and up walk the link graph
|
|
5188
|
+
further out. For an exact set with no expansion, OR the selectors together
|
|
5189
|
+
(\`[[A]]|[[B]]\`) and pass \`hops: 0\`.
|
|
5190
|
+
|
|
5191
|
+
Skip logic \u2014 what retrieval excludes and why:
|
|
5192
|
+
- Draft/unapproved documents are skipped unless \`include_drafts: true\`
|
|
5193
|
+
(and public/read-only callers always get approved content only).
|
|
5194
|
+
- \`max_tokens\` is a budget: documents that don't fit are skipped, and the
|
|
5195
|
+
trace reports \`truncated_by_budget\` so silence never means "complete".
|
|
5196
|
+
- Documents are deduped by id when a title match and a selector match overlap.
|
|
5197
|
+
|
|
5198
|
+
Other read endpoints (all under \`${B}/nests/<nest-id>\`):
|
|
5199
|
+
- \`POST /query\` \u2014 \`{ "query": "<selector>", "hops": N }\`; matches + snippets, no bodies
|
|
5200
|
+
- \`GET /search?q=<terms>\` \u2014 full-text search
|
|
5201
|
+
- \`GET /overview\` \u2014 node count, types, tags
|
|
5202
|
+
- \`GET /nodes\` \u2014 list; \`GET /nodes/<node-id>?format=markdown\` \u2014 one document
|
|
5203
|
+
- \`GET /export?format=markdown\` \u2014 whole nest as one markdown file (optional \`selector\`, \`max_tokens\`)
|
|
5204
|
+
- \`GET /graph\` \u2014 ontology graph (nodes, links, tags, stewards)
|
|
5205
|
+
|
|
5206
|
+
Write endpoints (need write permission):
|
|
5207
|
+
- \`POST /nodes\` \u2014 \`{ "title", "content", "tags"?, "type"?, "folder"? }\`
|
|
5208
|
+
- \`PATCH /nodes/<node-id>\` \u2014 \`{ "content"? , "append"?, "tags"?, "title"? }\`
|
|
5209
|
+
- \`DELETE /nodes/<node-id>\`
|
|
5210
|
+
- Governance: \`POST /nodes/<id>/submit-review\`, \`/approve\`, \`/reject\`
|
|
5211
|
+
|
|
5212
|
+
## MCP (Model Context Protocol)
|
|
5213
|
+
|
|
5214
|
+
Streamable HTTP, stateless. Two endpoints:
|
|
5215
|
+
|
|
5216
|
+
- \`${B}/mcp\` \u2014 server-level, one connection for everything readable.
|
|
5217
|
+
Tools: \`nest_index\` (call first), then \`context_query\`, \`context_search\`,
|
|
5218
|
+
\`context_get\`, \`context_list\`, \`context_overview\`, \`context_resolve\` \u2014
|
|
5219
|
+
each takes a \`nest\` argument (id or exact name) plus the same
|
|
5220
|
+
selector/hops/max_tokens parameters as the REST API. Read-only.
|
|
5221
|
+
- \`${B}/nests/<nest-id>/mcp\` \u2014 per-nest, full toolset including writes and
|
|
5222
|
+
governance (create/update, submit/approve/reject, stewards, sharing).
|
|
5223
|
+
|
|
5224
|
+
Claude Code / Cursor / VS Code connect natively via HTTP with the
|
|
5225
|
+
\`Authorization: Bearer\` header. Claude Desktop needs the mcp-remote bridge:
|
|
5226
|
+
|
|
5227
|
+
\`\`\`
|
|
5228
|
+
npx -y mcp-remote ${B}/mcp --header "Authorization: Bearer <api-key>"
|
|
5229
|
+
\`\`\`
|
|
5230
|
+
|
|
5231
|
+
## ctx CLI (npm: @promptowl/contextnest-cli)
|
|
5232
|
+
|
|
5233
|
+
\`npm i -g @promptowl/contextnest-cli\`
|
|
5234
|
+
|
|
5235
|
+
- \`ctx push --server ${B} --nest <nest-id> --key <api-key>\` \u2014 upload a local
|
|
5236
|
+
vault into a nest (add \`--include-drafts\` for unpublished nodes).
|
|
5237
|
+
- All other ctx commands (\`query\`, \`add\`, \`search\`, \`checkpoint\`) operate on
|
|
5238
|
+
a LOCAL vault directory only. For remote reads use the API or MCP above \u2014
|
|
5239
|
+
there is currently no remote \`ctx query\`.
|
|
5240
|
+
|
|
5241
|
+
## npm packages
|
|
5242
|
+
|
|
5243
|
+
- \`@promptowl/contextnest-community\` \u2014 this server
|
|
5244
|
+
- \`@promptowl/contextnest-cli\` \u2014 the ctx CLI
|
|
5245
|
+
- \`@promptowl/contextnest-engine\` \u2014 vault storage + selector/graph engine
|
|
5246
|
+
|
|
5247
|
+
## Conventions
|
|
5248
|
+
|
|
5249
|
+
- Node ids are paths: \`nodes/<slug>\` or nested \`nodes/<folder>/<slug>\`.
|
|
5250
|
+
- Tags are \`#lowercase\`. Wiki-links are \`[[Exact Title]]\`.
|
|
5251
|
+
- Every write is versioned and hash-chained; governed nests route writes
|
|
5252
|
+
through steward review before they become AI-visible.
|
|
5253
|
+
`;
|
|
5254
|
+
}
|
|
5255
|
+
var llmsTxtRoutes = new Hono8();
|
|
5256
|
+
llmsTxtRoutes.get("/", (c) => {
|
|
5257
|
+
const md = renderLlmsTxt(requestBaseUrl(c.req.url));
|
|
5258
|
+
return c.text(md, 200, { "Content-Type": "text/markdown; charset=utf-8" });
|
|
5259
|
+
});
|
|
5260
|
+
|
|
5261
|
+
// src/governance/routes.ts
|
|
5262
|
+
import { Hono as Hono9 } from "hono";
|
|
4342
5263
|
|
|
4343
5264
|
// src/governance/comment-service.ts
|
|
4344
5265
|
import { v4 as uuid4 } from "uuid";
|
|
@@ -4642,7 +5563,7 @@ function parseLimit(raw, def = 100, max = 1e3) {
|
|
|
4642
5563
|
if (Number.isNaN(n) || n < 1) return def;
|
|
4643
5564
|
return Math.min(n, max);
|
|
4644
5565
|
}
|
|
4645
|
-
var governanceRoutes = new
|
|
5566
|
+
var governanceRoutes = new Hono9();
|
|
4646
5567
|
governanceRoutes.get("/stewards", async (c) => {
|
|
4647
5568
|
const nestId = c.req.param("nestId");
|
|
4648
5569
|
const scope = c.req.query("scope");
|
|
@@ -4671,7 +5592,7 @@ governanceRoutes.get("/stewards", async (c) => {
|
|
|
4671
5592
|
governanceRoutes.post("/stewards", async (c) => {
|
|
4672
5593
|
const nestId = c.req.param("nestId");
|
|
4673
5594
|
const body = await c.req.json();
|
|
4674
|
-
const assignedBy = await
|
|
5595
|
+
const assignedBy = await getUserEmail4(c);
|
|
4675
5596
|
if (!body.scope) throw new ValidationError("scope is required");
|
|
4676
5597
|
if (body.scope === "folder") {
|
|
4677
5598
|
throw new ValidationError(
|
|
@@ -4746,7 +5667,7 @@ governanceRoutes.get("/review-queue", async (c) => {
|
|
|
4746
5667
|
limit,
|
|
4747
5668
|
offset
|
|
4748
5669
|
});
|
|
4749
|
-
const email = await
|
|
5670
|
+
const email = await getUserEmail4(c);
|
|
4750
5671
|
const canReviewCache = /* @__PURE__ */ new Map();
|
|
4751
5672
|
const requests = [];
|
|
4752
5673
|
for (const r of result.requests) {
|
|
@@ -4770,7 +5691,7 @@ governanceRoutes.get("/external-edits", async (c) => {
|
|
|
4770
5691
|
});
|
|
4771
5692
|
governanceRoutes.post("/external-edits/scan", async (c) => {
|
|
4772
5693
|
const nestId = c.req.param("nestId");
|
|
4773
|
-
const actor = await
|
|
5694
|
+
const actor = await getUserEmail4(c);
|
|
4774
5695
|
const result = await scanNestForDrift(nestId, actor);
|
|
4775
5696
|
return c.json(result);
|
|
4776
5697
|
});
|
|
@@ -4780,7 +5701,7 @@ governanceRoutes.get("/activity", async (c) => {
|
|
|
4780
5701
|
const activity = await getActivity({ nestId, limit });
|
|
4781
5702
|
return c.json({ activity });
|
|
4782
5703
|
});
|
|
4783
|
-
var governanceNodeRoutes = new
|
|
5704
|
+
var governanceNodeRoutes = new Hono9();
|
|
4784
5705
|
governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
|
|
4785
5706
|
const nestId = c.req.param("nestId");
|
|
4786
5707
|
const nodeId = c.req.param("nodeId");
|
|
@@ -4839,7 +5760,7 @@ governanceNodeRoutes.post("/:nodeId{.+}/comments", async (c) => {
|
|
|
4839
5760
|
const nestId = c.req.param("nestId");
|
|
4840
5761
|
const nodeId = c.req.param("nodeId");
|
|
4841
5762
|
const body = await c.req.json();
|
|
4842
|
-
const author = await
|
|
5763
|
+
const author = await getUserEmail4(c);
|
|
4843
5764
|
try {
|
|
4844
5765
|
const comment = await createComment({
|
|
4845
5766
|
nestId,
|
|
@@ -4863,7 +5784,7 @@ governanceNodeRoutes.post(
|
|
|
4863
5784
|
const nestId = c.req.param("nestId");
|
|
4864
5785
|
const nodeId = c.req.param("nodeId");
|
|
4865
5786
|
const commentId = c.req.param("commentId");
|
|
4866
|
-
const resolvedBy = await
|
|
5787
|
+
const resolvedBy = await getUserEmail4(c);
|
|
4867
5788
|
try {
|
|
4868
5789
|
const comment = await resolveComment({
|
|
4869
5790
|
nestId,
|
|
@@ -4896,7 +5817,7 @@ governanceNodeRoutes.post("/:nodeId{.+}/submit-review", async (c) => {
|
|
|
4896
5817
|
if (currentVersion === 0) {
|
|
4897
5818
|
throw new ValidationError("Node has no versions to review");
|
|
4898
5819
|
}
|
|
4899
|
-
const userEmail = await
|
|
5820
|
+
const userEmail = await getUserEmail4(c);
|
|
4900
5821
|
let request;
|
|
4901
5822
|
try {
|
|
4902
5823
|
request = await submitForReview({
|
|
@@ -4931,7 +5852,7 @@ governanceNodeRoutes.post("/:nodeId{.+}/approve", async (c) => {
|
|
|
4931
5852
|
const nestId = c.req.param("nestId");
|
|
4932
5853
|
const nodeId = c.req.param("nodeId");
|
|
4933
5854
|
const body = await c.req.json();
|
|
4934
|
-
const userEmail = await
|
|
5855
|
+
const userEmail = await getUserEmail4(c);
|
|
4935
5856
|
const isAdmin = isSuperAdmin(userEmail);
|
|
4936
5857
|
try {
|
|
4937
5858
|
const request = await approve({
|
|
@@ -4954,7 +5875,7 @@ governanceNodeRoutes.post("/:nodeId{.+}/reject", async (c) => {
|
|
|
4954
5875
|
if (!body.note) {
|
|
4955
5876
|
throw new ValidationError("Rejection note is required");
|
|
4956
5877
|
}
|
|
4957
|
-
const userEmail = await
|
|
5878
|
+
const userEmail = await getUserEmail4(c);
|
|
4958
5879
|
try {
|
|
4959
5880
|
const request = await reject({
|
|
4960
5881
|
nestId,
|
|
@@ -4971,19 +5892,19 @@ governanceNodeRoutes.post("/:nodeId{.+}/reject", async (c) => {
|
|
|
4971
5892
|
governanceNodeRoutes.get("/:nodeId{.+}/can-access", async (c) => {
|
|
4972
5893
|
const nestId = c.req.param("nestId");
|
|
4973
5894
|
const nodeId = c.req.param("nodeId");
|
|
4974
|
-
const userEmail = await
|
|
5895
|
+
const userEmail = await getUserEmail4(c);
|
|
4975
5896
|
return c.json(await canUserAccess(nestId, nodeId, userEmail));
|
|
4976
5897
|
});
|
|
4977
5898
|
governanceNodeRoutes.get("/:nodeId{.+}/can-approve", async (c) => {
|
|
4978
5899
|
const nestId = c.req.param("nestId");
|
|
4979
5900
|
const nodeId = c.req.param("nodeId");
|
|
4980
|
-
const userEmail = await
|
|
5901
|
+
const userEmail = await getUserEmail4(c);
|
|
4981
5902
|
return c.json(await canUserApprove(nestId, nodeId, userEmail));
|
|
4982
5903
|
});
|
|
4983
5904
|
governanceNodeRoutes.get("/:nodeId{.+}/can-edit", async (c) => {
|
|
4984
5905
|
const nestId = c.req.param("nestId");
|
|
4985
5906
|
const nodeId = c.req.param("nodeId");
|
|
4986
|
-
const userEmail = await
|
|
5907
|
+
const userEmail = await getUserEmail4(c);
|
|
4987
5908
|
return c.json(await canUserEdit(nestId, nodeId, userEmail));
|
|
4988
5909
|
});
|
|
4989
5910
|
governanceNodeRoutes.get("/:nodeId{.+?}/external-edits", async (c) => {
|
|
@@ -5016,7 +5937,7 @@ governanceNodeRoutes.post(
|
|
|
5016
5937
|
const nodeId = c.req.param("nodeId");
|
|
5017
5938
|
const suggestionId = c.req.param("suggestionId");
|
|
5018
5939
|
const body = await c.req.json().catch(() => ({}));
|
|
5019
|
-
const actor = await
|
|
5940
|
+
const actor = await getUserEmail4(c);
|
|
5020
5941
|
try {
|
|
5021
5942
|
const result = await approveExternalEdit({
|
|
5022
5943
|
nestId,
|
|
@@ -5052,7 +5973,7 @@ governanceNodeRoutes.post(
|
|
|
5052
5973
|
if (!body.reason) {
|
|
5053
5974
|
throw new ValidationError("Rejection reason is required");
|
|
5054
5975
|
}
|
|
5055
|
-
const actor = await
|
|
5976
|
+
const actor = await getUserEmail4(c);
|
|
5056
5977
|
try {
|
|
5057
5978
|
const result = await rejectExternalEdit({
|
|
5058
5979
|
nestId,
|
|
@@ -5073,7 +5994,7 @@ governanceNodeRoutes.post(
|
|
|
5073
5994
|
governanceNodeRoutes.post("/:nodeId{.+}/cancel-review", async (c) => {
|
|
5074
5995
|
const nestId = c.req.param("nestId");
|
|
5075
5996
|
const nodeId = c.req.param("nodeId");
|
|
5076
|
-
const userEmail = await
|
|
5997
|
+
const userEmail = await getUserEmail4(c);
|
|
5077
5998
|
const request = await cancelReview({
|
|
5078
5999
|
nestId,
|
|
5079
6000
|
nodeId,
|
|
@@ -5081,7 +6002,7 @@ governanceNodeRoutes.post("/:nodeId{.+}/cancel-review", async (c) => {
|
|
|
5081
6002
|
});
|
|
5082
6003
|
return c.json({ review: request });
|
|
5083
6004
|
});
|
|
5084
|
-
async function
|
|
6005
|
+
async function getUserEmail4(c) {
|
|
5085
6006
|
const userId = c.get("userId");
|
|
5086
6007
|
const db = getDb();
|
|
5087
6008
|
const user = await db.get(
|
|
@@ -5091,6 +6012,30 @@ async function getUserEmail3(c) {
|
|
|
5091
6012
|
return user?.email || "anonymous@localhost";
|
|
5092
6013
|
}
|
|
5093
6014
|
|
|
6015
|
+
// src/shared/node-id.ts
|
|
6016
|
+
function assertSafeNodeId(rawId) {
|
|
6017
|
+
let id = rawId;
|
|
6018
|
+
try {
|
|
6019
|
+
id = decodeURIComponent(rawId);
|
|
6020
|
+
} catch {
|
|
6021
|
+
}
|
|
6022
|
+
if (!id || id.length > 512) {
|
|
6023
|
+
throw new ValidationError("invalid node id");
|
|
6024
|
+
}
|
|
6025
|
+
if (id.includes("\0") || id.includes("\\")) {
|
|
6026
|
+
throw new ValidationError("invalid node id");
|
|
6027
|
+
}
|
|
6028
|
+
if (id.startsWith("/")) {
|
|
6029
|
+
throw new ValidationError("invalid node id (absolute path)");
|
|
6030
|
+
}
|
|
6031
|
+
for (const seg of id.split("/")) {
|
|
6032
|
+
if (seg === "" || seg === "." || seg === "..") {
|
|
6033
|
+
throw new ValidationError("invalid node id (path traversal)");
|
|
6034
|
+
}
|
|
6035
|
+
}
|
|
6036
|
+
return id;
|
|
6037
|
+
}
|
|
6038
|
+
|
|
5094
6039
|
// src/auth/anonymous.ts
|
|
5095
6040
|
import bcrypt from "bcryptjs";
|
|
5096
6041
|
async function ensureAnonymousUser() {
|
|
@@ -5130,6 +6075,7 @@ var openModeMiddleware = createMiddleware2(async (c, next) => {
|
|
|
5130
6075
|
function isPublicReadEligiblePath(method, path) {
|
|
5131
6076
|
if (method !== "GET") return false;
|
|
5132
6077
|
if (!/^\/nests\/[^/]+(\/.*)?$/.test(path)) return false;
|
|
6078
|
+
if (/^\/nests\/[^/]+\/stewards/.test(path)) return false;
|
|
5133
6079
|
return !/\/(collaborators|visibility|settings|mcp)/.test(path);
|
|
5134
6080
|
}
|
|
5135
6081
|
var flexAuthMiddleware = createMiddleware2(async (c, next) => {
|
|
@@ -5153,7 +6099,7 @@ var flexAuthMiddleware = createMiddleware2(async (c, next) => {
|
|
|
5153
6099
|
return c.json({ error: "Missing or invalid credentials" }, 401);
|
|
5154
6100
|
});
|
|
5155
6101
|
function createApp() {
|
|
5156
|
-
const app = new
|
|
6102
|
+
const app = new Hono10({ router: new LinearRouter() });
|
|
5157
6103
|
const corsOrigins = config.CORS_ORIGINS;
|
|
5158
6104
|
app.use(
|
|
5159
6105
|
"*",
|
|
@@ -5174,6 +6120,22 @@ function createApp() {
|
|
|
5174
6120
|
}
|
|
5175
6121
|
return next();
|
|
5176
6122
|
});
|
|
6123
|
+
app.use("*", async (c, next) => {
|
|
6124
|
+
const path = c.req.path;
|
|
6125
|
+
const traced = /^\/(nests|auth|admin|stats|license|index|mcp)(\/|$)/.test(path) && !path.endsWith("/mcp");
|
|
6126
|
+
const started = Date.now();
|
|
6127
|
+
await next();
|
|
6128
|
+
if (!traced) return;
|
|
6129
|
+
void logTraceEvent({
|
|
6130
|
+
kind: "api",
|
|
6131
|
+
method: c.req.method,
|
|
6132
|
+
path,
|
|
6133
|
+
nestId: /^\/nests\/([^/]+)/.exec(path)?.[1] ?? null,
|
|
6134
|
+
userId: c.get("userId") ?? null,
|
|
6135
|
+
status: c.res.status,
|
|
6136
|
+
durationMs: Date.now() - started
|
|
6137
|
+
});
|
|
6138
|
+
});
|
|
5177
6139
|
app.get(
|
|
5178
6140
|
"/health",
|
|
5179
6141
|
(c) => c.json({
|
|
@@ -5273,7 +6235,8 @@ function createApp() {
|
|
|
5273
6235
|
logo_url: config.LOGO_URL,
|
|
5274
6236
|
telemetry_enabled: config.TELEMETRY_ENABLED,
|
|
5275
6237
|
public_base_url: config.PUBLIC_BASE_URL,
|
|
5276
|
-
max_body_bytes: config.MAX_BODY_BYTES
|
|
6238
|
+
max_body_bytes: config.MAX_BODY_BYTES,
|
|
6239
|
+
slack_webhook_url: config.SLACK_WEBHOOK_URL ?? ""
|
|
5277
6240
|
});
|
|
5278
6241
|
app.get("/admin/settings", async (c) => {
|
|
5279
6242
|
if (!await adminSettingsAllowed(c))
|
|
@@ -5308,6 +6271,12 @@ function createApp() {
|
|
|
5308
6271
|
if ("telemetry_enabled" in body) {
|
|
5309
6272
|
pending.push({ name: "TELEMETRY_ENABLED", value: body.telemetry_enabled ? "true" : "false" });
|
|
5310
6273
|
}
|
|
6274
|
+
if ("slack_webhook_url" in body) {
|
|
6275
|
+
const v = String(body.slack_webhook_url ?? "").trim();
|
|
6276
|
+
if (v && !/^https:\/\//i.test(v))
|
|
6277
|
+
errors.push("slack_webhook_url must be an https:// URL (or empty to disable)");
|
|
6278
|
+
else pending.push({ name: "SLACK_WEBHOOK_URL", value: v || null });
|
|
6279
|
+
}
|
|
5311
6280
|
if ("max_body_bytes" in body) {
|
|
5312
6281
|
const n = Number(body.max_body_bytes);
|
|
5313
6282
|
if (!Number.isFinite(n) || n < 1024 * 1024 || n > 500 * 1024 * 1024)
|
|
@@ -5333,6 +6302,29 @@ function createApp() {
|
|
|
5333
6302
|
}
|
|
5334
6303
|
return c.json({ settings: currentServerSettings() });
|
|
5335
6304
|
});
|
|
6305
|
+
app.use("/admin/trace", flexAuthMiddleware);
|
|
6306
|
+
const traceAllowed = async (c) => {
|
|
6307
|
+
if (config.AUTH_MODE === "open") return true;
|
|
6308
|
+
const userId = c.get("userId");
|
|
6309
|
+
if (await isLicenseAdminUserId(userId)) return true;
|
|
6310
|
+
return isSuperAdmin(await resolveCallerEmail(userId));
|
|
6311
|
+
};
|
|
6312
|
+
app.get("/admin/trace", async (c) => {
|
|
6313
|
+
if (!await traceAllowed(c))
|
|
6314
|
+
return c.json({ error: "Only the server admin can view this." }, 403);
|
|
6315
|
+
const limitRaw = Number(c.req.query("limit"));
|
|
6316
|
+
const offsetRaw = Number(c.req.query("offset"));
|
|
6317
|
+
const limit = Number.isFinite(limitRaw) ? limitRaw : 25;
|
|
6318
|
+
const offset = Number.isFinite(offsetRaw) ? offsetRaw : 0;
|
|
6319
|
+
const { events, total } = await listTraceEvents({
|
|
6320
|
+
limit,
|
|
6321
|
+
offset,
|
|
6322
|
+
kind: c.req.query("kind"),
|
|
6323
|
+
nestId: c.req.query("nest") || void 0,
|
|
6324
|
+
user: c.req.query("user") || void 0
|
|
6325
|
+
});
|
|
6326
|
+
return c.json({ count: events.length, total, limit, offset, events });
|
|
6327
|
+
});
|
|
5336
6328
|
app.use("/stats", flexAuthMiddleware);
|
|
5337
6329
|
app.get("/stats", async (c) => {
|
|
5338
6330
|
const db = getDb();
|
|
@@ -5359,7 +6351,12 @@ function createApp() {
|
|
|
5359
6351
|
users: usersRow.c
|
|
5360
6352
|
});
|
|
5361
6353
|
});
|
|
5362
|
-
|
|
6354
|
+
app.route("/llms.txt", llmsTxtRoutes);
|
|
6355
|
+
app.use("/index", flexAuthMiddleware);
|
|
6356
|
+
app.route("/index", nestIndexRoutes);
|
|
6357
|
+
app.use("/mcp", flexAuthMiddleware);
|
|
6358
|
+
app.route("/mcp", serverMcpRoutes);
|
|
6359
|
+
const nestsApp = new Hono10();
|
|
5363
6360
|
nestsApp.use("*", flexAuthMiddleware);
|
|
5364
6361
|
nestsApp.use("*", async (c, next) => {
|
|
5365
6362
|
const localPath = c.req.path.replace(/^\/nests\//, "");
|
|
@@ -5416,10 +6413,10 @@ function createApp() {
|
|
|
5416
6413
|
const isAnnotationAction = /\/annotations$/.test(path) || /\/annotations\/[^/]+\/(comments|resolve|reopen)$/.test(path);
|
|
5417
6414
|
const isCommentAction = /\/comments$/.test(path) || /\/comments\/[^/]+\/resolve$/.test(path);
|
|
5418
6415
|
const isStewardRoster = path.includes("/stewards") && !path.includes("/nodes/");
|
|
5419
|
-
if (isStewardRoster &&
|
|
6416
|
+
if (isStewardRoster && permission !== "owner" && permission !== "admin") {
|
|
5420
6417
|
return c.json(
|
|
5421
6418
|
{
|
|
5422
|
-
error: "You don't have permission to manage stewards. Only the
|
|
6419
|
+
error: "You don't have permission to manage stewards. Only a nest admin, the nest owner, or the server admin can do this."
|
|
5423
6420
|
},
|
|
5424
6421
|
403
|
|
5425
6422
|
);
|
|
@@ -5468,10 +6465,14 @@ function createApp() {
|
|
|
5468
6465
|
c.set("nestPermission", permission);
|
|
5469
6466
|
return next();
|
|
5470
6467
|
});
|
|
6468
|
+
nestsApp.use("/:nestId/nodes/:nodeId{.+}", async (c, next) => {
|
|
6469
|
+
assertSafeNodeId(c.req.param("nodeId"));
|
|
6470
|
+
return next();
|
|
6471
|
+
});
|
|
5471
6472
|
nestsApp.route("/", nestRoutes);
|
|
5472
6473
|
nestsApp.route("/:nestId", governanceRoutes);
|
|
5473
|
-
nestsApp.route("/:nestId/nodes", governanceNodeRoutes);
|
|
5474
6474
|
nestsApp.route("/:nestId/nodes", annotationRoutes);
|
|
6475
|
+
nestsApp.route("/:nestId/nodes", governanceNodeRoutes);
|
|
5475
6476
|
nestsApp.route("/:nestId/nodes", nodeRoutes);
|
|
5476
6477
|
nestsApp.route("/:nestId", queryRoutes);
|
|
5477
6478
|
nestsApp.route("/:nestId", sharingRoutes);
|