@promptowl/contextnest-community 1.14.0 → 1.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONFIGURATION.md +15 -0
- package/dist/chunk-CLKZZZS2.js +624 -0
- package/dist/{chunk-JP67WXLA.js → chunk-D6HICFVW.js} +77 -3
- package/dist/{chunk-I5KYGMIT.js → chunk-JMJLNEXE.js} +78 -1
- package/dist/{chunk-KPK656BH.js → chunk-L7UCMGWZ.js} +478 -924
- package/dist/{chunk-IHGPZ6NY.js → chunk-OTGZQZKK.js} +1 -1
- package/dist/{chunk-MZ4IR5KH.js → chunk-QJVRZT6M.js} +356 -76
- package/dist/{client-3MGIP57D.js → client-OBS5HOTA.js} +1 -1
- package/dist/{grants-service-5ZFISCBH.js → grants-service-I535QVTW.js} +2 -2
- package/dist/index.js +2419 -1061
- package/dist/{migrations.postgres-BRXZY2GE.js → migrations.postgres-CYKO5CFV.js} +41 -1
- package/dist/review-service-V2GR6VM3.js +47 -0
- package/dist/{stewardship-service-G7IF7ZNP.js → stewardship-service-LTREGVSJ.js} +4 -4
- package/dist/{version-service-FGL47APL.js → version-service-V6K5SZ3P.js} +11 -3
- package/dist/web3/assets/index-DnQARUL3.css +1 -0
- package/dist/web3/assets/index-_m7WyozM.js +1051 -0
- package/dist/web3/index.html +2 -2
- package/package.json +4 -3
- 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-JMJLNEXE.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";
|
|
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')";
|
|
152
22
|
}
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// src/shared/paths.ts
|
|
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,8 +458,113 @@ async function _validateLicenseImpl(forceFresh) {
|
|
|
640
458
|
}
|
|
641
459
|
}
|
|
642
460
|
|
|
643
|
-
// src/governance/
|
|
644
|
-
import {
|
|
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()));
|
|
471
|
+
}
|
|
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;
|
|
486
|
+
}
|
|
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
|
+
}
|
|
645
568
|
|
|
646
569
|
// src/notify/email-render.ts
|
|
647
570
|
function escapeHtml(s) {
|
|
@@ -669,9 +592,24 @@ var STATUS_META = {
|
|
|
669
592
|
},
|
|
670
593
|
shared: { subjectWord: "shared with you", label: "Shared", color: "#2563eb" },
|
|
671
594
|
invited: { subjectWord: "invited you", label: "Invitation", color: "#2563eb" },
|
|
672
|
-
steward: { subjectWord: "steward", label: "Steward", color: "#7c3aed" }
|
|
673
|
-
|
|
674
|
-
|
|
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 rejected",
|
|
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;
|
|
675
613
|
var detailRow = (label, valueHtml) => `<tr>
|
|
676
614
|
<td style="padding:5px 0;font-size:13px;color:#6b7280;width:90px;vertical-align:top;">${label}</td>
|
|
677
615
|
<td style="padding:5px 0;font-size:13px;color:#111827;vertical-align:top;">${valueHtml}</td>
|
|
@@ -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 reject the request from the nest.`,
|
|
687
|
+
lineH: (x) => `${x.doc} has been flagged for deletion${x.byH}${x.noteH}. Delete it or reject the request from the nest.`,
|
|
688
|
+
rows: governanceRows
|
|
689
|
+
},
|
|
690
|
+
deletion_declined: {
|
|
691
|
+
heading: (x) => `Your deletion request for ${x.title} was rejected`,
|
|
692
|
+
detailsHeader: "Document details",
|
|
693
|
+
linkLabel: "Open document",
|
|
694
|
+
lineT: (x) => `Your request to delete ${x.title} was rejected${x.byT}${x.noteT}. The document stays.`,
|
|
695
|
+
lineH: (x) => `Your request to delete ${x.doc} was rejected${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,6 +972,225 @@ async function sendEmailToRecipient(to, details) {
|
|
|
1008
972
|
}
|
|
1009
973
|
}
|
|
1010
974
|
|
|
975
|
+
// src/nests/service.ts
|
|
976
|
+
import { mkdirSync } from "fs";
|
|
977
|
+
import { v4 as uuid2 } from "uuid";
|
|
978
|
+
import { NestStorage as NestStorage3 } from "@promptowl/contextnest-engine";
|
|
979
|
+
|
|
980
|
+
// src/shared/paths.ts
|
|
981
|
+
import { join as join2 } from "path";
|
|
982
|
+
function nestStorageRoot() {
|
|
983
|
+
return config.NEST_STORAGE_ROOT;
|
|
984
|
+
}
|
|
985
|
+
function resolveNestPath(nestId) {
|
|
986
|
+
return join2(nestStorageRoot(), nestId);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// src/governance/teams-service.ts
|
|
990
|
+
import { v4 as uuid } from "uuid";
|
|
991
|
+
|
|
992
|
+
// src/nodes/engine.ts
|
|
993
|
+
import {
|
|
994
|
+
NestStorage as NestStorage2,
|
|
995
|
+
GraphQueryEngine,
|
|
996
|
+
VersionManager
|
|
997
|
+
} from "@promptowl/contextnest-engine";
|
|
998
|
+
import { createEngineApi } from "@promptowl/contextnest-engine/api";
|
|
999
|
+
|
|
1000
|
+
// src/nodes/flat-storage.ts
|
|
1001
|
+
import { readdir } from "fs/promises";
|
|
1002
|
+
import { join as join3, relative, sep } from "path";
|
|
1003
|
+
import { NestStorage } from "@promptowl/contextnest-engine";
|
|
1004
|
+
|
|
1005
|
+
// src/fs/vault-import.ts
|
|
1006
|
+
function isSkippedSegment(segment) {
|
|
1007
|
+
return segment.startsWith(".") || segment === "node_modules" || segment === "_suggestions";
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// src/shared/batch.ts
|
|
1011
|
+
var IO_CONCURRENCY = 32;
|
|
1012
|
+
async function mapBatched(items, fn, concurrency = IO_CONCURRENCY) {
|
|
1013
|
+
const out = [];
|
|
1014
|
+
for (let i = 0; i < items.length; i += concurrency) {
|
|
1015
|
+
out.push(...await Promise.all(items.slice(i, i + concurrency).map(fn)));
|
|
1016
|
+
}
|
|
1017
|
+
return out;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// src/nodes/flat-storage.ts
|
|
1021
|
+
var META_FILES = /* @__PURE__ */ new Set([
|
|
1022
|
+
"INDEX.md",
|
|
1023
|
+
"CONTEXT.md",
|
|
1024
|
+
"CLAUDE.md",
|
|
1025
|
+
"GEMINI.md",
|
|
1026
|
+
"AGENTS.md",
|
|
1027
|
+
"QWEN.md"
|
|
1028
|
+
]);
|
|
1029
|
+
var FlatNestStorage = class extends NestStorage {
|
|
1030
|
+
async discoverDocuments() {
|
|
1031
|
+
const root = this.root;
|
|
1032
|
+
let entries;
|
|
1033
|
+
try {
|
|
1034
|
+
entries = await readdir(root, { recursive: true, withFileTypes: true });
|
|
1035
|
+
} catch (err) {
|
|
1036
|
+
console.error("[FlatNestStorage] cannot read folder", root, err);
|
|
1037
|
+
return [];
|
|
1038
|
+
}
|
|
1039
|
+
const ids = [];
|
|
1040
|
+
for (const e of entries) {
|
|
1041
|
+
if (!e.isFile() || !e.name.endsWith(".md")) continue;
|
|
1042
|
+
if (META_FILES.has(e.name)) continue;
|
|
1043
|
+
const dir = e.parentPath ?? e.path ?? root;
|
|
1044
|
+
const rel = relative(root, join3(dir, e.name)).split(sep).join("/");
|
|
1045
|
+
if (rel.split("/").some(isSkippedSegment)) continue;
|
|
1046
|
+
ids.push(rel.replace(/\.md$/, ""));
|
|
1047
|
+
}
|
|
1048
|
+
ids.sort();
|
|
1049
|
+
const read = await mapBatched(ids, async (id) => {
|
|
1050
|
+
try {
|
|
1051
|
+
return await this.readDocument(id);
|
|
1052
|
+
} catch (err) {
|
|
1053
|
+
console.error("[FlatNestStorage] skipped unreadable doc", id, err);
|
|
1054
|
+
return null;
|
|
1055
|
+
}
|
|
1056
|
+
});
|
|
1057
|
+
return read.filter((n) => n !== null);
|
|
1058
|
+
}
|
|
1059
|
+
};
|
|
1060
|
+
|
|
1061
|
+
// src/nodes/engine.ts
|
|
1062
|
+
var DISCOVERY_TTL_MS = 3e4;
|
|
1063
|
+
var DOCUMENT_WRITERS = [
|
|
1064
|
+
"writeDocument",
|
|
1065
|
+
"deleteDocument",
|
|
1066
|
+
"writeVaultFile",
|
|
1067
|
+
"init"
|
|
1068
|
+
];
|
|
1069
|
+
function withDiscoveryCache(storage) {
|
|
1070
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1071
|
+
const discover = storage.discoverDocuments.bind(storage);
|
|
1072
|
+
let generation = 0;
|
|
1073
|
+
storage.discoverDocuments = async (options = {}) => {
|
|
1074
|
+
const key = options.includeRetired || options.includeSuperseded ? "all" : "live";
|
|
1075
|
+
const hit = entries.get(key);
|
|
1076
|
+
if (hit && Date.now() - hit.ts < DISCOVERY_TTL_MS) return hit.docs;
|
|
1077
|
+
const startedAt = generation;
|
|
1078
|
+
const docs = await discover(options);
|
|
1079
|
+
if (generation === startedAt) entries.set(key, { docs, ts: Date.now() });
|
|
1080
|
+
return docs;
|
|
1081
|
+
};
|
|
1082
|
+
const invalidate = () => {
|
|
1083
|
+
generation++;
|
|
1084
|
+
entries.clear();
|
|
1085
|
+
};
|
|
1086
|
+
for (const name of DOCUMENT_WRITERS) {
|
|
1087
|
+
const write = storage[name].bind(storage);
|
|
1088
|
+
storage[name] = async (...args) => {
|
|
1089
|
+
try {
|
|
1090
|
+
return await write(...args);
|
|
1091
|
+
} finally {
|
|
1092
|
+
invalidate();
|
|
1093
|
+
}
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
return { storage, invalidate };
|
|
1097
|
+
}
|
|
1098
|
+
var NestEngineCache = class {
|
|
1099
|
+
cache = /* @__PURE__ */ new Map();
|
|
1100
|
+
async get(nestId) {
|
|
1101
|
+
let engine = this.cache.get(nestId);
|
|
1102
|
+
if (!engine) {
|
|
1103
|
+
const nestPath = resolveNestPath(nestId);
|
|
1104
|
+
const { storage, invalidate } = withDiscoveryCache(
|
|
1105
|
+
await isImportedNest(nestId) ? new FlatNestStorage(nestPath) : new NestStorage2(nestPath)
|
|
1106
|
+
);
|
|
1107
|
+
const query = new GraphQueryEngine(storage);
|
|
1108
|
+
const versions = new VersionManager(storage);
|
|
1109
|
+
engine = { storage, query, versions, invalidateDiscovery: invalidate };
|
|
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
|
+
var engineApi = createEngineApi();
|
|
1120
|
+
async function opContext(nestId, actor, onProgress) {
|
|
1121
|
+
const { storage, query, versions } = await engineCache.get(nestId);
|
|
1122
|
+
return { storage, query, versions, actor, onProgress };
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
// src/governance/title-resolver.ts
|
|
1126
|
+
async function buildTitleMap(nestId) {
|
|
1127
|
+
try {
|
|
1128
|
+
const { storage } = await engineCache.get(nestId);
|
|
1129
|
+
const docs = await storage.discoverDocuments();
|
|
1130
|
+
return new Map(docs.map((d) => [d.id, d.frontmatter.title]));
|
|
1131
|
+
} catch {
|
|
1132
|
+
return /* @__PURE__ */ new Map();
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
function folderForNode(nodeId) {
|
|
1136
|
+
const segments = nodeId.split("/");
|
|
1137
|
+
const root = segments.indexOf("nodes");
|
|
1138
|
+
return segments.slice(root + 1, -1).join("/");
|
|
1139
|
+
}
|
|
1140
|
+
async function describeNode(nestId, nodeId) {
|
|
1141
|
+
const title = await titleForNode(nestId, nodeId);
|
|
1142
|
+
const folder = folderForNode(nodeId);
|
|
1143
|
+
return folder ? `${title} (in ${folder})` : title;
|
|
1144
|
+
}
|
|
1145
|
+
async function titleForNode(nestId, nodeId) {
|
|
1146
|
+
const titles = await buildTitleMap(nestId);
|
|
1147
|
+
return titles.get(nodeId) || titleFallback(titles, nodeId);
|
|
1148
|
+
}
|
|
1149
|
+
function titleFallback(titles, nodeId) {
|
|
1150
|
+
const at = nodeId.indexOf("nodes/");
|
|
1151
|
+
if (at > 0) {
|
|
1152
|
+
const normalized = nodeId.slice(at);
|
|
1153
|
+
const hit = titles.get(normalized);
|
|
1154
|
+
if (hit) return hit;
|
|
1155
|
+
}
|
|
1156
|
+
return nodeId.split("/").pop() || nodeId;
|
|
1157
|
+
}
|
|
1158
|
+
function prettyFolderPath(path) {
|
|
1159
|
+
return path.replace(/^nodes\//, "").split("/").filter(Boolean).map(
|
|
1160
|
+
(seg) => seg.split(/[-_]/).map((s) => s ? s[0].toUpperCase() + s.slice(1) : s).join(" ")
|
|
1161
|
+
).join(" / ");
|
|
1162
|
+
}
|
|
1163
|
+
async function nestName(nestId) {
|
|
1164
|
+
try {
|
|
1165
|
+
const row = await getDb().get("SELECT name FROM nests WHERE id = ?", [
|
|
1166
|
+
nestId
|
|
1167
|
+
]);
|
|
1168
|
+
return row?.name || nestId;
|
|
1169
|
+
} catch {
|
|
1170
|
+
return nestId;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
function docLink(nestId, nodeId, baseUrl) {
|
|
1174
|
+
const base = baseUrl || config.PUBLIC_BASE_URL;
|
|
1175
|
+
if (!base) return "";
|
|
1176
|
+
const q = new URLSearchParams({ nest: nestId });
|
|
1177
|
+
if (nodeId) q.set("doc", nodeId);
|
|
1178
|
+
return `${base.replace(/\/$/, "")}/?${q.toString()}`;
|
|
1179
|
+
}
|
|
1180
|
+
async function buildDocContext(nestId, nodeId, baseUrl) {
|
|
1181
|
+
const titles = await buildTitleMap(nestId);
|
|
1182
|
+
const docTitle = titles.get(nodeId) || nodeId;
|
|
1183
|
+
const folder = folderForNode(nodeId);
|
|
1184
|
+
const name = await nestName(nestId);
|
|
1185
|
+
return {
|
|
1186
|
+
docTitle,
|
|
1187
|
+
// Slack one-liner label ("Title (in Folder)"), same as describeNode().
|
|
1188
|
+
label: folder ? `${docTitle} (in ${folder})` : docTitle,
|
|
1189
|
+
path: folder ? `${name} / ${folder}` : name,
|
|
1190
|
+
link: docLink(nestId, nodeId, baseUrl)
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1011
1194
|
// src/governance/teams-service.ts
|
|
1012
1195
|
var VALID_TEAM_ROLES = ["admin", "editor", "viewer"];
|
|
1013
1196
|
function parseMembers(json) {
|
|
@@ -1467,6 +1650,7 @@ function teamRoleToPermission(role) {
|
|
|
1467
1650
|
var includesAny = (roles, wanted) => roles.some((r) => wanted.includes(r));
|
|
1468
1651
|
var canViewWith = (roles) => roles.length > 0;
|
|
1469
1652
|
var canEditWith = (roles) => includesAny(roles, ["owner", "admin", "editor"]);
|
|
1653
|
+
var canApproveWith = (roles) => includesAny(roles, ["owner", "admin", "reviewer"]);
|
|
1470
1654
|
var PRECEDENCE = [
|
|
1471
1655
|
"owner",
|
|
1472
1656
|
"admin",
|
|
@@ -1562,7 +1746,41 @@ async function isPublicReader(nestId, userId) {
|
|
|
1562
1746
|
return true;
|
|
1563
1747
|
}
|
|
1564
1748
|
|
|
1749
|
+
// src/governance/tag-index-service.ts
|
|
1750
|
+
function normalizeTag(raw) {
|
|
1751
|
+
return raw.trim().replace(/^#+/, "").toLowerCase();
|
|
1752
|
+
}
|
|
1753
|
+
async function syncNodeTags(nestId, nodeId, tags) {
|
|
1754
|
+
const db = getDb();
|
|
1755
|
+
const normalized = Array.from(
|
|
1756
|
+
new Set(
|
|
1757
|
+
tags.filter((t) => typeof t === "string").map(normalizeTag).filter(Boolean)
|
|
1758
|
+
)
|
|
1759
|
+
);
|
|
1760
|
+
const insertSql = insertOrIgnore(
|
|
1761
|
+
db,
|
|
1762
|
+
"INSERT INTO node_tag_index (nest_id, node_id, tag_name) VALUES (?, ?, ?)"
|
|
1763
|
+
);
|
|
1764
|
+
await db.transaction(async (tx) => {
|
|
1765
|
+
await tx.run(
|
|
1766
|
+
"DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?",
|
|
1767
|
+
[nestId, nodeId]
|
|
1768
|
+
);
|
|
1769
|
+
for (const tag of normalized) {
|
|
1770
|
+
await tx.run(insertSql, [nestId, nodeId, tag]);
|
|
1771
|
+
}
|
|
1772
|
+
});
|
|
1773
|
+
}
|
|
1774
|
+
async function removeNodeFromTagIndex(nestId, nodeId) {
|
|
1775
|
+
const db = getDb();
|
|
1776
|
+
await db.run(
|
|
1777
|
+
"DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?",
|
|
1778
|
+
[nestId, nodeId]
|
|
1779
|
+
);
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1565
1782
|
// src/nests/service.ts
|
|
1783
|
+
import { rm } from "fs/promises";
|
|
1566
1784
|
async function isImportedNest(nestId) {
|
|
1567
1785
|
const row = await getDb().get(
|
|
1568
1786
|
"SELECT is_imported FROM nests WHERE id = ?",
|
|
@@ -1619,6 +1837,43 @@ async function setAllowSelfApprove(nestId, allow) {
|
|
|
1619
1837
|
nestId
|
|
1620
1838
|
]);
|
|
1621
1839
|
}
|
|
1840
|
+
async function nestReviewsPrimeOnly(nestId) {
|
|
1841
|
+
const db = getDb();
|
|
1842
|
+
const row = await db.get(
|
|
1843
|
+
"SELECT prime_only_review FROM nests WHERE id = ?",
|
|
1844
|
+
[nestId]
|
|
1845
|
+
);
|
|
1846
|
+
return !!row?.prime_only_review;
|
|
1847
|
+
}
|
|
1848
|
+
async function setPrimeOnlyReview(nestId, enabled) {
|
|
1849
|
+
const db = getDb();
|
|
1850
|
+
await db.run("UPDATE nests SET prime_only_review = ? WHERE id = ?", [
|
|
1851
|
+
enabled ? 1 : 0,
|
|
1852
|
+
nestId
|
|
1853
|
+
]);
|
|
1854
|
+
}
|
|
1855
|
+
async function nestPrimeTags(nestId) {
|
|
1856
|
+
const db = getDb();
|
|
1857
|
+
const row = await db.get("SELECT prime_tags FROM nests WHERE id = ?", [
|
|
1858
|
+
nestId
|
|
1859
|
+
]);
|
|
1860
|
+
return parsePrimeTags(row?.prime_tags);
|
|
1861
|
+
}
|
|
1862
|
+
async function setPrimeTags(nestId, tags) {
|
|
1863
|
+
const db = getDb();
|
|
1864
|
+
await db.run("UPDATE nests SET prime_tags = ? WHERE id = ?", [
|
|
1865
|
+
parsePrimeTags(tags.join(",")).join(","),
|
|
1866
|
+
nestId
|
|
1867
|
+
]);
|
|
1868
|
+
}
|
|
1869
|
+
function parsePrimeTags(raw) {
|
|
1870
|
+
if (!raw) return [];
|
|
1871
|
+
return [
|
|
1872
|
+
...new Set(
|
|
1873
|
+
raw.split(",").map((t) => normalizeTag(t)).filter(Boolean)
|
|
1874
|
+
)
|
|
1875
|
+
];
|
|
1876
|
+
}
|
|
1622
1877
|
async function disableStewardshipAndWipeGovernance(nestId) {
|
|
1623
1878
|
const db = getDb();
|
|
1624
1879
|
return db.transaction(async (tx) => {
|
|
@@ -1700,32 +1955,21 @@ async function createNest(userId, name, description) {
|
|
|
1700
1955
|
);
|
|
1701
1956
|
const path = resolveNestPath(id);
|
|
1702
1957
|
mkdirSync(path, { recursive: true });
|
|
1703
|
-
const storage = new
|
|
1958
|
+
const storage = new NestStorage3(path);
|
|
1704
1959
|
await storage.init(trimmed);
|
|
1705
1960
|
trackEvent("nest.create", { nestId: id, userId });
|
|
1706
1961
|
return await db.get("SELECT * FROM nests WHERE id = ?", [id]);
|
|
1707
1962
|
}
|
|
1708
|
-
async function importNest(userId, name
|
|
1963
|
+
async function importNest(userId, name) {
|
|
1709
1964
|
const nm = (name || "").trim();
|
|
1710
1965
|
if (!nm) {
|
|
1711
1966
|
throw new ValidationError("name is required");
|
|
1712
1967
|
}
|
|
1713
|
-
if (!Array.isArray(files)) {
|
|
1714
|
-
throw new ValidationError("files are required");
|
|
1715
|
-
}
|
|
1716
1968
|
const db = getDb();
|
|
1717
1969
|
const id = uuid2();
|
|
1718
1970
|
const slug = toSlug(nm);
|
|
1719
1971
|
const visibility = userId === ANON_USER_ID ? "public" : "private";
|
|
1720
|
-
|
|
1721
|
-
mkdirSync(dest, { recursive: true });
|
|
1722
|
-
try {
|
|
1723
|
-
await writeImportedFiles(dest, files);
|
|
1724
|
-
} catch (err) {
|
|
1725
|
-
console.error("[nests] import write failed", dest, err);
|
|
1726
|
-
discardImportDir(dest);
|
|
1727
|
-
throw new ValidationError("Failed to import folder");
|
|
1728
|
-
}
|
|
1972
|
+
mkdirSync(resolveNestPath(id), { recursive: true });
|
|
1729
1973
|
await db.run(
|
|
1730
1974
|
"INSERT INTO nests (id, user_id, name, slug, description, visibility, is_imported) VALUES (?, ?, ?, ?, ?, ?, 1)",
|
|
1731
1975
|
[id, userId, nm, slug, null, visibility]
|
|
@@ -1889,703 +2133,17 @@ async function deleteNest(nestId) {
|
|
|
1889
2133
|
});
|
|
1890
2134
|
const path = resolveNestPath(nestId);
|
|
1891
2135
|
try {
|
|
1892
|
-
|
|
2136
|
+
await rm(path, { recursive: true, force: true });
|
|
1893
2137
|
} catch (err) {
|
|
1894
2138
|
console.warn(`[nests] failed to remove nest directory ${path}:`, err);
|
|
1895
2139
|
}
|
|
1896
2140
|
trackEvent("nest.delete", { nestId });
|
|
1897
2141
|
}
|
|
1898
2142
|
|
|
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
2143
|
export {
|
|
2144
|
+
nowExpr,
|
|
2145
|
+
insertOrIgnore,
|
|
2146
|
+
insertOrReplace,
|
|
2589
2147
|
trackEvent,
|
|
2590
2148
|
startTelemetryLoop,
|
|
2591
2149
|
persistSetting,
|
|
@@ -2608,11 +2166,18 @@ export {
|
|
|
2608
2166
|
sendEmailToRecipient,
|
|
2609
2167
|
nestStorageRoot,
|
|
2610
2168
|
resolveNestPath,
|
|
2169
|
+
normalizeTag,
|
|
2170
|
+
syncNodeTags,
|
|
2171
|
+
removeNodeFromTagIndex,
|
|
2611
2172
|
uniqueNestName,
|
|
2612
2173
|
isStewardshipEnabled,
|
|
2613
2174
|
setStewardshipEnabled,
|
|
2614
2175
|
nestAllowsSelfApprove,
|
|
2615
2176
|
setAllowSelfApprove,
|
|
2177
|
+
nestReviewsPrimeOnly,
|
|
2178
|
+
setPrimeOnlyReview,
|
|
2179
|
+
nestPrimeTags,
|
|
2180
|
+
setPrimeTags,
|
|
2616
2181
|
disableStewardshipAndWipeGovernance,
|
|
2617
2182
|
renameNest,
|
|
2618
2183
|
createNest,
|
|
@@ -2624,8 +2189,13 @@ export {
|
|
|
2624
2189
|
getNest,
|
|
2625
2190
|
deleteNest,
|
|
2626
2191
|
engineCache,
|
|
2192
|
+
engineApi,
|
|
2193
|
+
opContext,
|
|
2627
2194
|
buildTitleMap,
|
|
2195
|
+
folderForNode,
|
|
2628
2196
|
describeNode,
|
|
2197
|
+
titleForNode,
|
|
2198
|
+
prettyFolderPath,
|
|
2629
2199
|
nestName,
|
|
2630
2200
|
docLink,
|
|
2631
2201
|
buildDocContext,
|
|
@@ -2643,30 +2213,14 @@ export {
|
|
|
2643
2213
|
listNestTeams,
|
|
2644
2214
|
shareTeamWithNest,
|
|
2645
2215
|
unshareTeamFromNest,
|
|
2216
|
+
resolveTeamRolesForUser,
|
|
2217
|
+
collabPermToRole,
|
|
2218
|
+
canViewWith,
|
|
2219
|
+
canEditWith,
|
|
2220
|
+
canApproveWith,
|
|
2221
|
+
primaryRole,
|
|
2646
2222
|
isServerAdminUserId,
|
|
2647
2223
|
resolveNestPermission,
|
|
2648
2224
|
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
|
|
2225
|
+
isPublicReader
|
|
2672
2226
|
};
|