@promptowl/contextnest-community 1.16.1 → 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONFIGURATION.md +415 -415
- package/LICENSE.md +142 -142
- package/README.md +168 -169
- package/dist/{chunk-D6HICFVW.js → chunk-34UXRZXD.js} +59 -14
- package/dist/chunk-F66VCBLA.js +136 -0
- package/dist/{chunk-L7UCMGWZ.js → chunk-FUUJG6XP.js} +641 -285
- package/dist/{chunk-QJVRZT6M.js → chunk-HGN45I5P.js} +59 -21
- package/dist/{chunk-JMJLNEXE.js → chunk-HYSTFMFG.js} +43 -2
- package/dist/chunk-OOBZG6BW.js +379 -0
- package/dist/{chunk-OTGZQZKK.js → chunk-QUZXRLUA.js} +11 -10
- package/dist/{chunk-CLKZZZS2.js → chunk-TQACHVVA.js} +35 -4
- package/dist/{chunk-SLTQACJW.js → chunk-YB3LKF7U.js} +3 -1
- package/dist/{client-OBS5HOTA.js → client-SW5I6ZBK.js} +2 -2
- package/dist/engine-34E3YNX7.js +14 -0
- package/dist/external-edit-service-6Q6SHEOF.js +33 -0
- package/dist/{grants-service-I535QVTW.js → grants-service-ME7HIVHU.js} +5 -5
- package/dist/index.js +700 -526
- package/dist/{keys-73STFJJB.js → keys-CTOGAG3W.js} +5 -1
- package/dist/{migrations.postgres-CYKO5CFV.js → migrations.postgres-5VI4RDTS.js} +41 -4
- package/dist/{review-service-V2GR6VM3.js → review-service-H2M5ACPQ.js} +11 -9
- package/dist/{stewardship-service-LTREGVSJ.js → stewardship-service-U4CKL7Y5.js} +8 -4
- package/dist/{version-service-V6K5SZ3P.js → version-service-JWEN5UUW.js} +6 -4
- package/dist/web3/assets/{hootie-C2ocYkn4.svg → hootie-_U3ECslt.svg} +8 -8
- package/dist/web3/assets/index-DONv5_Ni.css +1 -0
- package/dist/web3/assets/index-jSGrnboh.js +1065 -0
- package/dist/web3/index.html +14 -14
- package/package.json +165 -165
- package/dist/chunk-XRK6SQSC.js +0 -58
- package/dist/web3/assets/index-DnQARUL3.css +0 -1
- package/dist/web3/assets/index-_m7WyozM.js +0 -1051
|
@@ -1,20 +1,22 @@
|
|
|
1
|
+
import {
|
|
2
|
+
canUserAccess,
|
|
3
|
+
canUserApprove,
|
|
4
|
+
resolveNestWideRoles,
|
|
5
|
+
resolveStewardsForNode,
|
|
6
|
+
stewardCoverageForUser
|
|
7
|
+
} from "./chunk-TQACHVVA.js";
|
|
1
8
|
import {
|
|
2
9
|
grantCoversNode,
|
|
3
10
|
listUserGrants,
|
|
4
11
|
resolveNodeGrant
|
|
5
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-QUZXRLUA.js";
|
|
6
13
|
import {
|
|
7
14
|
createVersion,
|
|
8
15
|
getApprovedVersion,
|
|
9
16
|
getApprovedVersions,
|
|
10
17
|
getCurrentVersion,
|
|
11
18
|
setApprovedVersion
|
|
12
|
-
} from "./chunk-
|
|
13
|
-
import {
|
|
14
|
-
canUserAccess,
|
|
15
|
-
canUserApprove,
|
|
16
|
-
resolveStewardsForNode
|
|
17
|
-
} from "./chunk-CLKZZZS2.js";
|
|
19
|
+
} from "./chunk-34UXRZXD.js";
|
|
18
20
|
import {
|
|
19
21
|
buildDocContext,
|
|
20
22
|
buildTitleMap,
|
|
@@ -28,10 +30,12 @@ import {
|
|
|
28
30
|
notifyEmailForNest,
|
|
29
31
|
nowExpr,
|
|
30
32
|
opContext,
|
|
33
|
+
permissionLabel,
|
|
31
34
|
prettyFolderPath,
|
|
35
|
+
resolveNestPermission,
|
|
32
36
|
sendEmailToRecipient,
|
|
33
37
|
titleForNode
|
|
34
|
-
} from "./chunk-
|
|
38
|
+
} from "./chunk-FUUJG6XP.js";
|
|
35
39
|
import {
|
|
36
40
|
ConflictError,
|
|
37
41
|
NotFoundError,
|
|
@@ -40,7 +44,7 @@ import {
|
|
|
40
44
|
import {
|
|
41
45
|
config,
|
|
42
46
|
getDb
|
|
43
|
-
} from "./chunk-
|
|
47
|
+
} from "./chunk-HYSTFMFG.js";
|
|
44
48
|
|
|
45
49
|
// src/governance/review-service.ts
|
|
46
50
|
import { v4 as uuid3 } from "uuid";
|
|
@@ -168,7 +172,7 @@ async function notifyNestInvite(params) {
|
|
|
168
172
|
nestId,
|
|
169
173
|
inviteeEmail.toLowerCase(),
|
|
170
174
|
nestId,
|
|
171
|
-
`You were added to "${nestName2}" as ${permission}`,
|
|
175
|
+
`You were added to "${nestName2}" as ${permissionLabel(permission)}`,
|
|
172
176
|
(/* @__PURE__ */ new Date()).toISOString()
|
|
173
177
|
]
|
|
174
178
|
);
|
|
@@ -179,10 +183,16 @@ async function notifyNestInvite(params) {
|
|
|
179
183
|
async function listNotifications(userEmail, opts = {}) {
|
|
180
184
|
const limit = Math.min(opts.limit ?? 50, 200);
|
|
181
185
|
return await getDb().all(
|
|
182
|
-
`SELECT id, nest_id, kind, subject_id, message, created_at, read_at
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
+
`SELECT n.id, n.nest_id, n.kind, n.subject_id, n.message, n.created_at, n.read_at,
|
|
187
|
+
COALESCE(
|
|
188
|
+
rr.node_id,
|
|
189
|
+
CASE WHEN dr.target_type = 'document' THEN dr.node_id END
|
|
190
|
+
) AS node_id
|
|
191
|
+
FROM notifications n
|
|
192
|
+
LEFT JOIN review_requests rr ON rr.id = n.subject_id
|
|
193
|
+
LEFT JOIN deletion_requests dr ON dr.id = n.subject_id
|
|
194
|
+
WHERE LOWER(n.user_email) = LOWER(?)${opts.unreadOnly ? " AND n.read_at IS NULL" : ""}
|
|
195
|
+
ORDER BY n.created_at DESC LIMIT ${limit}`,
|
|
186
196
|
[userEmail]
|
|
187
197
|
);
|
|
188
198
|
}
|
|
@@ -225,20 +235,37 @@ async function canReadNode(nestId, nodeId, userId, userEmail) {
|
|
|
225
235
|
if ((await canUserAccess(nestId, nodeId, userEmail)).allowed) return true;
|
|
226
236
|
return await resolveNodeGrant(nestId, userId, nodeId) !== null;
|
|
227
237
|
}
|
|
238
|
+
async function canRequestDeletion(params) {
|
|
239
|
+
const { nestId, userId, userEmail, nodeId } = params;
|
|
240
|
+
const permission = params.permission ?? await resolveNestPermission(nestId, userId);
|
|
241
|
+
if (permission === "owner" || permission === "admin" || permission === "write") {
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
const resolved = await resolveStewardsForNode(nestId, nodeId);
|
|
245
|
+
const isEditorSteward = resolved.some(
|
|
246
|
+
(r) => r.steward.userEmail.toLowerCase() === userEmail.toLowerCase() && r.steward.role === "editor" && (nodeId !== "" || r.steward.scope === "nest")
|
|
247
|
+
);
|
|
248
|
+
if (isEditorSteward) return true;
|
|
249
|
+
if (nodeId === "") return false;
|
|
250
|
+
return await resolveNodeGrant(nestId, userId, nodeId) === "write";
|
|
251
|
+
}
|
|
228
252
|
async function filterAccessible(nestId, userId, userEmail, nodes, approvedVersions) {
|
|
229
253
|
if (await isPublicReader(nestId, userId)) {
|
|
230
254
|
const approved = approvedVersions ?? await getApprovedVersions(nestId);
|
|
231
255
|
return nodes.filter((n) => approved.has(n.id));
|
|
232
256
|
}
|
|
233
|
-
if (!await isStewardshipEnabled(nestId)) return nodes;
|
|
234
257
|
const grants = await listUserGrants(nestId, userId);
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
if (
|
|
238
|
-
|
|
239
|
-
}
|
|
258
|
+
if (!await isStewardshipEnabled(nestId)) {
|
|
259
|
+
if (grants.length === 0) return nodes;
|
|
260
|
+
if (await resolveNestPermission(nestId, userId) !== "none") return nodes;
|
|
261
|
+
return nodes.filter((n) => grantCoversNode(grants, n.id));
|
|
240
262
|
}
|
|
241
|
-
return
|
|
263
|
+
if ((await resolveNestWideRoles(nestId, userEmail)).length > 0) return nodes;
|
|
264
|
+
const coverage = await stewardCoverageForUser(nestId, userEmail);
|
|
265
|
+
if (coverage.nestWide) return nodes;
|
|
266
|
+
return nodes.filter(
|
|
267
|
+
(n) => coverage.nodeIds.has(n.id) || grantCoversNode(grants, n.id)
|
|
268
|
+
);
|
|
242
269
|
}
|
|
243
270
|
|
|
244
271
|
// src/workflow/edge-type-routes.ts
|
|
@@ -1477,6 +1504,15 @@ async function listDeletionRequests(params) {
|
|
|
1477
1504
|
async function dropDeletionRequest(id) {
|
|
1478
1505
|
await getDb().run("DELETE FROM deletion_requests WHERE id = ?", [id]);
|
|
1479
1506
|
}
|
|
1507
|
+
async function withdrawDeletionRequest(params) {
|
|
1508
|
+
const db = getDb();
|
|
1509
|
+
const pending = await getDeletionRequest(params.id);
|
|
1510
|
+
if (!pending || pending.nestId !== params.nestId || pending.status !== "pending" || pending.requestedBy.toLowerCase() !== params.withdrawnBy.toLowerCase()) {
|
|
1511
|
+
return null;
|
|
1512
|
+
}
|
|
1513
|
+
await db.run("DELETE FROM deletion_requests WHERE id = ?", [params.id]);
|
|
1514
|
+
return pending;
|
|
1515
|
+
}
|
|
1480
1516
|
async function declineDeletion(params) {
|
|
1481
1517
|
const db = getDb();
|
|
1482
1518
|
const pending = await getDeletionRequest(params.id);
|
|
@@ -1556,6 +1592,7 @@ export {
|
|
|
1556
1592
|
markNotificationsRead,
|
|
1557
1593
|
resolveCallerEmail,
|
|
1558
1594
|
canReadNode,
|
|
1595
|
+
canRequestDeletion,
|
|
1559
1596
|
filterAccessible,
|
|
1560
1597
|
safeJson,
|
|
1561
1598
|
requireWorkflowPlane,
|
|
@@ -1586,5 +1623,6 @@ export {
|
|
|
1586
1623
|
getDeletionRequest,
|
|
1587
1624
|
listDeletionRequests,
|
|
1588
1625
|
dropDeletionRequest,
|
|
1626
|
+
withdrawDeletionRequest,
|
|
1589
1627
|
declineDeletion
|
|
1590
1628
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ANON_USER_ID
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-YB3LKF7U.js";
|
|
4
4
|
|
|
5
5
|
// src/db/client.ts
|
|
6
6
|
import Database from "better-sqlite3";
|
|
@@ -741,6 +741,9 @@ function runMigrations(db) {
|
|
|
741
741
|
if (!nestCols.includes("prime_tags")) {
|
|
742
742
|
db.exec("ALTER TABLE nests ADD COLUMN prime_tags TEXT NOT NULL DEFAULT 'prime-document'");
|
|
743
743
|
}
|
|
744
|
+
if (!nestCols.includes("index_synced_at")) {
|
|
745
|
+
db.exec("ALTER TABLE nests ADD COLUMN index_synced_at TEXT");
|
|
746
|
+
}
|
|
744
747
|
const userCols = db.prepare("PRAGMA table_info(users)").all().map((c) => c.name);
|
|
745
748
|
if (!userCols.includes("is_admin")) {
|
|
746
749
|
db.exec("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0");
|
|
@@ -1531,6 +1534,44 @@ function runMigrations(db) {
|
|
|
1531
1534
|
`);
|
|
1532
1535
|
recordMigration("034_versions_author_status_index");
|
|
1533
1536
|
}
|
|
1537
|
+
if (!hasMigration("035_multiple_api_keys_per_user")) {
|
|
1538
|
+
db.exec(`DROP INDEX IF EXISTS idx_api_keys_user_unique;`);
|
|
1539
|
+
recordMigration("035_multiple_api_keys_per_user");
|
|
1540
|
+
}
|
|
1541
|
+
if (!hasMigration("036_node_index")) {
|
|
1542
|
+
db.transaction(() => {
|
|
1543
|
+
db.exec(`
|
|
1544
|
+
CREATE TABLE IF NOT EXISTS node_index (
|
|
1545
|
+
nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
|
|
1546
|
+
node_id TEXT NOT NULL,
|
|
1547
|
+
folder TEXT NOT NULL DEFAULT '',
|
|
1548
|
+
title TEXT NOT NULL DEFAULT '',
|
|
1549
|
+
type TEXT NOT NULL DEFAULT 'document',
|
|
1550
|
+
status TEXT NOT NULL DEFAULT 'draft',
|
|
1551
|
+
updated_at TEXT,
|
|
1552
|
+
frontmatter_json TEXT NOT NULL,
|
|
1553
|
+
PRIMARY KEY (nest_id, node_id)
|
|
1554
|
+
);
|
|
1555
|
+
CREATE INDEX IF NOT EXISTS idx_node_index_folder
|
|
1556
|
+
ON node_index(nest_id, folder);
|
|
1557
|
+
|
|
1558
|
+
-- Which documents have an external edit staged against them.
|
|
1559
|
+
--
|
|
1560
|
+
-- Its own table rather than a node_index column: node_index rows are
|
|
1561
|
+
-- upserted with INSERT OR REPLACE on every document write, which
|
|
1562
|
+
-- resets any column the write does not carry \u2014 and a suggestion's
|
|
1563
|
+
-- existence has nothing to do with the document write that would
|
|
1564
|
+
-- clobber it. Kept in step at the three points that stage, approve or
|
|
1565
|
+
-- reject a suggestion, and refreshed wholesale by a rebuild.
|
|
1566
|
+
CREATE TABLE IF NOT EXISTS node_suggestions (
|
|
1567
|
+
nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
|
|
1568
|
+
node_id TEXT NOT NULL,
|
|
1569
|
+
PRIMARY KEY (nest_id, node_id)
|
|
1570
|
+
);
|
|
1571
|
+
`);
|
|
1572
|
+
})();
|
|
1573
|
+
recordMigration("036_node_index");
|
|
1574
|
+
}
|
|
1534
1575
|
}
|
|
1535
1576
|
function mergeCaseCollidingUsers(db) {
|
|
1536
1577
|
const groups = db.prepare(
|
|
@@ -1748,7 +1789,7 @@ async function initDb() {
|
|
|
1748
1789
|
if (config.DB_DRIVER === "postgres") {
|
|
1749
1790
|
const { Pool } = await import("pg");
|
|
1750
1791
|
const { PostgresAdapter } = await import("./adapter.postgres-YOODX2BI.js");
|
|
1751
|
-
const { runPostgresMigrations } = await import("./migrations.postgres-
|
|
1792
|
+
const { runPostgresMigrations } = await import("./migrations.postgres-5VI4RDTS.js");
|
|
1752
1793
|
const pool = new Pool(buildPgConfig());
|
|
1753
1794
|
adapter = new PostgresAdapter(pool);
|
|
1754
1795
|
await runPostgresMigrations(adapter);
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import {
|
|
2
|
+
engineCache,
|
|
3
|
+
indexedNodeIds,
|
|
4
|
+
markIndexStale,
|
|
5
|
+
resolveNestPath,
|
|
6
|
+
setSuggestionFlag
|
|
7
|
+
} from "./chunk-FUUJG6XP.js";
|
|
8
|
+
import {
|
|
9
|
+
getDb
|
|
10
|
+
} from "./chunk-HYSTFMFG.js";
|
|
11
|
+
|
|
12
|
+
// src/governance/external-edit-service.ts
|
|
13
|
+
import { readFile, readdir } from "fs/promises";
|
|
14
|
+
import { join, relative, sep } from "path";
|
|
15
|
+
import {
|
|
16
|
+
detectDrift,
|
|
17
|
+
stageSuggestion,
|
|
18
|
+
ChainEventLog,
|
|
19
|
+
approveSuggestion,
|
|
20
|
+
rejectSuggestion,
|
|
21
|
+
listSuggestions,
|
|
22
|
+
readSuggestion,
|
|
23
|
+
parseDocument,
|
|
24
|
+
VersionManager,
|
|
25
|
+
computeContentHash,
|
|
26
|
+
getChecksumContent
|
|
27
|
+
} from "@promptowl/contextnest-engine";
|
|
28
|
+
var communityRbac = {
|
|
29
|
+
isCzar: () => false,
|
|
30
|
+
canIngest: () => true,
|
|
31
|
+
isDocOwner: () => true
|
|
32
|
+
};
|
|
33
|
+
function docPath(nestId, documentId) {
|
|
34
|
+
return join(resolveNestPath(nestId), `${documentId}.md`);
|
|
35
|
+
}
|
|
36
|
+
async function readRaw(nestId, documentId) {
|
|
37
|
+
try {
|
|
38
|
+
return await readFile(docPath(nestId, documentId), "utf-8");
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async function loadChainHead(storage, documentId) {
|
|
44
|
+
const history = await storage.readHistory(documentId);
|
|
45
|
+
if (!history || history.versions.length === 0) return null;
|
|
46
|
+
const latest = history.versions[history.versions.length - 1];
|
|
47
|
+
try {
|
|
48
|
+
const content = await new VersionManager(storage).reconstructVersion(
|
|
49
|
+
documentId,
|
|
50
|
+
latest.version
|
|
51
|
+
);
|
|
52
|
+
return { version: latest.version, content };
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function loadLatestApprovedNode(nestId, documentId) {
|
|
58
|
+
const { storage } = await engineCache.get(nestId);
|
|
59
|
+
const head = await loadChainHead(storage, documentId);
|
|
60
|
+
if (!head) return null;
|
|
61
|
+
return parseDocument(docPath(nestId, documentId), head.content, documentId);
|
|
62
|
+
}
|
|
63
|
+
async function bodyMatchesLatestVersion(storage, documentId, liveBodyHash) {
|
|
64
|
+
const head = await loadChainHead(storage, documentId);
|
|
65
|
+
if (!head) return false;
|
|
66
|
+
const headBodyHash = computeContentHash(getChecksumContent(head.content));
|
|
67
|
+
return headBodyHash === liveBodyHash;
|
|
68
|
+
}
|
|
69
|
+
async function scanDocumentForDrift(nestId, documentId, actor = "system:scanner") {
|
|
70
|
+
const res = await scanDocumentForDriftInternal(nestId, documentId, actor);
|
|
71
|
+
return res?.meta ?? null;
|
|
72
|
+
}
|
|
73
|
+
async function scanDocumentForDriftInternal(nestId, documentId, actor) {
|
|
74
|
+
const { storage } = await engineCache.get(nestId);
|
|
75
|
+
const node = await storage.readDocument(documentId).catch(() => null);
|
|
76
|
+
if (!node) return null;
|
|
77
|
+
const raw = await readRaw(nestId, documentId);
|
|
78
|
+
if (raw == null) return null;
|
|
79
|
+
const drift = detectDrift(raw, node.frontmatter.checksum);
|
|
80
|
+
if (!drift.drifted) return null;
|
|
81
|
+
if (await bodyMatchesLatestVersion(storage, documentId, drift.actualHash)) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const approved = await loadChainHead(storage, documentId);
|
|
85
|
+
if (!approved) return null;
|
|
86
|
+
const existing = await listSuggestions(storage, documentId);
|
|
87
|
+
const dup = existing.find((s) => s.proposed_hash === drift.actualHash);
|
|
88
|
+
if (dup) {
|
|
89
|
+
await setSuggestionFlag(nestId, documentId, true);
|
|
90
|
+
return { meta: dup, created: false };
|
|
91
|
+
}
|
|
92
|
+
const result = await stageSuggestion({
|
|
93
|
+
storage,
|
|
94
|
+
documentId,
|
|
95
|
+
approvedRawContent: approved.content,
|
|
96
|
+
proposedRawContent: raw,
|
|
97
|
+
source: "out-of-band-edit",
|
|
98
|
+
actor,
|
|
99
|
+
docTier: "standard"
|
|
100
|
+
});
|
|
101
|
+
await setSuggestionFlag(nestId, documentId, true);
|
|
102
|
+
return { meta: result.meta, created: true };
|
|
103
|
+
}
|
|
104
|
+
async function refreshSuggestionFlag(nestId, storage, documentId) {
|
|
105
|
+
try {
|
|
106
|
+
const remaining = await listSuggestions(storage, documentId);
|
|
107
|
+
await setSuggestionFlag(nestId, documentId, remaining.length > 0);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
console.error("[external-edit] flag refresh failed", nestId, documentId, err);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async function scanNestForDrift(nestId, actor = "system:scanner") {
|
|
113
|
+
const { storage, dropDiscoveryCache } = await engineCache.get(nestId);
|
|
114
|
+
dropDiscoveryCache();
|
|
115
|
+
const docs = await storage.discoverDocuments();
|
|
116
|
+
const results = await Promise.all(
|
|
117
|
+
docs.map((doc) => scanDocumentForDriftInternal(nestId, doc.id, actor))
|
|
118
|
+
);
|
|
119
|
+
const staged = results.filter((r) => r?.created).length;
|
|
120
|
+
const onDisk = await storage.discoverDocuments({ includeRetired: true });
|
|
121
|
+
const indexed = await indexedNodeIds(nestId);
|
|
122
|
+
const membershipMoved = indexed.size !== onDisk.length || onDisk.some((d) => !indexed.has(d.id));
|
|
123
|
+
if (staged > 0 || membershipMoved) await markIndexStale(nestId);
|
|
124
|
+
return { scanned: docs.length, staged };
|
|
125
|
+
}
|
|
126
|
+
async function documentsWithSuggestions(nestId) {
|
|
127
|
+
const root = resolveNestPath(nestId);
|
|
128
|
+
const ids = /* @__PURE__ */ new Set();
|
|
129
|
+
let entries;
|
|
130
|
+
try {
|
|
131
|
+
entries = await readdir(root, { recursive: true, withFileTypes: true });
|
|
132
|
+
} catch (err) {
|
|
133
|
+
if (err?.code !== "ENOENT") {
|
|
134
|
+
console.error("[external-edit] suggestion scan failed", nestId, err);
|
|
135
|
+
}
|
|
136
|
+
return ids;
|
|
137
|
+
}
|
|
138
|
+
for (const e of entries) {
|
|
139
|
+
if (!e.isFile() || !e.name.endsWith(".meta.yaml")) continue;
|
|
140
|
+
const dir = e.parentPath ?? e.path ?? root;
|
|
141
|
+
const rel = relative(root, dir).split(sep).join("/");
|
|
142
|
+
const segs = rel.split("/");
|
|
143
|
+
const marker = segs.lastIndexOf("_suggestions");
|
|
144
|
+
if (marker === -1 || segs.length !== marker + 2) continue;
|
|
145
|
+
ids.add([...segs.slice(0, marker), segs[marker + 1]].filter(Boolean).join("/"));
|
|
146
|
+
}
|
|
147
|
+
return ids;
|
|
148
|
+
}
|
|
149
|
+
async function getPendingChange(nestId, documentId) {
|
|
150
|
+
const { storage } = await engineCache.get(nestId);
|
|
151
|
+
const list = await listSuggestions(storage, documentId);
|
|
152
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
153
|
+
const meta = list[i];
|
|
154
|
+
if (await bodyMatchesLatestVersion(storage, documentId, meta.proposed_hash)) {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
suggestion_id: meta.suggestion_id,
|
|
159
|
+
detected_at: meta.detected_at,
|
|
160
|
+
source: meta.source,
|
|
161
|
+
proposed_hash: meta.proposed_hash
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
async function listNestExternalEdits(nestId) {
|
|
167
|
+
const { storage } = await engineCache.get(nestId);
|
|
168
|
+
const docs = await storage.discoverDocuments();
|
|
169
|
+
const lists = await Promise.all(
|
|
170
|
+
docs.map(async (doc) => {
|
|
171
|
+
const metas = await listSuggestions(storage, doc.id);
|
|
172
|
+
const fresh = [];
|
|
173
|
+
for (const meta of metas) {
|
|
174
|
+
if (await bodyMatchesLatestVersion(storage, doc.id, meta.proposed_hash)) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
fresh.push(meta);
|
|
178
|
+
}
|
|
179
|
+
return fresh;
|
|
180
|
+
})
|
|
181
|
+
);
|
|
182
|
+
const entries = lists.flat().map((meta) => ({
|
|
183
|
+
suggestion_id: meta.suggestion_id,
|
|
184
|
+
nest_id: nestId,
|
|
185
|
+
document_id: meta.document_id,
|
|
186
|
+
source: meta.source,
|
|
187
|
+
detected_at: meta.detected_at,
|
|
188
|
+
actor: meta.actor,
|
|
189
|
+
target_hash: meta.target_hash,
|
|
190
|
+
proposed_hash: meta.proposed_hash,
|
|
191
|
+
note: meta.note
|
|
192
|
+
}));
|
|
193
|
+
return entries.sort((a, b) => b.detected_at.localeCompare(a.detected_at));
|
|
194
|
+
}
|
|
195
|
+
async function getExternalEditDetail(nestId, documentId, suggestionId) {
|
|
196
|
+
const { storage } = await engineCache.get(nestId);
|
|
197
|
+
const found = await readSuggestion(storage, documentId, suggestionId);
|
|
198
|
+
if (!found) return null;
|
|
199
|
+
return {
|
|
200
|
+
suggestion_id: found.meta.suggestion_id,
|
|
201
|
+
nest_id: nestId,
|
|
202
|
+
document_id: found.meta.document_id,
|
|
203
|
+
source: found.meta.source,
|
|
204
|
+
detected_at: found.meta.detected_at,
|
|
205
|
+
actor: found.meta.actor,
|
|
206
|
+
target_hash: found.meta.target_hash,
|
|
207
|
+
proposed_hash: found.meta.proposed_hash,
|
|
208
|
+
note: found.meta.note,
|
|
209
|
+
patch: found.patch
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
async function recordVerdict(nestId, event) {
|
|
213
|
+
try {
|
|
214
|
+
const { storage } = await engineCache.get(nestId);
|
|
215
|
+
await new ChainEventLog(storage).append(event);
|
|
216
|
+
return true;
|
|
217
|
+
} catch (err) {
|
|
218
|
+
console.error(
|
|
219
|
+
`[external-edit] failed to record verdict ${event.event_id} for nest ${nestId}:`,
|
|
220
|
+
err
|
|
221
|
+
);
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
async function listExternalEditVerdicts(nestId, documentId) {
|
|
226
|
+
try {
|
|
227
|
+
const { storage } = await engineCache.get(nestId);
|
|
228
|
+
const events = await new ChainEventLog(storage).readByDocument(documentId);
|
|
229
|
+
return events.flatMap((e) => {
|
|
230
|
+
const meta = e.action_metadata || {};
|
|
231
|
+
if (typeof meta.suggestion_id !== "string") return [];
|
|
232
|
+
const reason = meta.rejection_reason;
|
|
233
|
+
const comment = meta.approval_comment;
|
|
234
|
+
return [
|
|
235
|
+
{
|
|
236
|
+
suggestion_id: meta.suggestion_id,
|
|
237
|
+
status: typeof reason === "string" ? "rejected" : "approved",
|
|
238
|
+
actor: e.actor,
|
|
239
|
+
at: e.timestamp,
|
|
240
|
+
note: typeof reason === "string" ? reason : typeof comment === "string" ? comment : void 0,
|
|
241
|
+
source: typeof meta.source === "string" ? meta.source : void 0
|
|
242
|
+
}
|
|
243
|
+
];
|
|
244
|
+
});
|
|
245
|
+
} catch (err) {
|
|
246
|
+
console.error(
|
|
247
|
+
`[external-edit] failed to read verdicts for ${nestId}/${documentId}:`,
|
|
248
|
+
err
|
|
249
|
+
);
|
|
250
|
+
return [];
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
async function mirrorVersion(input) {
|
|
254
|
+
const { storage } = await engineCache.get(input.nestId);
|
|
255
|
+
const node = await storage.readDocument(input.documentId);
|
|
256
|
+
const { upsertVersion, setApprovedVersion } = await import("./version-service-JWEN5UUW.js");
|
|
257
|
+
await upsertVersion({
|
|
258
|
+
nestId: input.nestId,
|
|
259
|
+
nodeId: input.documentId,
|
|
260
|
+
version: input.version,
|
|
261
|
+
content: node.body || "",
|
|
262
|
+
author: input.actor,
|
|
263
|
+
status: "published",
|
|
264
|
+
tags: node.frontmatter.tags || [],
|
|
265
|
+
changeNote: input.note
|
|
266
|
+
});
|
|
267
|
+
await setApprovedVersion(
|
|
268
|
+
input.nestId,
|
|
269
|
+
input.documentId,
|
|
270
|
+
input.version,
|
|
271
|
+
input.actor
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
async function approveExternalEdit(input) {
|
|
275
|
+
const { storage } = await engineCache.get(input.nestId);
|
|
276
|
+
let result;
|
|
277
|
+
try {
|
|
278
|
+
result = await approveSuggestion({
|
|
279
|
+
storage,
|
|
280
|
+
rbac: communityRbac,
|
|
281
|
+
documentId: input.documentId,
|
|
282
|
+
suggestionId: input.suggestionId,
|
|
283
|
+
actor: input.actor,
|
|
284
|
+
zone: "default",
|
|
285
|
+
comment: input.comment
|
|
286
|
+
});
|
|
287
|
+
} catch (err) {
|
|
288
|
+
console.error(
|
|
289
|
+
`[external-edit] approveSuggestion failed for ${input.nestId}/${input.documentId} suggestion=${input.suggestionId}:`,
|
|
290
|
+
err
|
|
291
|
+
);
|
|
292
|
+
throw err;
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
await mirrorVersion({
|
|
296
|
+
nestId: input.nestId,
|
|
297
|
+
documentId: input.documentId,
|
|
298
|
+
version: result.versionEntry.version,
|
|
299
|
+
actor: input.actor,
|
|
300
|
+
note: input.comment ? `External edit approved: ${input.comment}` : "External edit approved"
|
|
301
|
+
});
|
|
302
|
+
} catch (err) {
|
|
303
|
+
console.error(
|
|
304
|
+
`[external-edit] failed to mirror approved version into node_versions for ${input.nestId}/${input.documentId}:`,
|
|
305
|
+
err
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
const historyRecorded = await recordVerdict(input.nestId, result.chainEvent);
|
|
309
|
+
await refreshSuggestionFlag(input.nestId, storage, input.documentId);
|
|
310
|
+
return { ...result, historyRecorded };
|
|
311
|
+
}
|
|
312
|
+
async function rejectExternalEdit(input) {
|
|
313
|
+
const { storage } = await engineCache.get(input.nestId);
|
|
314
|
+
const result = await rejectSuggestion({
|
|
315
|
+
storage,
|
|
316
|
+
rbac: communityRbac,
|
|
317
|
+
documentId: input.documentId,
|
|
318
|
+
suggestionId: input.suggestionId,
|
|
319
|
+
actor: input.actor,
|
|
320
|
+
zone: "default",
|
|
321
|
+
reason: input.reason
|
|
322
|
+
});
|
|
323
|
+
const historyRecorded = await recordVerdict(input.nestId, result.chainEvent);
|
|
324
|
+
try {
|
|
325
|
+
const approved = await loadChainHead(storage, input.documentId);
|
|
326
|
+
if (approved) {
|
|
327
|
+
await storage.writeDocument(input.documentId, approved.content);
|
|
328
|
+
}
|
|
329
|
+
} catch (err) {
|
|
330
|
+
console.error(
|
|
331
|
+
`[external-edit] revert-on-reject failed for ${input.nestId}/${input.documentId}:`,
|
|
332
|
+
err
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
await refreshSuggestionFlag(input.nestId, storage, input.documentId);
|
|
336
|
+
return { ...result, historyRecorded };
|
|
337
|
+
}
|
|
338
|
+
var scannerTimer = null;
|
|
339
|
+
async function scanAllNests() {
|
|
340
|
+
const db = getDb();
|
|
341
|
+
const rows = await db.all("SELECT id FROM nests");
|
|
342
|
+
await Promise.all(
|
|
343
|
+
rows.map(
|
|
344
|
+
({ id }) => scanNestForDrift(id).catch(
|
|
345
|
+
(err) => console.error(`[external-edit] scan failed for nest ${id}:`, err)
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
function startDriftScanner(intervalMs = 3e4) {
|
|
351
|
+
if (scannerTimer) return;
|
|
352
|
+
scannerTimer = setInterval(() => {
|
|
353
|
+
scanAllNests().catch(
|
|
354
|
+
(err) => console.error("[external-edit] scanner tick failed:", err)
|
|
355
|
+
);
|
|
356
|
+
}, intervalMs);
|
|
357
|
+
scannerTimer.unref?.();
|
|
358
|
+
}
|
|
359
|
+
function stopDriftScanner() {
|
|
360
|
+
if (scannerTimer) {
|
|
361
|
+
clearInterval(scannerTimer);
|
|
362
|
+
scannerTimer = null;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export {
|
|
367
|
+
loadLatestApprovedNode,
|
|
368
|
+
scanDocumentForDrift,
|
|
369
|
+
scanNestForDrift,
|
|
370
|
+
documentsWithSuggestions,
|
|
371
|
+
getPendingChange,
|
|
372
|
+
listNestExternalEdits,
|
|
373
|
+
getExternalEditDetail,
|
|
374
|
+
listExternalEditVerdicts,
|
|
375
|
+
approveExternalEdit,
|
|
376
|
+
rejectExternalEdit,
|
|
377
|
+
startDriftScanner,
|
|
378
|
+
stopDriftScanner
|
|
379
|
+
};
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
} from "./chunk-GUNJTORH.js";
|
|
4
4
|
import {
|
|
5
5
|
getDb
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-HYSTFMFG.js";
|
|
7
7
|
|
|
8
8
|
// src/governance/grants-service.ts
|
|
9
9
|
import { v4 as uuid } from "uuid";
|
|
@@ -22,14 +22,6 @@ async function resolveNodeGrant(nestId, userId, nodeId) {
|
|
|
22
22
|
}
|
|
23
23
|
return best;
|
|
24
24
|
}
|
|
25
|
-
async function hasAnyGrant(nestId, userId) {
|
|
26
|
-
if (!userId) return false;
|
|
27
|
-
const row = await getDb().get(
|
|
28
|
-
"SELECT 1 AS x FROM grants WHERE nest_id = ? AND user_id = ? LIMIT 1",
|
|
29
|
-
[nestId, userId]
|
|
30
|
-
);
|
|
31
|
-
return !!row;
|
|
32
|
-
}
|
|
33
25
|
async function listUserGrants(nestId, userId) {
|
|
34
26
|
if (!userId) return [];
|
|
35
27
|
return await getDb().all(
|
|
@@ -42,6 +34,15 @@ function grantCoversNode(grants, nodeId) {
|
|
|
42
34
|
(g) => nodeId === g.target || nodeId.startsWith(g.target + "/")
|
|
43
35
|
);
|
|
44
36
|
}
|
|
37
|
+
function grantRoleForNode(grants, nodeId) {
|
|
38
|
+
let best = null;
|
|
39
|
+
for (const g of grants) {
|
|
40
|
+
if (nodeId === g.target || nodeId.startsWith(g.target + "/")) {
|
|
41
|
+
if (!best || rank(g.role) > rank(best)) best = g.role;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return best;
|
|
45
|
+
}
|
|
45
46
|
async function createGrant(params) {
|
|
46
47
|
const { nestId, targetType, target, userId, role, grantedBy } = params;
|
|
47
48
|
if (!["document", "folder"].includes(targetType)) {
|
|
@@ -94,9 +95,9 @@ async function deleteGrant(nestId, id) {
|
|
|
94
95
|
|
|
95
96
|
export {
|
|
96
97
|
resolveNodeGrant,
|
|
97
|
-
hasAnyGrant,
|
|
98
98
|
listUserGrants,
|
|
99
99
|
grantCoversNode,
|
|
100
|
+
grantRoleForNode,
|
|
100
101
|
createGrant,
|
|
101
102
|
listGrants,
|
|
102
103
|
deleteGrant
|