@promptowl/contextnest-community 1.14.0 → 1.15.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 +14 -0
- package/dist/{chunk-IHGPZ6NY.js → chunk-5IAKUUZF.js} +1 -1
- package/dist/{chunk-KPK656BH.js → chunk-7BOG3S5H.js} +563 -1006
- package/dist/{chunk-I5KYGMIT.js → chunk-BCFKLY4H.js} +54 -1
- package/dist/{chunk-MZ4IR5KH.js → chunk-C4U75IUR.js} +349 -32
- package/dist/chunk-GWDOLYFJ.js +624 -0
- package/dist/{chunk-JP67WXLA.js → chunk-JWVZ3T35.js} +58 -3
- package/dist/{client-3MGIP57D.js → client-NFUHPLLM.js} +1 -1
- package/dist/{grants-service-5ZFISCBH.js → grants-service-O4AO326A.js} +2 -2
- package/dist/index.js +1547 -657
- package/dist/{migrations.postgres-BRXZY2GE.js → migrations.postgres-CYKO5CFV.js} +41 -1
- package/dist/review-service-JUAFYB73.js +47 -0
- package/dist/{stewardship-service-G7IF7ZNP.js → stewardship-service-FHJ2RBKN.js} +4 -4
- package/dist/{version-service-FGL47APL.js → version-service-ECYCBVSE.js} +7 -3
- package/dist/web3/assets/index-BaMJ4tEz.css +1 -0
- package/dist/web3/assets/index-D950G4hR.js +1052 -0
- package/dist/web3/index.html +2 -2
- package/package.json +2 -2
- package/dist/chunk-XQ46F76G.js +0 -16
- package/dist/review-service-6G7MVTY6.js +0 -31
- package/dist/web3/assets/index-BYmOkm4p.js +0 -1052
- package/dist/web3/assets/index-p6uQyhml.css +0 -1
|
@@ -4,209 +4,27 @@ import {
|
|
|
4
4
|
NotFoundError,
|
|
5
5
|
ValidationError
|
|
6
6
|
} from "./chunk-GUNJTORH.js";
|
|
7
|
-
import {
|
|
8
|
-
normalizeEmail
|
|
9
|
-
} from "./chunk-FRQJWGN3.js";
|
|
10
|
-
import {
|
|
11
|
-
insertOrReplace,
|
|
12
|
-
nowExpr
|
|
13
|
-
} from "./chunk-XQ46F76G.js";
|
|
14
7
|
import {
|
|
15
8
|
config,
|
|
16
9
|
getDb,
|
|
17
10
|
isEmailish
|
|
18
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-BCFKLY4H.js";
|
|
19
12
|
import {
|
|
20
13
|
ANON_USER_ID
|
|
21
14
|
} from "./chunk-SLTQACJW.js";
|
|
22
|
-
|
|
23
|
-
// src/governance/stewardship-service.ts
|
|
24
|
-
import { v4 as uuid3 } from "uuid";
|
|
25
|
-
|
|
26
|
-
// src/governance/access-service.ts
|
|
27
|
-
import { readFileSync, existsSync } from "fs";
|
|
28
|
-
import { join } from "path";
|
|
29
|
-
var accessConfig = null;
|
|
30
|
-
var grantedSuperAdmins = /* @__PURE__ */ new Set();
|
|
31
|
-
async function loadGrantedSuperAdmins() {
|
|
32
|
-
const rows = await getDb().all(
|
|
33
|
-
"SELECT email FROM super_admins"
|
|
34
|
-
);
|
|
35
|
-
grantedSuperAdmins = new Set(rows.map((r) => r.email.toLowerCase()));
|
|
36
|
-
}
|
|
37
|
-
function loadAccessConfig() {
|
|
38
|
-
const candidates = [
|
|
39
|
-
join(config.DATA_ROOT, "access.yaml"),
|
|
40
|
-
join(config.DATA_ROOT, "access.yml")
|
|
41
|
-
];
|
|
42
|
-
for (const path of candidates) {
|
|
43
|
-
if (existsSync(path)) {
|
|
44
|
-
const content = readFileSync(path, "utf-8");
|
|
45
|
-
accessConfig = parseAccessYaml(content);
|
|
46
|
-
return accessConfig;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
accessConfig = null;
|
|
50
|
-
return null;
|
|
51
|
-
}
|
|
52
|
-
function getAccessConfig() {
|
|
53
|
-
return accessConfig;
|
|
54
|
-
}
|
|
55
|
-
function isSuperAdmin(email) {
|
|
56
|
-
const lower = email.toLowerCase();
|
|
57
|
-
if (grantedSuperAdmins.has(lower)) return true;
|
|
58
|
-
return isConfigSuperAdmin(lower);
|
|
59
|
-
}
|
|
60
|
-
function isConfigSuperAdmin(email) {
|
|
61
|
-
if (!accessConfig?.super_admins) return false;
|
|
62
|
-
return accessConfig.super_admins.map((e) => e.toLowerCase()).includes(email.toLowerCase());
|
|
63
|
-
}
|
|
64
|
-
function parseAccessYaml(content) {
|
|
65
|
-
const result = {};
|
|
66
|
-
const lines = content.split("\n");
|
|
67
|
-
let currentSection = null;
|
|
68
|
-
let currentGroup = null;
|
|
69
|
-
let inMembers = false;
|
|
70
|
-
for (const rawLine of lines) {
|
|
71
|
-
const line = rawLine.trimEnd();
|
|
72
|
-
if (!line || line.startsWith("#")) continue;
|
|
73
|
-
if (!line.startsWith(" ") && !line.startsWith(" ")) {
|
|
74
|
-
const match = line.match(/^(\w+):\s*(.*)?$/);
|
|
75
|
-
if (!match) continue;
|
|
76
|
-
const key = match[1];
|
|
77
|
-
const value = match[2]?.trim();
|
|
78
|
-
if (key === "mode") {
|
|
79
|
-
result.mode = value;
|
|
80
|
-
currentSection = null;
|
|
81
|
-
} else if (key === "allowed_users") {
|
|
82
|
-
currentSection = "allowed_users";
|
|
83
|
-
result.allowed_users = [];
|
|
84
|
-
} else if (key === "groups") {
|
|
85
|
-
currentSection = "groups";
|
|
86
|
-
result.groups = {};
|
|
87
|
-
} else if (key === "super_admins") {
|
|
88
|
-
currentSection = "super_admins";
|
|
89
|
-
result.super_admins = [];
|
|
90
|
-
}
|
|
91
|
-
currentGroup = null;
|
|
92
|
-
inMembers = false;
|
|
93
|
-
continue;
|
|
94
|
-
}
|
|
95
|
-
const listMatch = line.match(/^\s+-\s+["']?([^"'\n]+?)["']?\s*$/);
|
|
96
|
-
if (currentSection === "allowed_users" && listMatch) {
|
|
97
|
-
result.allowed_users.push(listMatch[1].trim());
|
|
98
|
-
continue;
|
|
99
|
-
}
|
|
100
|
-
if (currentSection === "super_admins" && listMatch) {
|
|
101
|
-
result.super_admins.push(listMatch[1].trim());
|
|
102
|
-
continue;
|
|
103
|
-
}
|
|
104
|
-
if (currentSection === "groups") {
|
|
105
|
-
const groupMatch = line.match(/^ (\w+):$/);
|
|
106
|
-
if (groupMatch) {
|
|
107
|
-
currentGroup = groupMatch[1];
|
|
108
|
-
result.groups[currentGroup] = { members: [], default_permission: "read" };
|
|
109
|
-
inMembers = false;
|
|
110
|
-
continue;
|
|
111
|
-
}
|
|
112
|
-
if (currentGroup) {
|
|
113
|
-
const propMatch = line.match(/^\s{4}(\w+):\s*(.*)?$/);
|
|
114
|
-
if (propMatch) {
|
|
115
|
-
const prop = propMatch[1];
|
|
116
|
-
const val = propMatch[2]?.trim();
|
|
117
|
-
if (prop === "default_permission" && val) {
|
|
118
|
-
result.groups[currentGroup].default_permission = val;
|
|
119
|
-
}
|
|
120
|
-
if (prop === "members") {
|
|
121
|
-
inMembers = true;
|
|
122
|
-
}
|
|
123
|
-
continue;
|
|
124
|
-
}
|
|
125
|
-
if (inMembers && listMatch) {
|
|
126
|
-
result.groups[currentGroup].members.push(listMatch[1].trim());
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
return result;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// src/nodes/engine.ts
|
|
135
15
|
import {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
VersionManager
|
|
139
|
-
} from "@promptowl/contextnest-engine";
|
|
140
|
-
|
|
141
|
-
// src/nests/service.ts
|
|
142
|
-
import { rmSync as rmSync2, mkdirSync } from "fs";
|
|
143
|
-
import { v4 as uuid2 } from "uuid";
|
|
144
|
-
import { NestStorage } from "@promptowl/contextnest-engine";
|
|
16
|
+
normalizeEmail
|
|
17
|
+
} from "./chunk-FRQJWGN3.js";
|
|
145
18
|
|
|
146
|
-
// src/
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
import { join as join2, dirname, isAbsolute } from "path";
|
|
150
|
-
function isSkippedSegment(segment) {
|
|
151
|
-
return segment.startsWith(".") || segment === "node_modules" || segment === "_suggestions";
|
|
152
|
-
}
|
|
153
|
-
var ALLOWED_DOT_DIRS = /* @__PURE__ */ new Set([".versions", ".context"]);
|
|
154
|
-
function safeSegments(rel) {
|
|
155
|
-
if (!rel || isAbsolute(rel)) return null;
|
|
156
|
-
const segs = rel.split(/[\\/]/).filter(Boolean);
|
|
157
|
-
if (!segs.length) return null;
|
|
158
|
-
for (const s of segs) {
|
|
159
|
-
if (s === "..") return null;
|
|
160
|
-
if (!ALLOWED_DOT_DIRS.has(s) && isSkippedSegment(s)) return null;
|
|
161
|
-
}
|
|
162
|
-
return segs;
|
|
163
|
-
}
|
|
164
|
-
var WRITE_CONCURRENCY = 32;
|
|
165
|
-
async function writeImportedFiles(dest, files) {
|
|
166
|
-
const targets = [];
|
|
167
|
-
const skipped = [];
|
|
168
|
-
for (const f of files) {
|
|
169
|
-
const segs = safeSegments(f?.path || "");
|
|
170
|
-
if (!segs) {
|
|
171
|
-
skipped.push(f?.path || "(empty)");
|
|
172
|
-
continue;
|
|
173
|
-
}
|
|
174
|
-
targets.push({ full: join2(dest, ...segs), content: f.content ?? "" });
|
|
175
|
-
}
|
|
176
|
-
const dirs = new Set(targets.map((t) => dirname(t.full)));
|
|
177
|
-
await Promise.all([...dirs].map((d) => mkdir(d, { recursive: true })));
|
|
178
|
-
let written = 0;
|
|
179
|
-
for (let i = 0; i < targets.length; i += WRITE_CONCURRENCY) {
|
|
180
|
-
const batch = targets.slice(i, i + WRITE_CONCURRENCY);
|
|
181
|
-
await Promise.all(
|
|
182
|
-
batch.map(async (t) => {
|
|
183
|
-
await writeFile(t.full, t.content, "utf-8");
|
|
184
|
-
written++;
|
|
185
|
-
})
|
|
186
|
-
);
|
|
187
|
-
}
|
|
188
|
-
console.log(
|
|
189
|
-
`[import] writeImportedFiles: received=${files.length} written=${written} skipped=${skipped.length}`
|
|
190
|
-
);
|
|
191
|
-
if (skipped.length) {
|
|
192
|
-
console.log("[import] skipped (unsafe/hidden path):", skipped.join(", "));
|
|
193
|
-
}
|
|
194
|
-
return written;
|
|
195
|
-
}
|
|
196
|
-
function discardImportDir(dest) {
|
|
197
|
-
try {
|
|
198
|
-
rmSync(dest, { recursive: true, force: true });
|
|
199
|
-
} catch {
|
|
200
|
-
}
|
|
19
|
+
// src/db/sql.ts
|
|
20
|
+
function nowExpr(db) {
|
|
21
|
+
return db.dialect === "sqlite" ? "datetime('now')" : "to_char((now() AT TIME ZONE 'utc'), 'YYYY-MM-DD HH24:MI:SS')";
|
|
201
22
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
import { join as join3 } from "path";
|
|
205
|
-
function nestStorageRoot() {
|
|
206
|
-
return config.NEST_STORAGE_ROOT;
|
|
23
|
+
function insertOrIgnore(db, insertSql) {
|
|
24
|
+
return db.dialect === "sqlite" ? insertSql.replace(/^\s*INSERT\s+INTO/i, "INSERT OR IGNORE INTO") : `${insertSql} ON CONFLICT DO NOTHING`;
|
|
207
25
|
}
|
|
208
|
-
function
|
|
209
|
-
return
|
|
26
|
+
function insertOrReplace(db, insertSql, conflictCols, set) {
|
|
27
|
+
return db.dialect === "sqlite" ? insertSql.replace(/^\s*INSERT\s+INTO/i, "INSERT OR REPLACE INTO") : `${insertSql} ON CONFLICT (${conflictCols.join(", ")}) DO UPDATE SET ${set}`;
|
|
210
28
|
}
|
|
211
29
|
|
|
212
30
|
// src/telemetry/tracker.ts
|
|
@@ -640,53 +458,173 @@ async function _validateLicenseImpl(forceFresh) {
|
|
|
640
458
|
}
|
|
641
459
|
}
|
|
642
460
|
|
|
643
|
-
// src/governance/
|
|
644
|
-
import {
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
461
|
+
// src/governance/access-service.ts
|
|
462
|
+
import { readFileSync, existsSync } from "fs";
|
|
463
|
+
import { join } from "path";
|
|
464
|
+
var accessConfig = null;
|
|
465
|
+
var grantedSuperAdmins = /* @__PURE__ */ new Set();
|
|
466
|
+
async function loadGrantedSuperAdmins() {
|
|
467
|
+
const rows = await getDb().all(
|
|
468
|
+
"SELECT email FROM super_admins"
|
|
469
|
+
);
|
|
470
|
+
grantedSuperAdmins = new Set(rows.map((r) => r.email.toLowerCase()));
|
|
649
471
|
}
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
);
|
|
659
|
-
|
|
660
|
-
|
|
472
|
+
function loadAccessConfig() {
|
|
473
|
+
const candidates = [
|
|
474
|
+
join(config.DATA_ROOT, "access.yaml"),
|
|
475
|
+
join(config.DATA_ROOT, "access.yml")
|
|
476
|
+
];
|
|
477
|
+
for (const path of candidates) {
|
|
478
|
+
if (existsSync(path)) {
|
|
479
|
+
const content = readFileSync(path, "utf-8");
|
|
480
|
+
accessConfig = parseAccessYaml(content);
|
|
481
|
+
return accessConfig;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
accessConfig = null;
|
|
485
|
+
return null;
|
|
661
486
|
}
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
487
|
+
function getAccessConfig() {
|
|
488
|
+
return accessConfig;
|
|
489
|
+
}
|
|
490
|
+
function isSuperAdmin(email) {
|
|
491
|
+
const lower = email.toLowerCase();
|
|
492
|
+
if (grantedSuperAdmins.has(lower)) return true;
|
|
493
|
+
return isConfigSuperAdmin(lower);
|
|
494
|
+
}
|
|
495
|
+
function isConfigSuperAdmin(email) {
|
|
496
|
+
if (!accessConfig?.super_admins) return false;
|
|
497
|
+
return accessConfig.super_admins.map((e) => e.toLowerCase()).includes(email.toLowerCase());
|
|
498
|
+
}
|
|
499
|
+
function parseAccessYaml(content) {
|
|
500
|
+
const result = {};
|
|
501
|
+
const lines = content.split("\n");
|
|
502
|
+
let currentSection = null;
|
|
503
|
+
let currentGroup = null;
|
|
504
|
+
let inMembers = false;
|
|
505
|
+
for (const rawLine of lines) {
|
|
506
|
+
const line = rawLine.trimEnd();
|
|
507
|
+
if (!line || line.startsWith("#")) continue;
|
|
508
|
+
if (!line.startsWith(" ") && !line.startsWith(" ")) {
|
|
509
|
+
const match = line.match(/^(\w+):\s*(.*)?$/);
|
|
510
|
+
if (!match) continue;
|
|
511
|
+
const key = match[1];
|
|
512
|
+
const value = match[2]?.trim();
|
|
513
|
+
if (key === "mode") {
|
|
514
|
+
result.mode = value;
|
|
515
|
+
currentSection = null;
|
|
516
|
+
} else if (key === "allowed_users") {
|
|
517
|
+
currentSection = "allowed_users";
|
|
518
|
+
result.allowed_users = [];
|
|
519
|
+
} else if (key === "groups") {
|
|
520
|
+
currentSection = "groups";
|
|
521
|
+
result.groups = {};
|
|
522
|
+
} else if (key === "super_admins") {
|
|
523
|
+
currentSection = "super_admins";
|
|
524
|
+
result.super_admins = [];
|
|
525
|
+
}
|
|
526
|
+
currentGroup = null;
|
|
527
|
+
inMembers = false;
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
const listMatch = line.match(/^\s+-\s+["']?([^"'\n]+?)["']?\s*$/);
|
|
531
|
+
if (currentSection === "allowed_users" && listMatch) {
|
|
532
|
+
result.allowed_users.push(listMatch[1].trim());
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
if (currentSection === "super_admins" && listMatch) {
|
|
536
|
+
result.super_admins.push(listMatch[1].trim());
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
if (currentSection === "groups") {
|
|
540
|
+
const groupMatch = line.match(/^ (\w+):$/);
|
|
541
|
+
if (groupMatch) {
|
|
542
|
+
currentGroup = groupMatch[1];
|
|
543
|
+
result.groups[currentGroup] = { members: [], default_permission: "read" };
|
|
544
|
+
inMembers = false;
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
if (currentGroup) {
|
|
548
|
+
const propMatch = line.match(/^\s{4}(\w+):\s*(.*)?$/);
|
|
549
|
+
if (propMatch) {
|
|
550
|
+
const prop = propMatch[1];
|
|
551
|
+
const val = propMatch[2]?.trim();
|
|
552
|
+
if (prop === "default_permission" && val) {
|
|
553
|
+
result.groups[currentGroup].default_permission = val;
|
|
554
|
+
}
|
|
555
|
+
if (prop === "members") {
|
|
556
|
+
inMembers = true;
|
|
557
|
+
}
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
if (inMembers && listMatch) {
|
|
561
|
+
result.groups[currentGroup].members.push(listMatch[1].trim());
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return result;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// src/notify/email-render.ts
|
|
570
|
+
function escapeHtml(s) {
|
|
571
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
572
|
+
}
|
|
573
|
+
var EVENT = {
|
|
574
|
+
":white_check_mark:": { icon: "\u2705", color: "#16a34a" },
|
|
575
|
+
":x:": { icon: "\u274C", color: "#dc2626" },
|
|
576
|
+
":memo:": { icon: "\u{1F4DD}", color: "#d97706" },
|
|
577
|
+
":key:": { icon: "\u{1F511}", color: "#2563eb" }
|
|
578
|
+
};
|
|
579
|
+
var ALL_EMOJI = Object.fromEntries(
|
|
580
|
+
Object.entries(EVENT).map(([code, m]) => [code, m.icon])
|
|
581
|
+
);
|
|
582
|
+
function renderText(message) {
|
|
583
|
+
return message.replace(/:[a-z_]+:/g, (m) => ALL_EMOJI[m] ?? "").replace(/\*/g, "").replace(/[ \t]{2,}/g, " ").trim();
|
|
584
|
+
}
|
|
585
|
+
var STATUS_META = {
|
|
586
|
+
approved: { subjectWord: "approved", label: "Approved", color: "#16a34a" },
|
|
587
|
+
rejected: { subjectWord: "rejected", label: "Rejected", color: "#dc2626" },
|
|
588
|
+
pending_review: {
|
|
589
|
+
subjectWord: "awaiting review",
|
|
590
|
+
label: "Pending review",
|
|
591
|
+
color: "#d97706"
|
|
592
|
+
},
|
|
593
|
+
shared: { subjectWord: "shared with you", label: "Shared", color: "#2563eb" },
|
|
594
|
+
invited: { subjectWord: "invited you", label: "Invitation", color: "#2563eb" },
|
|
595
|
+
steward: { subjectWord: "steward", label: "Steward", color: "#7c3aed" },
|
|
596
|
+
deletion_requested: {
|
|
597
|
+
subjectWord: "flagged for deletion",
|
|
598
|
+
label: "Deletion requested",
|
|
599
|
+
color: "#d97706"
|
|
600
|
+
},
|
|
601
|
+
deletion_declined: {
|
|
602
|
+
subjectWord: "kept",
|
|
603
|
+
label: "Deletion declined",
|
|
604
|
+
color: "#d97706"
|
|
605
|
+
},
|
|
606
|
+
deletion_deleted: {
|
|
607
|
+
subjectWord: "deleted",
|
|
608
|
+
label: "Deleted",
|
|
609
|
+
color: "#dc2626"
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
var cap = (s) => s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
|
|
613
|
+
var detailRow = (label, valueHtml) => `<tr>
|
|
614
|
+
<td style="padding:5px 0;font-size:13px;color:#6b7280;width:90px;vertical-align:top;">${label}</td>
|
|
615
|
+
<td style="padding:5px 0;font-size:13px;color:#111827;vertical-align:top;">${valueHtml}</td>
|
|
616
|
+
</tr>`;
|
|
617
|
+
var plainRow = (label, value) => ({
|
|
618
|
+
label,
|
|
619
|
+
text: value,
|
|
620
|
+
html: escapeHtml(value)
|
|
621
|
+
});
|
|
622
|
+
var governanceRows = (x) => [
|
|
623
|
+
plainRow("Path", x.d.path),
|
|
624
|
+
{ label: "Status", text: x.meta.label, html: x.statusBadge },
|
|
625
|
+
...x.d.version != null ? [plainRow("Version", `v${x.d.version}`)] : []
|
|
626
|
+
];
|
|
627
|
+
var TEMPLATES = {
|
|
690
628
|
approved: {
|
|
691
629
|
heading: (x) => `${x.title} is ${x.meta.subjectWord}`,
|
|
692
630
|
detailsHeader: "Document details",
|
|
@@ -739,6 +677,32 @@ var TEMPLATES = {
|
|
|
739
677
|
] : []
|
|
740
678
|
]
|
|
741
679
|
},
|
|
680
|
+
// Deletion requests reuse the governance details block — same "what/where/
|
|
681
|
+
// status" shape, and the reason/note rides in `note` like a review note.
|
|
682
|
+
deletion_requested: {
|
|
683
|
+
heading: (x) => `${x.title} is ${x.meta.subjectWord}`,
|
|
684
|
+
detailsHeader: "Document details",
|
|
685
|
+
linkLabel: "Review the request",
|
|
686
|
+
lineT: (x) => `${x.title} has been flagged for deletion${x.byT}${x.noteT}. Delete it or decline the request from the nest.`,
|
|
687
|
+
lineH: (x) => `${x.doc} has been flagged for deletion${x.byH}${x.noteH}. Delete it or decline the request from the nest.`,
|
|
688
|
+
rows: governanceRows
|
|
689
|
+
},
|
|
690
|
+
deletion_declined: {
|
|
691
|
+
heading: (x) => `Your deletion request for ${x.title} was declined`,
|
|
692
|
+
detailsHeader: "Document details",
|
|
693
|
+
linkLabel: "Open document",
|
|
694
|
+
lineT: (x) => `Your request to delete ${x.title} was declined${x.byT}${x.noteT}. The document stays.`,
|
|
695
|
+
lineH: (x) => `Your request to delete ${x.doc} was declined${x.byH}${x.noteH}. The document stays.`,
|
|
696
|
+
rows: governanceRows
|
|
697
|
+
},
|
|
698
|
+
deletion_deleted: {
|
|
699
|
+
heading: (x) => `${x.title} was ${x.meta.subjectWord}`,
|
|
700
|
+
detailsHeader: "Document details",
|
|
701
|
+
linkLabel: "Open nest",
|
|
702
|
+
lineT: (x) => `${x.title} has been deleted${x.byT}${x.noteT} \u2014 your deletion request was accepted.`,
|
|
703
|
+
lineH: (x) => `${x.doc} has been deleted${x.byH}${x.noteH} \u2014 your deletion request was accepted.`,
|
|
704
|
+
rows: governanceRows
|
|
705
|
+
},
|
|
742
706
|
steward: {
|
|
743
707
|
heading: (x) => `You're now a steward of ${x.title}`,
|
|
744
708
|
detailsHeader: "Details",
|
|
@@ -1008,70 +972,284 @@ async function sendEmailToRecipient(to, details) {
|
|
|
1008
972
|
}
|
|
1009
973
|
}
|
|
1010
974
|
|
|
1011
|
-
// src/
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
975
|
+
// src/nests/service.ts
|
|
976
|
+
import { rmSync as rmSync2, mkdirSync } from "fs";
|
|
977
|
+
import { v4 as uuid2 } from "uuid";
|
|
978
|
+
import { NestStorage as NestStorage3 } from "@promptowl/contextnest-engine";
|
|
979
|
+
|
|
980
|
+
// src/fs/vault-import.ts
|
|
981
|
+
import { rmSync } from "fs";
|
|
982
|
+
import { writeFile, mkdir } from "fs/promises";
|
|
983
|
+
import { join as join2, dirname, isAbsolute } from "path";
|
|
984
|
+
function isSkippedSegment(segment) {
|
|
985
|
+
return segment.startsWith(".") || segment === "node_modules" || segment === "_suggestions";
|
|
1021
986
|
}
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
};
|
|
987
|
+
var ALLOWED_DOT_DIRS = /* @__PURE__ */ new Set([".versions", ".context"]);
|
|
988
|
+
function safeSegments(rel) {
|
|
989
|
+
if (!rel || isAbsolute(rel)) return null;
|
|
990
|
+
const segs = rel.split(/[\\/]/).filter(Boolean);
|
|
991
|
+
if (!segs.length) return null;
|
|
992
|
+
for (const s of segs) {
|
|
993
|
+
if (s === "..") return null;
|
|
994
|
+
if (!ALLOWED_DOT_DIRS.has(s) && isSkippedSegment(s)) return null;
|
|
995
|
+
}
|
|
996
|
+
return segs;
|
|
1033
997
|
}
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
const
|
|
1037
|
-
const
|
|
1038
|
-
|
|
1039
|
-
const
|
|
1040
|
-
|
|
1041
|
-
|
|
998
|
+
var WRITE_CONCURRENCY = 32;
|
|
999
|
+
async function writeImportedFiles(dest, files) {
|
|
1000
|
+
const targets = [];
|
|
1001
|
+
const skipped = [];
|
|
1002
|
+
for (const f of files) {
|
|
1003
|
+
const segs = safeSegments(f?.path || "");
|
|
1004
|
+
if (!segs) {
|
|
1005
|
+
skipped.push(f?.path || "(empty)");
|
|
1006
|
+
continue;
|
|
1007
|
+
}
|
|
1008
|
+
targets.push({ full: join2(dest, ...segs), content: f.content ?? "" });
|
|
1009
|
+
}
|
|
1010
|
+
const dirs = new Set(targets.map((t) => dirname(t.full)));
|
|
1011
|
+
await Promise.all([...dirs].map((d) => mkdir(d, { recursive: true })));
|
|
1012
|
+
let written = 0;
|
|
1013
|
+
for (let i = 0; i < targets.length; i += WRITE_CONCURRENCY) {
|
|
1014
|
+
const batch = targets.slice(i, i + WRITE_CONCURRENCY);
|
|
1015
|
+
await Promise.all(
|
|
1016
|
+
batch.map(async (t) => {
|
|
1017
|
+
await writeFile(t.full, t.content, "utf-8");
|
|
1018
|
+
written++;
|
|
1019
|
+
})
|
|
1042
1020
|
);
|
|
1043
|
-
for (const r of rows) invited.set(r.id, r.is_invited);
|
|
1044
1021
|
}
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
members: team.members.map((m) => ({
|
|
1053
|
-
...m,
|
|
1054
|
-
registered: (invited.get(m.userId) ?? 1) === 0
|
|
1055
|
-
}))
|
|
1056
|
-
};
|
|
1022
|
+
console.log(
|
|
1023
|
+
`[import] writeImportedFiles: received=${files.length} written=${written} skipped=${skipped.length}`
|
|
1024
|
+
);
|
|
1025
|
+
if (skipped.length) {
|
|
1026
|
+
console.log("[import] skipped (unsafe/hidden path):", skipped.join(", "));
|
|
1027
|
+
}
|
|
1028
|
+
return written;
|
|
1057
1029
|
}
|
|
1058
|
-
function
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
);
|
|
1030
|
+
function discardImportDir(dest) {
|
|
1031
|
+
try {
|
|
1032
|
+
rmSync(dest, { recursive: true, force: true });
|
|
1033
|
+
} catch {
|
|
1063
1034
|
}
|
|
1064
1035
|
}
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
if (!row) throw new NotFoundError("Team not found");
|
|
1071
|
-
return row;
|
|
1036
|
+
|
|
1037
|
+
// src/shared/paths.ts
|
|
1038
|
+
import { join as join3 } from "path";
|
|
1039
|
+
function nestStorageRoot() {
|
|
1040
|
+
return config.NEST_STORAGE_ROOT;
|
|
1072
1041
|
}
|
|
1073
|
-
|
|
1074
|
-
|
|
1042
|
+
function resolveNestPath(nestId) {
|
|
1043
|
+
return join3(nestStorageRoot(), nestId);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// src/governance/teams-service.ts
|
|
1047
|
+
import { v4 as uuid } from "uuid";
|
|
1048
|
+
|
|
1049
|
+
// src/nodes/engine.ts
|
|
1050
|
+
import {
|
|
1051
|
+
NestStorage as NestStorage2,
|
|
1052
|
+
GraphQueryEngine,
|
|
1053
|
+
VersionManager
|
|
1054
|
+
} from "@promptowl/contextnest-engine";
|
|
1055
|
+
|
|
1056
|
+
// src/nodes/flat-storage.ts
|
|
1057
|
+
import { readdirSync } from "fs";
|
|
1058
|
+
import { join as join4, relative, sep } from "path";
|
|
1059
|
+
import { NestStorage } from "@promptowl/contextnest-engine";
|
|
1060
|
+
var META_FILES = /* @__PURE__ */ new Set([
|
|
1061
|
+
"INDEX.md",
|
|
1062
|
+
"CONTEXT.md",
|
|
1063
|
+
"CLAUDE.md",
|
|
1064
|
+
"GEMINI.md",
|
|
1065
|
+
"AGENTS.md",
|
|
1066
|
+
"QWEN.md"
|
|
1067
|
+
]);
|
|
1068
|
+
var FlatNestStorage = class extends NestStorage {
|
|
1069
|
+
async discoverDocuments() {
|
|
1070
|
+
const root = this.root;
|
|
1071
|
+
let entries;
|
|
1072
|
+
try {
|
|
1073
|
+
entries = readdirSync(root, { recursive: true, withFileTypes: true });
|
|
1074
|
+
} catch (err) {
|
|
1075
|
+
console.error("[FlatNestStorage] cannot read folder", root, err);
|
|
1076
|
+
return [];
|
|
1077
|
+
}
|
|
1078
|
+
const ids = [];
|
|
1079
|
+
for (const e of entries) {
|
|
1080
|
+
if (!e.isFile() || !e.name.endsWith(".md")) continue;
|
|
1081
|
+
if (META_FILES.has(e.name)) continue;
|
|
1082
|
+
const dir = e.parentPath ?? e.path ?? root;
|
|
1083
|
+
const rel = relative(root, join4(dir, e.name)).split(sep).join("/");
|
|
1084
|
+
if (rel.split("/").some(isSkippedSegment)) continue;
|
|
1085
|
+
ids.push(rel.replace(/\.md$/, ""));
|
|
1086
|
+
}
|
|
1087
|
+
const nodes = [];
|
|
1088
|
+
for (const id of ids.sort()) {
|
|
1089
|
+
try {
|
|
1090
|
+
nodes.push(await this.readDocument(id));
|
|
1091
|
+
} catch (err) {
|
|
1092
|
+
console.error("[FlatNestStorage] skipped unreadable doc", id, err);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
return nodes;
|
|
1096
|
+
}
|
|
1097
|
+
};
|
|
1098
|
+
|
|
1099
|
+
// src/nodes/engine.ts
|
|
1100
|
+
var NestEngineCache = class {
|
|
1101
|
+
cache = /* @__PURE__ */ new Map();
|
|
1102
|
+
async get(nestId) {
|
|
1103
|
+
let engine = this.cache.get(nestId);
|
|
1104
|
+
if (!engine) {
|
|
1105
|
+
const nestPath = resolveNestPath(nestId);
|
|
1106
|
+
const storage = await isImportedNest(nestId) ? new FlatNestStorage(nestPath) : new NestStorage2(nestPath);
|
|
1107
|
+
const query = new GraphQueryEngine(storage);
|
|
1108
|
+
const versions = new VersionManager(storage);
|
|
1109
|
+
engine = { storage, query, versions };
|
|
1110
|
+
this.cache.set(nestId, engine);
|
|
1111
|
+
}
|
|
1112
|
+
return engine;
|
|
1113
|
+
}
|
|
1114
|
+
evict(nestId) {
|
|
1115
|
+
this.cache.delete(nestId);
|
|
1116
|
+
}
|
|
1117
|
+
};
|
|
1118
|
+
var engineCache = new NestEngineCache();
|
|
1119
|
+
|
|
1120
|
+
// src/governance/title-resolver.ts
|
|
1121
|
+
async function buildTitleMap(nestId) {
|
|
1122
|
+
try {
|
|
1123
|
+
const { storage } = await engineCache.get(nestId);
|
|
1124
|
+
const docs = await storage.discoverDocuments();
|
|
1125
|
+
return new Map(docs.map((d) => [d.id, d.frontmatter.title]));
|
|
1126
|
+
} catch {
|
|
1127
|
+
return /* @__PURE__ */ new Map();
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
function folderForNode(nodeId) {
|
|
1131
|
+
const segments = nodeId.split("/");
|
|
1132
|
+
const root = segments.indexOf("nodes");
|
|
1133
|
+
return segments.slice(root + 1, -1).join("/");
|
|
1134
|
+
}
|
|
1135
|
+
async function describeNode(nestId, nodeId) {
|
|
1136
|
+
const title = await titleForNode(nestId, nodeId);
|
|
1137
|
+
const folder = folderForNode(nodeId);
|
|
1138
|
+
return folder ? `${title} (in ${folder})` : title;
|
|
1139
|
+
}
|
|
1140
|
+
async function titleForNode(nestId, nodeId) {
|
|
1141
|
+
const titles = await buildTitleMap(nestId);
|
|
1142
|
+
return titles.get(nodeId) || titleFallback(titles, nodeId);
|
|
1143
|
+
}
|
|
1144
|
+
function titleFallback(titles, nodeId) {
|
|
1145
|
+
const at = nodeId.indexOf("nodes/");
|
|
1146
|
+
if (at > 0) {
|
|
1147
|
+
const normalized = nodeId.slice(at);
|
|
1148
|
+
const hit = titles.get(normalized);
|
|
1149
|
+
if (hit) return hit;
|
|
1150
|
+
}
|
|
1151
|
+
return nodeId.split("/").pop() || nodeId;
|
|
1152
|
+
}
|
|
1153
|
+
function prettyFolderPath(path) {
|
|
1154
|
+
return path.replace(/^nodes\//, "").split("/").filter(Boolean).map(
|
|
1155
|
+
(seg) => seg.split(/[-_]/).map((s) => s ? s[0].toUpperCase() + s.slice(1) : s).join(" ")
|
|
1156
|
+
).join(" / ");
|
|
1157
|
+
}
|
|
1158
|
+
async function nestName(nestId) {
|
|
1159
|
+
try {
|
|
1160
|
+
const row = await getDb().get("SELECT name FROM nests WHERE id = ?", [
|
|
1161
|
+
nestId
|
|
1162
|
+
]);
|
|
1163
|
+
return row?.name || nestId;
|
|
1164
|
+
} catch {
|
|
1165
|
+
return nestId;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
function docLink(nestId, nodeId, baseUrl) {
|
|
1169
|
+
const base = baseUrl || config.PUBLIC_BASE_URL;
|
|
1170
|
+
if (!base) return "";
|
|
1171
|
+
const q = new URLSearchParams({ nest: nestId });
|
|
1172
|
+
if (nodeId) q.set("doc", nodeId);
|
|
1173
|
+
return `${base.replace(/\/$/, "")}/?${q.toString()}`;
|
|
1174
|
+
}
|
|
1175
|
+
async function buildDocContext(nestId, nodeId, baseUrl) {
|
|
1176
|
+
const titles = await buildTitleMap(nestId);
|
|
1177
|
+
const docTitle = titles.get(nodeId) || nodeId;
|
|
1178
|
+
const folder = folderForNode(nodeId);
|
|
1179
|
+
const name = await nestName(nestId);
|
|
1180
|
+
return {
|
|
1181
|
+
docTitle,
|
|
1182
|
+
// Slack one-liner label ("Title (in Folder)"), same as describeNode().
|
|
1183
|
+
label: folder ? `${docTitle} (in ${folder})` : docTitle,
|
|
1184
|
+
path: folder ? `${name} / ${folder}` : name,
|
|
1185
|
+
link: docLink(nestId, nodeId, baseUrl)
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// src/governance/teams-service.ts
|
|
1190
|
+
var VALID_TEAM_ROLES = ["admin", "editor", "viewer"];
|
|
1191
|
+
function parseMembers(json) {
|
|
1192
|
+
if (!json) return [];
|
|
1193
|
+
try {
|
|
1194
|
+
const parsed = JSON.parse(json);
|
|
1195
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
1196
|
+
} catch {
|
|
1197
|
+
return [];
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
function rowToTeam(row) {
|
|
1201
|
+
return {
|
|
1202
|
+
id: row.id,
|
|
1203
|
+
name: row.name,
|
|
1204
|
+
owner_id: row.owner_id,
|
|
1205
|
+
members: parseMembers(row.members),
|
|
1206
|
+
source: row.source ?? null,
|
|
1207
|
+
external_id: row.external_id ?? null,
|
|
1208
|
+
created_at: row.created_at,
|
|
1209
|
+
updated_at: row.updated_at
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
async function enrichTeam(team) {
|
|
1213
|
+
const db = getDb();
|
|
1214
|
+
const ids = team.members.map((m) => m.userId);
|
|
1215
|
+
const invited = /* @__PURE__ */ new Map();
|
|
1216
|
+
if (ids.length) {
|
|
1217
|
+
const rows = await db.all(
|
|
1218
|
+
`SELECT id, is_invited FROM users WHERE id IN (${ids.map(() => "?").join(",")})`,
|
|
1219
|
+
ids
|
|
1220
|
+
);
|
|
1221
|
+
for (const r of rows) invited.set(r.id, r.is_invited);
|
|
1222
|
+
}
|
|
1223
|
+
const owner = await db.get("SELECT email FROM users WHERE id = ?", [
|
|
1224
|
+
team.owner_id
|
|
1225
|
+
]);
|
|
1226
|
+
return {
|
|
1227
|
+
...team,
|
|
1228
|
+
owner_email: owner?.email,
|
|
1229
|
+
// Unknown id (deleted user) → treat as not registered.
|
|
1230
|
+
members: team.members.map((m) => ({
|
|
1231
|
+
...m,
|
|
1232
|
+
registered: (invited.get(m.userId) ?? 1) === 0
|
|
1233
|
+
}))
|
|
1234
|
+
};
|
|
1235
|
+
}
|
|
1236
|
+
function assertValidRole(role) {
|
|
1237
|
+
if (!VALID_TEAM_ROLES.includes(role)) {
|
|
1238
|
+
throw new ValidationError(
|
|
1239
|
+
"role must be admin, editor, or viewer"
|
|
1240
|
+
);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
async function loadTeamRow(teamId) {
|
|
1244
|
+
const db = getDb();
|
|
1245
|
+
const row = await db.get("SELECT * FROM teams WHERE id = ?", [
|
|
1246
|
+
teamId
|
|
1247
|
+
]);
|
|
1248
|
+
if (!row) throw new NotFoundError("Team not found");
|
|
1249
|
+
return row;
|
|
1250
|
+
}
|
|
1251
|
+
var ROLE_RANK = {
|
|
1252
|
+
viewer: 1,
|
|
1075
1253
|
editor: 2,
|
|
1076
1254
|
admin: 3
|
|
1077
1255
|
};
|
|
@@ -1467,6 +1645,7 @@ function teamRoleToPermission(role) {
|
|
|
1467
1645
|
var includesAny = (roles, wanted) => roles.some((r) => wanted.includes(r));
|
|
1468
1646
|
var canViewWith = (roles) => roles.length > 0;
|
|
1469
1647
|
var canEditWith = (roles) => includesAny(roles, ["owner", "admin", "editor"]);
|
|
1648
|
+
var canApproveWith = (roles) => includesAny(roles, ["owner", "admin", "reviewer"]);
|
|
1470
1649
|
var PRECEDENCE = [
|
|
1471
1650
|
"owner",
|
|
1472
1651
|
"admin",
|
|
@@ -1562,6 +1741,39 @@ async function isPublicReader(nestId, userId) {
|
|
|
1562
1741
|
return true;
|
|
1563
1742
|
}
|
|
1564
1743
|
|
|
1744
|
+
// src/governance/tag-index-service.ts
|
|
1745
|
+
function normalizeTag(raw) {
|
|
1746
|
+
return raw.trim().replace(/^#+/, "").toLowerCase();
|
|
1747
|
+
}
|
|
1748
|
+
async function syncNodeTags(nestId, nodeId, tags) {
|
|
1749
|
+
const db = getDb();
|
|
1750
|
+
const normalized = Array.from(
|
|
1751
|
+
new Set(
|
|
1752
|
+
tags.filter((t) => typeof t === "string").map(normalizeTag).filter(Boolean)
|
|
1753
|
+
)
|
|
1754
|
+
);
|
|
1755
|
+
const insertSql = insertOrIgnore(
|
|
1756
|
+
db,
|
|
1757
|
+
"INSERT INTO node_tag_index (nest_id, node_id, tag_name) VALUES (?, ?, ?)"
|
|
1758
|
+
);
|
|
1759
|
+
await db.transaction(async (tx) => {
|
|
1760
|
+
await tx.run(
|
|
1761
|
+
"DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?",
|
|
1762
|
+
[nestId, nodeId]
|
|
1763
|
+
);
|
|
1764
|
+
for (const tag of normalized) {
|
|
1765
|
+
await tx.run(insertSql, [nestId, nodeId, tag]);
|
|
1766
|
+
}
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1769
|
+
async function removeNodeFromTagIndex(nestId, nodeId) {
|
|
1770
|
+
const db = getDb();
|
|
1771
|
+
await db.run(
|
|
1772
|
+
"DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?",
|
|
1773
|
+
[nestId, nodeId]
|
|
1774
|
+
);
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1565
1777
|
// src/nests/service.ts
|
|
1566
1778
|
async function isImportedNest(nestId) {
|
|
1567
1779
|
const row = await getDb().get(
|
|
@@ -1619,6 +1831,43 @@ async function setAllowSelfApprove(nestId, allow) {
|
|
|
1619
1831
|
nestId
|
|
1620
1832
|
]);
|
|
1621
1833
|
}
|
|
1834
|
+
async function nestReviewsPrimeOnly(nestId) {
|
|
1835
|
+
const db = getDb();
|
|
1836
|
+
const row = await db.get(
|
|
1837
|
+
"SELECT prime_only_review FROM nests WHERE id = ?",
|
|
1838
|
+
[nestId]
|
|
1839
|
+
);
|
|
1840
|
+
return !!row?.prime_only_review;
|
|
1841
|
+
}
|
|
1842
|
+
async function setPrimeOnlyReview(nestId, enabled) {
|
|
1843
|
+
const db = getDb();
|
|
1844
|
+
await db.run("UPDATE nests SET prime_only_review = ? WHERE id = ?", [
|
|
1845
|
+
enabled ? 1 : 0,
|
|
1846
|
+
nestId
|
|
1847
|
+
]);
|
|
1848
|
+
}
|
|
1849
|
+
async function nestPrimeTags(nestId) {
|
|
1850
|
+
const db = getDb();
|
|
1851
|
+
const row = await db.get("SELECT prime_tags FROM nests WHERE id = ?", [
|
|
1852
|
+
nestId
|
|
1853
|
+
]);
|
|
1854
|
+
return parsePrimeTags(row?.prime_tags);
|
|
1855
|
+
}
|
|
1856
|
+
async function setPrimeTags(nestId, tags) {
|
|
1857
|
+
const db = getDb();
|
|
1858
|
+
await db.run("UPDATE nests SET prime_tags = ? WHERE id = ?", [
|
|
1859
|
+
parsePrimeTags(tags.join(",")).join(","),
|
|
1860
|
+
nestId
|
|
1861
|
+
]);
|
|
1862
|
+
}
|
|
1863
|
+
function parsePrimeTags(raw) {
|
|
1864
|
+
if (!raw) return [];
|
|
1865
|
+
return [
|
|
1866
|
+
...new Set(
|
|
1867
|
+
raw.split(",").map((t) => normalizeTag(t)).filter(Boolean)
|
|
1868
|
+
)
|
|
1869
|
+
];
|
|
1870
|
+
}
|
|
1622
1871
|
async function disableStewardshipAndWipeGovernance(nestId) {
|
|
1623
1872
|
const db = getDb();
|
|
1624
1873
|
return db.transaction(async (tx) => {
|
|
@@ -1700,7 +1949,7 @@ async function createNest(userId, name, description) {
|
|
|
1700
1949
|
);
|
|
1701
1950
|
const path = resolveNestPath(id);
|
|
1702
1951
|
mkdirSync(path, { recursive: true });
|
|
1703
|
-
const storage = new
|
|
1952
|
+
const storage = new NestStorage3(path);
|
|
1704
1953
|
await storage.init(trimmed);
|
|
1705
1954
|
trackEvent("nest.create", { nestId: id, userId });
|
|
1706
1955
|
return await db.get("SELECT * FROM nests WHERE id = ?", [id]);
|
|
@@ -1896,696 +2145,10 @@ async function deleteNest(nestId) {
|
|
|
1896
2145
|
trackEvent("nest.delete", { nestId });
|
|
1897
2146
|
}
|
|
1898
2147
|
|
|
1899
|
-
// src/nodes/flat-storage.ts
|
|
1900
|
-
import { readdirSync } from "fs";
|
|
1901
|
-
import { join as join4, relative, sep } from "path";
|
|
1902
|
-
import { NestStorage as NestStorage2 } from "@promptowl/contextnest-engine";
|
|
1903
|
-
var META_FILES = /* @__PURE__ */ new Set([
|
|
1904
|
-
"INDEX.md",
|
|
1905
|
-
"CONTEXT.md",
|
|
1906
|
-
"CLAUDE.md",
|
|
1907
|
-
"GEMINI.md",
|
|
1908
|
-
"AGENTS.md",
|
|
1909
|
-
"QWEN.md"
|
|
1910
|
-
]);
|
|
1911
|
-
var FlatNestStorage = class extends NestStorage2 {
|
|
1912
|
-
async discoverDocuments() {
|
|
1913
|
-
const root = this.root;
|
|
1914
|
-
let entries;
|
|
1915
|
-
try {
|
|
1916
|
-
entries = readdirSync(root, { recursive: true, withFileTypes: true });
|
|
1917
|
-
} catch (err) {
|
|
1918
|
-
console.error("[FlatNestStorage] cannot read folder", root, err);
|
|
1919
|
-
return [];
|
|
1920
|
-
}
|
|
1921
|
-
const ids = [];
|
|
1922
|
-
for (const e of entries) {
|
|
1923
|
-
if (!e.isFile() || !e.name.endsWith(".md")) continue;
|
|
1924
|
-
if (META_FILES.has(e.name)) continue;
|
|
1925
|
-
const dir = e.parentPath ?? e.path ?? root;
|
|
1926
|
-
const rel = relative(root, join4(dir, e.name)).split(sep).join("/");
|
|
1927
|
-
if (rel.split("/").some(isSkippedSegment)) continue;
|
|
1928
|
-
ids.push(rel.replace(/\.md$/, ""));
|
|
1929
|
-
}
|
|
1930
|
-
const nodes = [];
|
|
1931
|
-
for (const id of ids.sort()) {
|
|
1932
|
-
try {
|
|
1933
|
-
nodes.push(await this.readDocument(id));
|
|
1934
|
-
} catch (err) {
|
|
1935
|
-
console.error("[FlatNestStorage] skipped unreadable doc", id, err);
|
|
1936
|
-
}
|
|
1937
|
-
}
|
|
1938
|
-
return nodes;
|
|
1939
|
-
}
|
|
1940
|
-
};
|
|
1941
|
-
|
|
1942
|
-
// src/nodes/engine.ts
|
|
1943
|
-
var NestEngineCache = class {
|
|
1944
|
-
cache = /* @__PURE__ */ new Map();
|
|
1945
|
-
async get(nestId) {
|
|
1946
|
-
let engine = this.cache.get(nestId);
|
|
1947
|
-
if (!engine) {
|
|
1948
|
-
const nestPath = resolveNestPath(nestId);
|
|
1949
|
-
const storage = await isImportedNest(nestId) ? new FlatNestStorage(nestPath) : new NestStorage3(nestPath);
|
|
1950
|
-
const query = new GraphQueryEngine(storage);
|
|
1951
|
-
const versions = new VersionManager(storage);
|
|
1952
|
-
engine = { storage, query, versions };
|
|
1953
|
-
this.cache.set(nestId, engine);
|
|
1954
|
-
}
|
|
1955
|
-
return engine;
|
|
1956
|
-
}
|
|
1957
|
-
evict(nestId) {
|
|
1958
|
-
this.cache.delete(nestId);
|
|
1959
|
-
}
|
|
1960
|
-
};
|
|
1961
|
-
var engineCache = new NestEngineCache();
|
|
1962
|
-
|
|
1963
|
-
// src/governance/title-resolver.ts
|
|
1964
|
-
async function buildTitleMap(nestId) {
|
|
1965
|
-
try {
|
|
1966
|
-
const { storage } = await engineCache.get(nestId);
|
|
1967
|
-
const docs = await storage.discoverDocuments();
|
|
1968
|
-
return new Map(docs.map((d) => [d.id, d.frontmatter.title]));
|
|
1969
|
-
} catch {
|
|
1970
|
-
return /* @__PURE__ */ new Map();
|
|
1971
|
-
}
|
|
1972
|
-
}
|
|
1973
|
-
function folderForNode(nodeId) {
|
|
1974
|
-
return nodeId.replace(/^nodes\//, "").split("/").slice(0, -1).join("/");
|
|
1975
|
-
}
|
|
1976
|
-
async function describeNode(nestId, nodeId) {
|
|
1977
|
-
const titles = await buildTitleMap(nestId);
|
|
1978
|
-
const title = titles.get(nodeId) || nodeId;
|
|
1979
|
-
const folder = folderForNode(nodeId);
|
|
1980
|
-
return folder ? `${title} (in ${folder})` : title;
|
|
1981
|
-
}
|
|
1982
|
-
async function nestName(nestId) {
|
|
1983
|
-
try {
|
|
1984
|
-
const row = await getDb().get("SELECT name FROM nests WHERE id = ?", [
|
|
1985
|
-
nestId
|
|
1986
|
-
]);
|
|
1987
|
-
return row?.name || nestId;
|
|
1988
|
-
} catch {
|
|
1989
|
-
return nestId;
|
|
1990
|
-
}
|
|
1991
|
-
}
|
|
1992
|
-
function docLink(nestId, nodeId, baseUrl) {
|
|
1993
|
-
const base = baseUrl || config.PUBLIC_BASE_URL;
|
|
1994
|
-
if (!base) return "";
|
|
1995
|
-
const q = new URLSearchParams({ nest: nestId });
|
|
1996
|
-
if (nodeId) q.set("doc", nodeId);
|
|
1997
|
-
return `${base.replace(/\/$/, "")}/?${q.toString()}`;
|
|
1998
|
-
}
|
|
1999
|
-
async function buildDocContext(nestId, nodeId, baseUrl) {
|
|
2000
|
-
const titles = await buildTitleMap(nestId);
|
|
2001
|
-
const docTitle = titles.get(nodeId) || nodeId;
|
|
2002
|
-
const folder = folderForNode(nodeId);
|
|
2003
|
-
const name = await nestName(nestId);
|
|
2004
|
-
return {
|
|
2005
|
-
docTitle,
|
|
2006
|
-
// Slack one-liner label ("Title (in Folder)"), same as describeNode().
|
|
2007
|
-
label: folder ? `${docTitle} (in ${folder})` : docTitle,
|
|
2008
|
-
path: folder ? `${name} / ${folder}` : name,
|
|
2009
|
-
link: docLink(nestId, nodeId, baseUrl)
|
|
2010
|
-
};
|
|
2011
|
-
}
|
|
2012
|
-
|
|
2013
|
-
// src/governance/stewardship-service.ts
|
|
2014
|
-
async function assignSteward(data) {
|
|
2015
|
-
const db = getDb();
|
|
2016
|
-
const id = uuid3();
|
|
2017
|
-
await db.run(
|
|
2018
|
-
`INSERT INTO stewards
|
|
2019
|
-
(id, nest_id, scope, node_pattern, tag_name, user_email, user_id, role, assigned_by, is_active)
|
|
2020
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
2021
|
-
[
|
|
2022
|
-
id,
|
|
2023
|
-
data.nestId,
|
|
2024
|
-
data.scope,
|
|
2025
|
-
data.nodePattern || null,
|
|
2026
|
-
data.tagName || null,
|
|
2027
|
-
data.userEmail,
|
|
2028
|
-
data.userId || null,
|
|
2029
|
-
data.role,
|
|
2030
|
-
data.assignedBy,
|
|
2031
|
-
data.isActive ? 1 : 0
|
|
2032
|
-
]
|
|
2033
|
-
);
|
|
2034
|
-
return { ...data, id };
|
|
2035
|
-
}
|
|
2036
|
-
async function removeSteward(id) {
|
|
2037
|
-
const db = getDb();
|
|
2038
|
-
await db.run("DELETE FROM stewards WHERE id = ?", [id]);
|
|
2039
|
-
}
|
|
2040
|
-
var VALID_STEWARD_ROLES = ["editor", "reviewer", "viewer"];
|
|
2041
|
-
async function updateSteward(id, update) {
|
|
2042
|
-
const db = getDb();
|
|
2043
|
-
const current = await db.get(
|
|
2044
|
-
"SELECT * FROM stewards WHERE id = ? AND is_active = 1",
|
|
2045
|
-
[id]
|
|
2046
|
-
);
|
|
2047
|
-
if (!current) {
|
|
2048
|
-
throw new ValidationError("Steward not found");
|
|
2049
|
-
}
|
|
2050
|
-
const role = update.role ?? current.role;
|
|
2051
|
-
if (!VALID_STEWARD_ROLES.includes(role)) {
|
|
2052
|
-
throw new ValidationError(
|
|
2053
|
-
`Invalid role "${role}" \u2014 must be editor, reviewer, or viewer.`
|
|
2054
|
-
);
|
|
2055
|
-
}
|
|
2056
|
-
let scope = current.scope;
|
|
2057
|
-
let nodePattern = current.node_pattern ?? null;
|
|
2058
|
-
let tagName = current.tag_name ?? null;
|
|
2059
|
-
if (update.scope) {
|
|
2060
|
-
scope = update.scope;
|
|
2061
|
-
nodePattern = null;
|
|
2062
|
-
tagName = null;
|
|
2063
|
-
switch (scope) {
|
|
2064
|
-
case "document":
|
|
2065
|
-
if (!update.documentId)
|
|
2066
|
-
throw new ValidationError("documentId required for document scope");
|
|
2067
|
-
nodePattern = update.documentId;
|
|
2068
|
-
break;
|
|
2069
|
-
case "tag":
|
|
2070
|
-
if (!update.tagName)
|
|
2071
|
-
throw new ValidationError("tagName required for tag scope");
|
|
2072
|
-
tagName = update.tagName.trim().replace(/^#+/, "").toLowerCase();
|
|
2073
|
-
break;
|
|
2074
|
-
case "nest":
|
|
2075
|
-
break;
|
|
2076
|
-
}
|
|
2077
|
-
const dup = await db.get(
|
|
2078
|
-
`SELECT role FROM stewards
|
|
2079
|
-
WHERE nest_id = ? AND is_active = 1 AND id != ? AND scope = ?
|
|
2080
|
-
AND user_email = ?
|
|
2081
|
-
AND COALESCE(node_pattern, '') = COALESCE(?, '')
|
|
2082
|
-
AND COALESCE(tag_name, '') = COALESCE(?, '')`,
|
|
2083
|
-
[current.nest_id, id, scope, current.user_email, nodePattern, tagName]
|
|
2084
|
-
);
|
|
2085
|
-
if (dup) {
|
|
2086
|
-
const scopeLabel = scope === "document" ? `document "${nodePattern}"` : scope === "tag" ? `tag "#${tagName}"` : "this nest";
|
|
2087
|
-
throw new ConflictError(
|
|
2088
|
-
`"${current.user_email}" is already a steward of ${scopeLabel} with the "${dup.role}" role.`
|
|
2089
|
-
);
|
|
2090
|
-
}
|
|
2091
|
-
}
|
|
2092
|
-
await db.run(
|
|
2093
|
-
"UPDATE stewards SET role = ?, scope = ?, node_pattern = ?, tag_name = ? WHERE id = ?",
|
|
2094
|
-
[role, scope, nodePattern, tagName, id]
|
|
2095
|
-
);
|
|
2096
|
-
const row = await db.get("SELECT * FROM stewards WHERE id = ?", [id]);
|
|
2097
|
-
return rowToSteward(row);
|
|
2098
|
-
}
|
|
2099
|
-
async function updateStewardRole(id, role) {
|
|
2100
|
-
return updateSteward(id, { role });
|
|
2101
|
-
}
|
|
2102
|
-
async function getSteward(id) {
|
|
2103
|
-
const db = getDb();
|
|
2104
|
-
const row = await db.get("SELECT * FROM stewards WHERE id = ?", [id]);
|
|
2105
|
-
return row ? rowToSteward(row) : null;
|
|
2106
|
-
}
|
|
2107
|
-
async function getStewardsForNest(nestId) {
|
|
2108
|
-
const db = getDb();
|
|
2109
|
-
const rows = await db.all(
|
|
2110
|
-
"SELECT * FROM stewards WHERE nest_id = ? AND is_active = 1",
|
|
2111
|
-
[nestId]
|
|
2112
|
-
);
|
|
2113
|
-
return enrichWithTitles(nestId, rows.map(rowToSteward));
|
|
2114
|
-
}
|
|
2115
|
-
async function getStewardsForScope(params) {
|
|
2116
|
-
const db = getDb();
|
|
2117
|
-
let sql = "SELECT * FROM stewards WHERE nest_id = ? AND is_active = 1";
|
|
2118
|
-
const args = [params.nestId];
|
|
2119
|
-
if (params.scope) {
|
|
2120
|
-
sql += " AND scope = ?";
|
|
2121
|
-
args.push(params.scope);
|
|
2122
|
-
}
|
|
2123
|
-
if (params.scopeTarget) {
|
|
2124
|
-
sql += " AND (node_pattern = ? OR tag_name = ?)";
|
|
2125
|
-
args.push(params.scopeTarget, params.scopeTarget);
|
|
2126
|
-
}
|
|
2127
|
-
return (await db.all(sql, args)).map(rowToSteward);
|
|
2128
|
-
}
|
|
2129
|
-
async function listStewards(params) {
|
|
2130
|
-
const db = getDb();
|
|
2131
|
-
let sql = "SELECT * FROM stewards WHERE nest_id = ? AND is_active = 1";
|
|
2132
|
-
const args = [params.nestId];
|
|
2133
|
-
if (params.scope) {
|
|
2134
|
-
sql += " AND scope = ?";
|
|
2135
|
-
args.push(params.scope);
|
|
2136
|
-
}
|
|
2137
|
-
if (params.search) {
|
|
2138
|
-
sql += " AND (user_email LIKE ? OR tag_name LIKE ? OR node_pattern LIKE ?)";
|
|
2139
|
-
const like = `%${params.search.toLowerCase()}%`;
|
|
2140
|
-
args.push(like, like, like);
|
|
2141
|
-
}
|
|
2142
|
-
sql += " ORDER BY scope, COALESCE(node_pattern, tag_name, ''), user_email";
|
|
2143
|
-
const stewards = (await db.all(sql, args)).map(rowToSteward);
|
|
2144
|
-
return enrichWithTitles(params.nestId, stewards);
|
|
2145
|
-
}
|
|
2146
|
-
async function enrichWithTitles(nestId, stewards) {
|
|
2147
|
-
if (!stewards.some((s) => s.scope === "document")) return stewards;
|
|
2148
|
-
const titleByNodeId = await buildTitleMap(nestId);
|
|
2149
|
-
for (const s of stewards) {
|
|
2150
|
-
if (s.scope === "document" && s.nodePattern) {
|
|
2151
|
-
s.nodeTitle = titleByNodeId.get(s.nodePattern);
|
|
2152
|
-
}
|
|
2153
|
-
}
|
|
2154
|
-
return stewards;
|
|
2155
|
-
}
|
|
2156
|
-
async function createStewardRecord(params) {
|
|
2157
|
-
if (params.users.length === 0) {
|
|
2158
|
-
throw new Error("At least one user is required");
|
|
2159
|
-
}
|
|
2160
|
-
let nodePattern;
|
|
2161
|
-
let tagName;
|
|
2162
|
-
switch (params.scope) {
|
|
2163
|
-
case "document":
|
|
2164
|
-
if (!params.documentId) throw new Error("documentId required for document scope");
|
|
2165
|
-
nodePattern = params.documentId;
|
|
2166
|
-
break;
|
|
2167
|
-
case "tag":
|
|
2168
|
-
if (!params.tagName) throw new Error("tagName required for tag scope");
|
|
2169
|
-
tagName = params.tagName.trim().replace(/^#+/, "").toLowerCase();
|
|
2170
|
-
break;
|
|
2171
|
-
case "nest":
|
|
2172
|
-
break;
|
|
2173
|
-
}
|
|
2174
|
-
const db = getDb();
|
|
2175
|
-
const results = [];
|
|
2176
|
-
const actor = (params.assignedBy || "").trim().toLowerCase();
|
|
2177
|
-
const ownerEmail = (await getNestOwnerEmail(params.nestId) || "").toLowerCase();
|
|
2178
|
-
for (const user of params.users) {
|
|
2179
|
-
const email = user.email.trim().toLowerCase();
|
|
2180
|
-
if (!email) continue;
|
|
2181
|
-
if (!isEmailish(email)) {
|
|
2182
|
-
throw new ValidationError(`"${user.email}" is not a valid email address.`);
|
|
2183
|
-
}
|
|
2184
|
-
if (email === actor) {
|
|
2185
|
-
throw new ValidationError(
|
|
2186
|
-
"You already manage this nest, so you can't add yourself as a steward."
|
|
2187
|
-
);
|
|
2188
|
-
}
|
|
2189
|
-
if (ownerEmail && email === ownerEmail) {
|
|
2190
|
-
throw new ValidationError(
|
|
2191
|
-
"The nest owner already has full access and doesn't need a steward role."
|
|
2192
|
-
);
|
|
2193
|
-
}
|
|
2194
|
-
const existing = await db.get(
|
|
2195
|
-
`SELECT * FROM stewards
|
|
2196
|
-
WHERE nest_id = ? AND is_active = 1 AND scope = ? AND user_email = ?
|
|
2197
|
-
AND COALESCE(node_pattern, '') = COALESCE(?, '')
|
|
2198
|
-
AND COALESCE(tag_name, '') = COALESCE(?, '')`,
|
|
2199
|
-
[
|
|
2200
|
-
params.nestId,
|
|
2201
|
-
params.scope,
|
|
2202
|
-
email,
|
|
2203
|
-
nodePattern ?? null,
|
|
2204
|
-
tagName ?? null
|
|
2205
|
-
]
|
|
2206
|
-
);
|
|
2207
|
-
if (existing) {
|
|
2208
|
-
const scopeLabel = params.scope === "document" ? `document "${nodePattern}"` : params.scope === "tag" ? `tag "#${tagName}"` : "this nest";
|
|
2209
|
-
throw new ConflictError(
|
|
2210
|
-
`"${email}" is already a steward of ${scopeLabel} with the "${existing.role}" role. Remove the existing assignment first to change the role.`
|
|
2211
|
-
);
|
|
2212
|
-
}
|
|
2213
|
-
const userRow = await db.get(
|
|
2214
|
-
"SELECT id FROM users WHERE email = ?",
|
|
2215
|
-
[email]
|
|
2216
|
-
);
|
|
2217
|
-
let userId = userRow?.id;
|
|
2218
|
-
if (!userId) {
|
|
2219
|
-
const { hashPassword } = await import("./keys-73STFJJB.js");
|
|
2220
|
-
userId = uuid3();
|
|
2221
|
-
await db.run(
|
|
2222
|
-
"INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
|
|
2223
|
-
[userId, email, null, await hashPassword(uuid3())]
|
|
2224
|
-
);
|
|
2225
|
-
}
|
|
2226
|
-
const created = await assignSteward({
|
|
2227
|
-
nestId: params.nestId,
|
|
2228
|
-
scope: params.scope,
|
|
2229
|
-
nodePattern,
|
|
2230
|
-
tagName,
|
|
2231
|
-
userEmail: email,
|
|
2232
|
-
userId,
|
|
2233
|
-
role: user.role ?? "reviewer",
|
|
2234
|
-
assignedBy: params.assignedBy,
|
|
2235
|
-
assignedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2236
|
-
isActive: true
|
|
2237
|
-
});
|
|
2238
|
-
results.push(created);
|
|
2239
|
-
}
|
|
2240
|
-
await db.run(
|
|
2241
|
-
"UPDATE nests SET stewardship_enabled = 1 WHERE id = ? AND stewardship_enabled = 0",
|
|
2242
|
-
[params.nestId]
|
|
2243
|
-
);
|
|
2244
|
-
if (results.length > 0) {
|
|
2245
|
-
let scopeLabel;
|
|
2246
|
-
if (params.scope === "document") {
|
|
2247
|
-
const titles = await buildTitleMap(params.nestId);
|
|
2248
|
-
const docTitle = titles.get(nodePattern) || nodePattern;
|
|
2249
|
-
const capitalized = docTitle.charAt(0).toUpperCase() + docTitle.slice(1);
|
|
2250
|
-
scopeLabel = `"${capitalized}" document`;
|
|
2251
|
-
} else if (params.scope === "tag") {
|
|
2252
|
-
scopeLabel = `#${tagName}`;
|
|
2253
|
-
} else {
|
|
2254
|
-
scopeLabel = "the whole nest";
|
|
2255
|
-
}
|
|
2256
|
-
const shareNestName = await nestName(params.nestId);
|
|
2257
|
-
const link = docLink(params.nestId, void 0, params.baseUrl);
|
|
2258
|
-
for (const s of results) {
|
|
2259
|
-
void sendEmailToRecipient(s.userEmail, {
|
|
2260
|
-
status: "steward",
|
|
2261
|
-
docTitle: shareNestName,
|
|
2262
|
-
path: shareNestName,
|
|
2263
|
-
permission: s.role,
|
|
2264
|
-
scopeLabel,
|
|
2265
|
-
by: params.assignedBy || void 0,
|
|
2266
|
-
link
|
|
2267
|
-
});
|
|
2268
|
-
}
|
|
2269
|
-
}
|
|
2270
|
-
return results;
|
|
2271
|
-
}
|
|
2272
|
-
async function resolveStewardsForNode(nestId, nodeId) {
|
|
2273
|
-
return (await resolve(nestId, nodeId)).stewards;
|
|
2274
|
-
}
|
|
2275
|
-
async function resolveStewardsWithFallback(nestId, nodeId) {
|
|
2276
|
-
return resolve(nestId, nodeId);
|
|
2277
|
-
}
|
|
2278
|
-
async function resolve(nestId, nodeId) {
|
|
2279
|
-
const db = getDb();
|
|
2280
|
-
const rows = await db.all(
|
|
2281
|
-
`
|
|
2282
|
-
SELECT s.*, 1 AS priority, ('document: ' || s.node_pattern) AS match_source
|
|
2283
|
-
FROM stewards s
|
|
2284
|
-
WHERE s.nest_id = ? AND s.is_active = 1 AND s.scope = 'document'
|
|
2285
|
-
AND s.node_pattern = ?
|
|
2286
|
-
UNION ALL
|
|
2287
|
-
SELECT s.*, 2 AS priority, ('tag: ' || s.tag_name) AS match_source
|
|
2288
|
-
FROM stewards s
|
|
2289
|
-
JOIN node_tag_index nt
|
|
2290
|
-
ON nt.nest_id = s.nest_id
|
|
2291
|
-
AND nt.tag_name = s.tag_name
|
|
2292
|
-
WHERE s.nest_id = ? AND s.is_active = 1 AND s.scope = 'tag'
|
|
2293
|
-
AND nt.node_id = ?
|
|
2294
|
-
UNION ALL
|
|
2295
|
-
SELECT s.*, 3 AS priority, 'nest-level steward' AS match_source
|
|
2296
|
-
FROM stewards s
|
|
2297
|
-
WHERE s.nest_id = ? AND s.is_active = 1 AND s.scope = 'nest'
|
|
2298
|
-
ORDER BY priority ASC, user_email ASC
|
|
2299
|
-
`,
|
|
2300
|
-
[
|
|
2301
|
-
nestId,
|
|
2302
|
-
nodeId,
|
|
2303
|
-
// document branch
|
|
2304
|
-
nestId,
|
|
2305
|
-
nodeId,
|
|
2306
|
-
// tag branch
|
|
2307
|
-
nestId
|
|
2308
|
-
// nest branch
|
|
2309
|
-
]
|
|
2310
|
-
);
|
|
2311
|
-
const resolved = rows.map((row) => ({
|
|
2312
|
-
steward: rowToSteward(row),
|
|
2313
|
-
priority: row.priority,
|
|
2314
|
-
source: row.match_source
|
|
2315
|
-
}));
|
|
2316
|
-
if (resolved.length > 0) {
|
|
2317
|
-
return { stewards: resolved, fallbackToOwner: false };
|
|
2318
|
-
}
|
|
2319
|
-
const owner = await db.get(
|
|
2320
|
-
`SELECT u.email FROM nests n
|
|
2321
|
-
JOIN users u ON u.id = n.user_id
|
|
2322
|
-
WHERE n.id = ?`,
|
|
2323
|
-
[nestId]
|
|
2324
|
-
);
|
|
2325
|
-
return {
|
|
2326
|
-
stewards: [],
|
|
2327
|
-
fallbackToOwner: true,
|
|
2328
|
-
ownerEmail: owner?.email
|
|
2329
|
-
};
|
|
2330
|
-
}
|
|
2331
|
-
async function userIdForEmail(email) {
|
|
2332
|
-
const db = getDb();
|
|
2333
|
-
const row = await db.get(
|
|
2334
|
-
"SELECT id FROM users WHERE LOWER(email) = LOWER(?)",
|
|
2335
|
-
[email]
|
|
2336
|
-
);
|
|
2337
|
-
return row?.id;
|
|
2338
|
-
}
|
|
2339
|
-
async function getStewardRolesForUser(nestId, userEmail) {
|
|
2340
|
-
const db = getDb();
|
|
2341
|
-
const rows = await db.all(
|
|
2342
|
-
"SELECT DISTINCT role FROM stewards WHERE nest_id = ? AND is_active = 1 AND LOWER(user_email) = LOWER(?)",
|
|
2343
|
-
[nestId, userEmail]
|
|
2344
|
-
);
|
|
2345
|
-
return rows.map((r) => r.role);
|
|
2346
|
-
}
|
|
2347
|
-
async function getStewardsForUser(nestId, userEmail) {
|
|
2348
|
-
const db = getDb();
|
|
2349
|
-
const rows = await db.all(
|
|
2350
|
-
"SELECT * FROM stewards WHERE nest_id = ? AND is_active = 1 AND LOWER(user_email) = LOWER(?)",
|
|
2351
|
-
[nestId, userEmail]
|
|
2352
|
-
);
|
|
2353
|
-
return rows.map(rowToSteward);
|
|
2354
|
-
}
|
|
2355
|
-
async function getCollaboratorRole(nestId, userEmail) {
|
|
2356
|
-
const userId = await userIdForEmail(userEmail);
|
|
2357
|
-
if (!userId) return null;
|
|
2358
|
-
const db = getDb();
|
|
2359
|
-
const row = await db.get(
|
|
2360
|
-
"SELECT permission FROM nest_collaborators WHERE nest_id = ? AND user_id = ?",
|
|
2361
|
-
[nestId, userId]
|
|
2362
|
-
);
|
|
2363
|
-
return row?.permission ?? null;
|
|
2364
|
-
}
|
|
2365
|
-
async function resolveGrantRoles(nestId, userEmail, userId) {
|
|
2366
|
-
const [grantRaw, stewardRoles, teamRoles] = await Promise.all([
|
|
2367
|
-
getCollaboratorRole(nestId, userEmail),
|
|
2368
|
-
getStewardRolesForUser(nestId, userEmail),
|
|
2369
|
-
resolveTeamRolesForUser(nestId, userId)
|
|
2370
|
-
]);
|
|
2371
|
-
return [
|
|
2372
|
-
...new Set(
|
|
2373
|
-
[collabPermToRole(grantRaw), ...stewardRoles, ...teamRoles].filter(
|
|
2374
|
-
Boolean
|
|
2375
|
-
)
|
|
2376
|
-
)
|
|
2377
|
-
];
|
|
2378
|
-
}
|
|
2379
|
-
async function resolveUserRoles(nestId, userEmail, opts) {
|
|
2380
|
-
const roles = /* @__PURE__ */ new Set();
|
|
2381
|
-
if (isSuperAdmin(userEmail)) roles.add("admin");
|
|
2382
|
-
const owner = await getNestOwnerEmail(nestId);
|
|
2383
|
-
if (owner && owner.toLowerCase() === userEmail.toLowerCase()) {
|
|
2384
|
-
roles.add("owner");
|
|
2385
|
-
}
|
|
2386
|
-
const userId = await userIdForEmail(userEmail);
|
|
2387
|
-
if (userId) {
|
|
2388
|
-
const collabRole = collabPermToRole(await resolveNestPermission(nestId, userId));
|
|
2389
|
-
if (collabRole) roles.add(collabRole);
|
|
2390
|
-
for (const role of await resolveTeamRolesForUser(nestId, userId)) {
|
|
2391
|
-
roles.add(role);
|
|
2392
|
-
}
|
|
2393
|
-
}
|
|
2394
|
-
const stewardRoles = opts?.nodeId ? (await resolveStewardsForNode(nestId, opts.nodeId)).filter(
|
|
2395
|
-
(r) => r.steward.userEmail.toLowerCase() === userEmail.toLowerCase()
|
|
2396
|
-
).map((r) => r.steward.role) : await getStewardRolesForUser(nestId, userEmail);
|
|
2397
|
-
for (const role of stewardRoles) roles.add(role);
|
|
2398
|
-
return [...roles];
|
|
2399
|
-
}
|
|
2400
|
-
async function canManageStewards(nestId, userId) {
|
|
2401
|
-
if (config.AUTH_MODE === "open") return true;
|
|
2402
|
-
const perm = await resolveNestPermission(nestId, userId);
|
|
2403
|
-
return perm === "owner" || perm === "admin";
|
|
2404
|
-
}
|
|
2405
|
-
async function canCreateInNest(nestId, userEmail) {
|
|
2406
|
-
if (config.AUTH_MODE === "open" || isSuperAdmin(userEmail)) return true;
|
|
2407
|
-
const userId = await userIdForEmail(userEmail);
|
|
2408
|
-
if (userId) {
|
|
2409
|
-
const perm = await resolveNestPermission(nestId, userId);
|
|
2410
|
-
if (perm === "owner" || perm === "admin" || perm === "write") return true;
|
|
2411
|
-
}
|
|
2412
|
-
return (await getStewardsForUser(nestId, userEmail)).some(
|
|
2413
|
-
(s) => s.role === "editor" && s.scope === "nest"
|
|
2414
|
-
);
|
|
2415
|
-
}
|
|
2416
|
-
async function getNestOwnerEmail(nestId) {
|
|
2417
|
-
const db = getDb();
|
|
2418
|
-
const row = await db.get(
|
|
2419
|
-
`SELECT u.email FROM nests n
|
|
2420
|
-
JOIN users u ON u.id = n.user_id
|
|
2421
|
-
WHERE n.id = ?`,
|
|
2422
|
-
[nestId]
|
|
2423
|
-
);
|
|
2424
|
-
return row?.email ?? null;
|
|
2425
|
-
}
|
|
2426
|
-
async function canUserEdit(nestId, nodeId, userEmail) {
|
|
2427
|
-
const roles = await resolveUserRoles(nestId, userEmail, { nodeId });
|
|
2428
|
-
if (roles.includes("owner")) {
|
|
2429
|
-
return { allowed: true, reason: "nest owner", role: "owner" };
|
|
2430
|
-
}
|
|
2431
|
-
if (isSuperAdmin(userEmail)) {
|
|
2432
|
-
return { allowed: true, reason: "super admin", role: "super_admin" };
|
|
2433
|
-
}
|
|
2434
|
-
if (canEditWith(roles)) {
|
|
2435
|
-
return {
|
|
2436
|
-
allowed: true,
|
|
2437
|
-
reason: "editor access (collaborator or steward)",
|
|
2438
|
-
role: roles.includes("admin") ? "admin" : "editor"
|
|
2439
|
-
};
|
|
2440
|
-
}
|
|
2441
|
-
return { allowed: false, reason: "no editor role on this node", role: null };
|
|
2442
|
-
}
|
|
2443
|
-
async function getCurrentVersionAuthor(nestId, nodeId) {
|
|
2444
|
-
const db = getDb();
|
|
2445
|
-
const row = await db.get(
|
|
2446
|
-
`SELECT author FROM node_versions
|
|
2447
|
-
WHERE nest_id = ? AND node_id = ?
|
|
2448
|
-
ORDER BY version DESC LIMIT 1`,
|
|
2449
|
-
[nestId, nodeId]
|
|
2450
|
-
);
|
|
2451
|
-
return row?.author ?? null;
|
|
2452
|
-
}
|
|
2453
|
-
async function getPendingReviewRequester(nestId, nodeId) {
|
|
2454
|
-
const db = getDb();
|
|
2455
|
-
const row = await db.get(
|
|
2456
|
-
`SELECT requested_by FROM review_requests
|
|
2457
|
-
WHERE nest_id = ? AND node_id = ? AND status = 'pending'
|
|
2458
|
-
ORDER BY requested_at DESC LIMIT 1`,
|
|
2459
|
-
[nestId, nodeId]
|
|
2460
|
-
);
|
|
2461
|
-
return row?.requested_by ?? null;
|
|
2462
|
-
}
|
|
2463
|
-
async function canUserApprove(nestId, nodeId, userEmail) {
|
|
2464
|
-
const roles = await resolveUserRoles(nestId, userEmail, { nodeId });
|
|
2465
|
-
const isOwner = roles.includes("owner");
|
|
2466
|
-
const isSuper = isSuperAdmin(userEmail);
|
|
2467
|
-
const allowSelf = await nestAllowsSelfApprove(nestId);
|
|
2468
|
-
const hasStewardApprove = roles.includes("admin") || roles.includes("reviewer");
|
|
2469
|
-
const actor = await getPendingReviewRequester(nestId, nodeId) ?? await getCurrentVersionAuthor(nestId, nodeId);
|
|
2470
|
-
const isOwnSubmission = !!actor && actor.toLowerCase() === userEmail.toLowerCase();
|
|
2471
|
-
if (hasStewardApprove) {
|
|
2472
|
-
if (isOwnSubmission && !((isOwner || isSuper) && allowSelf)) {
|
|
2473
|
-
return {
|
|
2474
|
-
allowed: false,
|
|
2475
|
-
reason: "You submitted this version for review, so you can't approve it yourself. Ask another reviewer to approve it (separation of duties).",
|
|
2476
|
-
role: roles.includes("admin") ? "admin" : "reviewer"
|
|
2477
|
-
};
|
|
2478
|
-
}
|
|
2479
|
-
return {
|
|
2480
|
-
allowed: true,
|
|
2481
|
-
reason: "reviewer access (collaborator or steward)",
|
|
2482
|
-
role: roles.includes("admin") ? "admin" : "reviewer"
|
|
2483
|
-
};
|
|
2484
|
-
}
|
|
2485
|
-
if (isOwner || isSuper) {
|
|
2486
|
-
if (allowSelf) {
|
|
2487
|
-
return {
|
|
2488
|
-
allowed: true,
|
|
2489
|
-
reason: isOwner ? "nest owner (self-approve enabled)" : "super admin",
|
|
2490
|
-
role: isOwner ? "owner" : "super_admin"
|
|
2491
|
-
};
|
|
2492
|
-
}
|
|
2493
|
-
return {
|
|
2494
|
-
allowed: false,
|
|
2495
|
-
reason: "Approvals go to assigned reviewers while self-approve is off. Enable self-approve in nest settings to approve directly (e.g. while seeding), or add a reviewer steward.",
|
|
2496
|
-
role: isOwner ? "owner" : "super_admin"
|
|
2497
|
-
};
|
|
2498
|
-
}
|
|
2499
|
-
const held = primaryRole(roles);
|
|
2500
|
-
return {
|
|
2501
|
-
allowed: false,
|
|
2502
|
-
reason: held ? `You're a "${held}" on this document \u2014 only reviewers (or admins) can approve. Ask the nest owner to grant you the reviewer steward role.` : "You're not a steward on this document, so you can't approve it. Ask the nest owner to add you as a reviewer.",
|
|
2503
|
-
role: held
|
|
2504
|
-
};
|
|
2505
|
-
}
|
|
2506
|
-
async function canUserAccess(nestId, nodeId, userEmail) {
|
|
2507
|
-
const roles = await resolveUserRoles(nestId, userEmail, { nodeId });
|
|
2508
|
-
if (roles.includes("owner")) {
|
|
2509
|
-
return { allowed: true, reason: "nest owner", role: "owner" };
|
|
2510
|
-
}
|
|
2511
|
-
if (isSuperAdmin(userEmail)) {
|
|
2512
|
-
return { allowed: true, reason: "super admin", role: "super_admin" };
|
|
2513
|
-
}
|
|
2514
|
-
if (canViewWith(roles)) {
|
|
2515
|
-
return {
|
|
2516
|
-
allowed: true,
|
|
2517
|
-
reason: "collaborator or steward access",
|
|
2518
|
-
role: primaryRole(roles)
|
|
2519
|
-
};
|
|
2520
|
-
}
|
|
2521
|
-
return { allowed: false, reason: "no collaborator or steward access", role: null };
|
|
2522
|
-
}
|
|
2523
|
-
async function syncFromConfig(nestId, config2) {
|
|
2524
|
-
const db = getDb();
|
|
2525
|
-
let count = 0;
|
|
2526
|
-
await db.run(
|
|
2527
|
-
"UPDATE stewards SET is_active = 0 WHERE nest_id = ?",
|
|
2528
|
-
[nestId]
|
|
2529
|
-
);
|
|
2530
|
-
await db.run(
|
|
2531
|
-
"UPDATE nests SET stewardship_enabled = 1 WHERE id = ?",
|
|
2532
|
-
[nestId]
|
|
2533
|
-
);
|
|
2534
|
-
const addEntries = async (scope, entries, target) => {
|
|
2535
|
-
for (const entry of entries) {
|
|
2536
|
-
const user = await db.get(
|
|
2537
|
-
"SELECT id FROM users WHERE email = ?",
|
|
2538
|
-
[entry.email]
|
|
2539
|
-
);
|
|
2540
|
-
const rawRole = entry.role || "reviewer";
|
|
2541
|
-
const role = rawRole === "admin" ? "reviewer" : rawRole;
|
|
2542
|
-
await assignSteward({
|
|
2543
|
-
nestId,
|
|
2544
|
-
scope,
|
|
2545
|
-
nodePattern: target?.nodePattern,
|
|
2546
|
-
tagName: target?.tagName ? target.tagName.trim().replace(/^#+/, "").toLowerCase() : void 0,
|
|
2547
|
-
userEmail: entry.email.toLowerCase(),
|
|
2548
|
-
userId: user?.id,
|
|
2549
|
-
role,
|
|
2550
|
-
assignedBy: "config",
|
|
2551
|
-
assignedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2552
|
-
isActive: true
|
|
2553
|
-
});
|
|
2554
|
-
count++;
|
|
2555
|
-
}
|
|
2556
|
-
};
|
|
2557
|
-
if (config2.nest) {
|
|
2558
|
-
await addEntries("nest", config2.nest);
|
|
2559
|
-
}
|
|
2560
|
-
if (config2.tags) {
|
|
2561
|
-
for (const [tagName, entries] of Object.entries(config2.tags)) {
|
|
2562
|
-
await addEntries("tag", entries, { tagName });
|
|
2563
|
-
}
|
|
2564
|
-
}
|
|
2565
|
-
if (config2.documents) {
|
|
2566
|
-
for (const [docPattern, entries] of Object.entries(config2.documents)) {
|
|
2567
|
-
await addEntries("document", entries, { nodePattern: docPattern });
|
|
2568
|
-
}
|
|
2569
|
-
}
|
|
2570
|
-
return count;
|
|
2571
|
-
}
|
|
2572
|
-
function rowToSteward(row) {
|
|
2573
|
-
return {
|
|
2574
|
-
id: row.id,
|
|
2575
|
-
nestId: row.nest_id,
|
|
2576
|
-
scope: row.scope,
|
|
2577
|
-
nodePattern: row.node_pattern || void 0,
|
|
2578
|
-
tagName: row.tag_name || void 0,
|
|
2579
|
-
userEmail: row.user_email,
|
|
2580
|
-
userId: row.user_id || void 0,
|
|
2581
|
-
role: row.role,
|
|
2582
|
-
assignedBy: row.assigned_by,
|
|
2583
|
-
assignedAt: row.assigned_at,
|
|
2584
|
-
isActive: !!row.is_active
|
|
2585
|
-
};
|
|
2586
|
-
}
|
|
2587
|
-
|
|
2588
2148
|
export {
|
|
2149
|
+
nowExpr,
|
|
2150
|
+
insertOrIgnore,
|
|
2151
|
+
insertOrReplace,
|
|
2589
2152
|
trackEvent,
|
|
2590
2153
|
startTelemetryLoop,
|
|
2591
2154
|
persistSetting,
|
|
@@ -2608,11 +2171,18 @@ export {
|
|
|
2608
2171
|
sendEmailToRecipient,
|
|
2609
2172
|
nestStorageRoot,
|
|
2610
2173
|
resolveNestPath,
|
|
2174
|
+
normalizeTag,
|
|
2175
|
+
syncNodeTags,
|
|
2176
|
+
removeNodeFromTagIndex,
|
|
2611
2177
|
uniqueNestName,
|
|
2612
2178
|
isStewardshipEnabled,
|
|
2613
2179
|
setStewardshipEnabled,
|
|
2614
2180
|
nestAllowsSelfApprove,
|
|
2615
2181
|
setAllowSelfApprove,
|
|
2182
|
+
nestReviewsPrimeOnly,
|
|
2183
|
+
setPrimeOnlyReview,
|
|
2184
|
+
nestPrimeTags,
|
|
2185
|
+
setPrimeTags,
|
|
2616
2186
|
disableStewardshipAndWipeGovernance,
|
|
2617
2187
|
renameNest,
|
|
2618
2188
|
createNest,
|
|
@@ -2625,7 +2195,10 @@ export {
|
|
|
2625
2195
|
deleteNest,
|
|
2626
2196
|
engineCache,
|
|
2627
2197
|
buildTitleMap,
|
|
2198
|
+
folderForNode,
|
|
2628
2199
|
describeNode,
|
|
2200
|
+
titleForNode,
|
|
2201
|
+
prettyFolderPath,
|
|
2629
2202
|
nestName,
|
|
2630
2203
|
docLink,
|
|
2631
2204
|
buildDocContext,
|
|
@@ -2643,30 +2216,14 @@ export {
|
|
|
2643
2216
|
listNestTeams,
|
|
2644
2217
|
shareTeamWithNest,
|
|
2645
2218
|
unshareTeamFromNest,
|
|
2219
|
+
resolveTeamRolesForUser,
|
|
2220
|
+
collabPermToRole,
|
|
2221
|
+
canViewWith,
|
|
2222
|
+
canEditWith,
|
|
2223
|
+
canApproveWith,
|
|
2224
|
+
primaryRole,
|
|
2646
2225
|
isServerAdminUserId,
|
|
2647
2226
|
resolveNestPermission,
|
|
2648
2227
|
permissionLevel,
|
|
2649
|
-
isPublicReader
|
|
2650
|
-
assignSteward,
|
|
2651
|
-
removeSteward,
|
|
2652
|
-
updateSteward,
|
|
2653
|
-
updateStewardRole,
|
|
2654
|
-
getSteward,
|
|
2655
|
-
getStewardsForNest,
|
|
2656
|
-
getStewardsForScope,
|
|
2657
|
-
listStewards,
|
|
2658
|
-
createStewardRecord,
|
|
2659
|
-
resolveStewardsForNode,
|
|
2660
|
-
resolveStewardsWithFallback,
|
|
2661
|
-
getStewardRolesForUser,
|
|
2662
|
-
getStewardsForUser,
|
|
2663
|
-
getCollaboratorRole,
|
|
2664
|
-
resolveGrantRoles,
|
|
2665
|
-
resolveUserRoles,
|
|
2666
|
-
canManageStewards,
|
|
2667
|
-
canCreateInNest,
|
|
2668
|
-
canUserEdit,
|
|
2669
|
-
canUserApprove,
|
|
2670
|
-
canUserAccess,
|
|
2671
|
-
syncFromConfig
|
|
2228
|
+
isPublicReader
|
|
2672
2229
|
};
|