@promptowl/contextnest-community 1.13.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 +71 -1
- package/README.md +2 -0
- package/dist/{chunk-ZQS5W45U.js → chunk-5IAKUUZF.js} +1 -1
- package/dist/{chunk-Q5NRCDD4.js → chunk-7BOG3S5H.js} +1560 -1574
- package/dist/{chunk-5QQ7WKAI.js → chunk-BCFKLY4H.js} +76 -1
- package/dist/{chunk-PAIP5KHB.js → chunk-C4U75IUR.js} +790 -515
- package/dist/chunk-GWDOLYFJ.js +624 -0
- package/dist/{chunk-EG77SQHO.js → chunk-JWVZ3T35.js} +58 -3
- package/dist/{client-AAMF22YJ.js → client-NFUHPLLM.js} +1 -1
- package/dist/{grants-service-FZD6QM6V.js → grants-service-O4AO326A.js} +2 -2
- package/dist/index.js +1833 -814
- package/dist/{migrations.postgres-BRXZY2GE.js → migrations.postgres-CYKO5CFV.js} +41 -1
- package/dist/review-service-JUAFYB73.js +47 -0
- package/dist/{stewardship-service-B6L7UACX.js → stewardship-service-FHJ2RBKN.js} +4 -4
- package/dist/{version-service-L52F4WVZ.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 +3 -2
- package/dist/chunk-XQ46F76G.js +0 -16
- package/dist/review-service-Q3NDK5TX.js +0 -29
- package/dist/web3/assets/index-DcmDj50q.css +0 -1
- package/dist/web3/assets/index-EGPZ6MAy.js +0 -1023
|
@@ -4,208 +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
|
-
function safeSegments(rel) {
|
|
154
|
-
if (!rel || isAbsolute(rel)) return null;
|
|
155
|
-
const segs = rel.split(/[\\/]/).filter(Boolean);
|
|
156
|
-
if (!segs.length) return null;
|
|
157
|
-
for (const s of segs) {
|
|
158
|
-
if (s === "..") return null;
|
|
159
|
-
if (s !== ".versions" && isSkippedSegment(s)) return null;
|
|
160
|
-
}
|
|
161
|
-
return segs;
|
|
162
|
-
}
|
|
163
|
-
var WRITE_CONCURRENCY = 32;
|
|
164
|
-
async function writeImportedFiles(dest, files) {
|
|
165
|
-
const targets = [];
|
|
166
|
-
const skipped = [];
|
|
167
|
-
for (const f of files) {
|
|
168
|
-
const segs = safeSegments(f?.path || "");
|
|
169
|
-
if (!segs) {
|
|
170
|
-
skipped.push(f?.path || "(empty)");
|
|
171
|
-
continue;
|
|
172
|
-
}
|
|
173
|
-
targets.push({ full: join2(dest, ...segs), content: f.content ?? "" });
|
|
174
|
-
}
|
|
175
|
-
const dirs = new Set(targets.map((t) => dirname(t.full)));
|
|
176
|
-
await Promise.all([...dirs].map((d) => mkdir(d, { recursive: true })));
|
|
177
|
-
let written = 0;
|
|
178
|
-
for (let i = 0; i < targets.length; i += WRITE_CONCURRENCY) {
|
|
179
|
-
const batch = targets.slice(i, i + WRITE_CONCURRENCY);
|
|
180
|
-
await Promise.all(
|
|
181
|
-
batch.map(async (t) => {
|
|
182
|
-
await writeFile(t.full, t.content, "utf-8");
|
|
183
|
-
written++;
|
|
184
|
-
})
|
|
185
|
-
);
|
|
186
|
-
}
|
|
187
|
-
console.log(
|
|
188
|
-
`[import] writeImportedFiles: received=${files.length} written=${written} skipped=${skipped.length}`
|
|
189
|
-
);
|
|
190
|
-
if (skipped.length) {
|
|
191
|
-
console.log("[import] skipped (unsafe/hidden path):", skipped.join(", "));
|
|
192
|
-
}
|
|
193
|
-
return written;
|
|
194
|
-
}
|
|
195
|
-
function discardImportDir(dest) {
|
|
196
|
-
try {
|
|
197
|
-
rmSync(dest, { recursive: true, force: true });
|
|
198
|
-
} catch {
|
|
199
|
-
}
|
|
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')";
|
|
200
22
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
import { join as join3 } from "path";
|
|
204
|
-
function nestStorageRoot() {
|
|
205
|
-
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`;
|
|
206
25
|
}
|
|
207
|
-
function
|
|
208
|
-
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}`;
|
|
209
28
|
}
|
|
210
29
|
|
|
211
30
|
// src/telemetry/tracker.ts
|
|
@@ -639,1527 +458,1697 @@ async function _validateLicenseImpl(forceFresh) {
|
|
|
639
458
|
}
|
|
640
459
|
}
|
|
641
460
|
|
|
642
|
-
// src/governance/
|
|
643
|
-
import {
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
function rowToTeam(row) {
|
|
655
|
-
return {
|
|
656
|
-
id: row.id,
|
|
657
|
-
name: row.name,
|
|
658
|
-
owner_id: row.owner_id,
|
|
659
|
-
members: parseMembers(row.members),
|
|
660
|
-
source: row.source ?? null,
|
|
661
|
-
external_id: row.external_id ?? null,
|
|
662
|
-
created_at: row.created_at,
|
|
663
|
-
updated_at: row.updated_at
|
|
664
|
-
};
|
|
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()));
|
|
665
471
|
}
|
|
666
|
-
|
|
667
|
-
const
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
+
}
|
|
676
483
|
}
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
]);
|
|
680
|
-
return {
|
|
681
|
-
...team,
|
|
682
|
-
owner_email: owner?.email,
|
|
683
|
-
// Unknown id (deleted user) → treat as not registered.
|
|
684
|
-
members: team.members.map((m) => ({
|
|
685
|
-
...m,
|
|
686
|
-
registered: (invited.get(m.userId) ?? 1) === 0
|
|
687
|
-
}))
|
|
688
|
-
};
|
|
484
|
+
accessConfig = null;
|
|
485
|
+
return null;
|
|
689
486
|
}
|
|
690
|
-
function
|
|
691
|
-
|
|
692
|
-
throw new ValidationError(
|
|
693
|
-
"role must be admin, editor, or viewer"
|
|
694
|
-
);
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
|
-
async function loadTeamRow(teamId) {
|
|
698
|
-
const db = getDb();
|
|
699
|
-
const row = await db.get("SELECT * FROM teams WHERE id = ?", [
|
|
700
|
-
teamId
|
|
701
|
-
]);
|
|
702
|
-
if (!row) throw new NotFoundError("Team not found");
|
|
703
|
-
return row;
|
|
704
|
-
}
|
|
705
|
-
var ROLE_RANK = {
|
|
706
|
-
viewer: 1,
|
|
707
|
-
editor: 2,
|
|
708
|
-
admin: 3
|
|
709
|
-
};
|
|
710
|
-
var OWNER_RANK = 4;
|
|
711
|
-
function managementRank(row, callerUserId, allowAdmin) {
|
|
712
|
-
if (row.owner_id === callerUserId || allowAdmin) return OWNER_RANK;
|
|
713
|
-
const m = parseMembers(row.members).find((x) => x.userId === callerUserId);
|
|
714
|
-
return m ? ROLE_RANK[m.role] ?? 0 : 0;
|
|
715
|
-
}
|
|
716
|
-
async function createTeam(params) {
|
|
717
|
-
const name = params.name?.trim();
|
|
718
|
-
if (!name) throw new ValidationError("Team name is required");
|
|
719
|
-
const db = getDb();
|
|
720
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
721
|
-
const id = uuid();
|
|
722
|
-
await db.run(
|
|
723
|
-
"INSERT INTO teams (id, name, owner_id, members, source, external_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
724
|
-
[
|
|
725
|
-
id,
|
|
726
|
-
name,
|
|
727
|
-
params.ownerId,
|
|
728
|
-
"[]",
|
|
729
|
-
params.source ?? null,
|
|
730
|
-
params.externalId ?? null,
|
|
731
|
-
now,
|
|
732
|
-
now
|
|
733
|
-
]
|
|
734
|
-
);
|
|
735
|
-
return enrichTeam(rowToTeam(await loadTeamRow(id)));
|
|
487
|
+
function getAccessConfig() {
|
|
488
|
+
return accessConfig;
|
|
736
489
|
}
|
|
737
|
-
|
|
738
|
-
const
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
[params.ownerId, params.source, params.externalId]
|
|
742
|
-
);
|
|
743
|
-
return row ? enrichTeam(rowToTeam(row)) : null;
|
|
490
|
+
function isSuperAdmin(email) {
|
|
491
|
+
const lower = email.toLowerCase();
|
|
492
|
+
if (grantedSuperAdmins.has(lower)) return true;
|
|
493
|
+
return isConfigSuperAdmin(lower);
|
|
744
494
|
}
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
"SELECT * FROM teams WHERE owner_id = ? OR members LIKE ? ORDER BY created_at",
|
|
749
|
-
[userId, `%${userId}%`]
|
|
750
|
-
);
|
|
751
|
-
const mine = rows.filter(
|
|
752
|
-
(r) => r.owner_id === userId || parseMembers(r.members).some((m) => m.userId === userId)
|
|
753
|
-
);
|
|
754
|
-
return Promise.all(mine.map((r) => enrichTeam(rowToTeam(r))));
|
|
495
|
+
function isConfigSuperAdmin(email) {
|
|
496
|
+
if (!accessConfig?.super_admins) return false;
|
|
497
|
+
return accessConfig.super_admins.map((e) => e.toLowerCase()).includes(email.toLowerCase());
|
|
755
498
|
}
|
|
756
|
-
|
|
757
|
-
|
|
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;
|
|
758
567
|
}
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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"
|
|
765
610
|
}
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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 = {
|
|
628
|
+
approved: {
|
|
629
|
+
heading: (x) => `${x.title} is ${x.meta.subjectWord}`,
|
|
630
|
+
detailsHeader: "Document details",
|
|
631
|
+
linkLabel: "Open document",
|
|
632
|
+
lineT: (x) => `${x.title}${x.vT} has been approved${x.byT}${x.noteT}.`,
|
|
633
|
+
lineH: (x) => `${x.doc}${x.vH} has been approved${x.byH}${x.noteH}.`,
|
|
634
|
+
rows: governanceRows
|
|
635
|
+
},
|
|
636
|
+
rejected: {
|
|
637
|
+
heading: (x) => `${x.title} is ${x.meta.subjectWord}`,
|
|
638
|
+
detailsHeader: "Document details",
|
|
639
|
+
linkLabel: "Open document",
|
|
640
|
+
lineT: (x) => `${x.title}${x.vT} has been rejected${x.byT}${x.noteT}.`,
|
|
641
|
+
lineH: (x) => `${x.doc}${x.vH} has been rejected${x.byH}${x.noteH}.`,
|
|
642
|
+
rows: governanceRows
|
|
643
|
+
},
|
|
644
|
+
pending_review: {
|
|
645
|
+
heading: (x) => `${x.title} is ${x.meta.subjectWord}`,
|
|
646
|
+
detailsHeader: "Document details",
|
|
647
|
+
linkLabel: "Open document",
|
|
648
|
+
lineT: (x) => `A review has been requested for ${x.title}${x.vT}${x.byT}${x.noteT}.`,
|
|
649
|
+
lineH: (x) => `A review has been requested for ${x.doc}${x.vH}${x.byH}${x.noteH}.`,
|
|
650
|
+
rows: governanceRows
|
|
651
|
+
},
|
|
652
|
+
shared: {
|
|
653
|
+
heading: (x) => `You've been added to ${x.title}`,
|
|
654
|
+
detailsHeader: "Details",
|
|
655
|
+
linkLabel: "Open nest",
|
|
656
|
+
lineT: (x) => `You've been given ${x.d.permission || "collaborator"} access to ${x.title}${x.d.by ? ` by ${x.d.by}` : ""}. Open it below to start collaborating.`,
|
|
657
|
+
lineH: (x) => `You've been given <strong>${escapeHtml(x.d.permission || "collaborator")}</strong> access to ${x.doc}${x.d.by ? ` by ${escapeHtml(x.d.by)}` : ""}. Open it below to start collaborating.`,
|
|
658
|
+
rows: (x) => [
|
|
659
|
+
plainRow("Nest", x.d.path),
|
|
660
|
+
...x.d.permission ? [plainRow("Role", cap(x.d.permission))] : []
|
|
661
|
+
]
|
|
662
|
+
},
|
|
663
|
+
invited: {
|
|
664
|
+
heading: (x) => `You've been invited to ${x.title}`,
|
|
665
|
+
detailsHeader: "Sign-in details",
|
|
666
|
+
linkLabel: "Sign in",
|
|
667
|
+
lineT: (x) => `You've been invited to ${x.title}${x.d.by ? ` by ${x.d.by}` : ""}. Sign in with your email and the temporary password below, then choose your own password.`,
|
|
668
|
+
lineH: (x) => `You've been invited to ${x.doc}${x.d.by ? ` by ${escapeHtml(x.d.by)}` : ""}. Sign in with your email and the temporary password below, then choose your own password.`,
|
|
669
|
+
rows: (x) => [
|
|
670
|
+
plainRow("Email", x.d.actor || ""),
|
|
671
|
+
...x.d.tempPassword ? [
|
|
672
|
+
{
|
|
673
|
+
label: "Temporary password",
|
|
674
|
+
text: x.d.tempPassword,
|
|
675
|
+
html: x.codeValue(x.d.tempPassword)
|
|
676
|
+
}
|
|
677
|
+
] : []
|
|
678
|
+
]
|
|
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
|
+
},
|
|
706
|
+
steward: {
|
|
707
|
+
heading: (x) => `You're now a steward of ${x.title}`,
|
|
708
|
+
detailsHeader: "Details",
|
|
709
|
+
linkLabel: "Open nest",
|
|
710
|
+
lineT: (x) => `You've been assigned as a ${x.d.permission || "reviewer"} steward of ${x.d.scopeLabel || x.title}${x.d.by ? ` by ${x.d.by}` : ""}. Open the nest below to start reviewing.`,
|
|
711
|
+
lineH: (x) => `You've been assigned as a <strong>${escapeHtml(x.d.permission || "reviewer")}</strong> steward of ${x.d.scopeLabel ? escapeHtml(x.d.scopeLabel) : x.doc}${x.d.by ? ` by ${escapeHtml(x.d.by)}` : ""}. Open the nest below to start reviewing.`,
|
|
712
|
+
rows: (x) => [
|
|
713
|
+
plainRow("Nest", x.d.path),
|
|
714
|
+
...x.d.permission ? [plainRow("Role", cap(x.d.permission))] : [],
|
|
715
|
+
...x.d.scopeLabel ? [plainRow("Scope", cap(x.d.scopeLabel))] : []
|
|
716
|
+
]
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
function renderEmail(d) {
|
|
720
|
+
const meta = STATUS_META[d.status];
|
|
721
|
+
const t = TEMPLATES[d.status];
|
|
722
|
+
const title = d.docTitle.replace(/[\r\n]+/g, " ").trim();
|
|
723
|
+
const vLabel = d.version != null ? `v${d.version}` : "";
|
|
724
|
+
const ctx = {
|
|
725
|
+
d,
|
|
726
|
+
title,
|
|
727
|
+
doc: `<strong>${escapeHtml(title)}</strong>`,
|
|
728
|
+
byT: d.actor ? ` by ${d.actor}` : "",
|
|
729
|
+
byH: d.actor ? ` by ${escapeHtml(d.actor)}` : "",
|
|
730
|
+
noteT: d.note ? ` with the note: "${d.note}"` : "",
|
|
731
|
+
noteH: d.note ? ` with the note: “${escapeHtml(d.note)}”` : "",
|
|
732
|
+
vT: vLabel ? ` ${vLabel}` : "",
|
|
733
|
+
vH: vLabel ? ` ${vLabel}` : "",
|
|
734
|
+
meta,
|
|
735
|
+
statusBadge: `<span style="display:inline-block;padding:2px 9px;border-radius:9999px;background:#f3f4f6;color:${meta.color};font-size:12px;font-weight:600;">${meta.label}</span>`,
|
|
736
|
+
codeValue: (v) => `<span style="font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:600;color:#111827;background:#f3f4f6;padding:2px 8px;border-radius:6px;">${escapeHtml(v)}</span>`
|
|
737
|
+
};
|
|
738
|
+
const heading = t.heading(ctx).replace(/[\r\n]+/g, " ").trim();
|
|
739
|
+
const subject = heading;
|
|
740
|
+
const detailsHeader = t.detailsHeader;
|
|
741
|
+
const linkLabel = t.linkLabel;
|
|
742
|
+
const lineH = t.lineH(ctx);
|
|
743
|
+
const rows = t.rows(ctx);
|
|
744
|
+
const detailLines = [
|
|
745
|
+
`${detailsHeader}:`,
|
|
746
|
+
...rows.map((r) => ` ${r.label}: ${r.text}`),
|
|
747
|
+
...d.link ? [` ${linkLabel}: ${d.link}`] : []
|
|
748
|
+
];
|
|
749
|
+
const text = ["Hi,", "", t.lineT(ctx), "", ...detailLines, "", "Regards,", "Community Nest"].join("\n");
|
|
750
|
+
const detailRows = rows.map((r) => detailRow(r.label, r.html)).join("");
|
|
751
|
+
const buttonRow = d.link ? `<tr><td style="padding:20px 24px 0;">
|
|
752
|
+
<table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>
|
|
753
|
+
<td bgcolor="${meta.color}" style="border-radius:6px;">
|
|
754
|
+
<a href="${escapeHtml(d.link)}" target="_blank" style="display:inline-block;padding:10px 20px;font-size:14px;font-weight:600;color:#ffffff;text-decoration:none;">${linkLabel}</a>
|
|
755
|
+
</td>
|
|
756
|
+
</tr></table>
|
|
757
|
+
</td></tr>` : "";
|
|
758
|
+
const html = `<div style="margin:0;padding:24px;background:#f6f8fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#111827;">
|
|
759
|
+
<table role="presentation" width="480" cellpadding="0" cellspacing="0" border="0" align="center" style="width:100%;max-width:480px;margin:0 auto;background:#ffffff;border:1px solid #e5e7eb;border-radius:12px;">
|
|
760
|
+
<tr><td style="padding:20px 24px 0;">
|
|
761
|
+
<div style="font-size:12px;font-weight:700;color:#6b7280;letter-spacing:0.06em;text-transform:uppercase;">ContextNest</div>
|
|
762
|
+
</td></tr>
|
|
763
|
+
<tr><td style="padding:10px 24px 0;">
|
|
764
|
+
<h1 style="margin:0;font-size:19px;line-height:1.35;font-weight:700;color:#111827;">${escapeHtml(heading)}</h1>
|
|
765
|
+
</td></tr>
|
|
766
|
+
<tr><td style="padding:16px 24px 0;font-size:14px;line-height:1.6;color:#374151;">
|
|
767
|
+
Hi,<br><br>${lineH}
|
|
768
|
+
</td></tr>
|
|
769
|
+
<tr><td style="padding:20px 24px 0;">
|
|
770
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border:1px solid #eef0f2;border-radius:8px;">
|
|
771
|
+
<tr><td style="padding:12px 16px;">
|
|
772
|
+
<div style="font-size:11px;font-weight:600;color:#9ca3af;letter-spacing:0.04em;text-transform:uppercase;padding-bottom:4px;">${detailsHeader}</div>
|
|
773
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">${detailRows}</table>
|
|
774
|
+
</td></tr>
|
|
775
|
+
</table>
|
|
776
|
+
</td></tr>
|
|
777
|
+
${buttonRow}
|
|
778
|
+
<tr><td style="padding:22px 24px 24px;font-size:14px;line-height:1.6;color:#374151;">
|
|
779
|
+
Regards,<br>Community Nest
|
|
780
|
+
</td></tr>
|
|
781
|
+
</table>
|
|
782
|
+
</div>`;
|
|
783
|
+
return { subject, text, html };
|
|
784
|
+
}
|
|
785
|
+
function renderHtml(message, nestName2) {
|
|
786
|
+
const lead = message.match(/^(:[a-z_]+:)\s*/);
|
|
787
|
+
const meta = lead && EVENT[lead[1]] || { icon: "\u{1F514}", color: "#475569" };
|
|
788
|
+
const rest = lead ? message.slice(lead[0].length) : message;
|
|
789
|
+
const body = escapeHtml(rest).replace(/\*([^*]+)\*/g, "<strong>$1</strong>").replace(/\n/g, "<br>");
|
|
790
|
+
const safeNest = escapeHtml(nestName2);
|
|
791
|
+
return `<div style="background:#f6f8fa;padding:24px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
|
|
792
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:480px;margin:0 auto;background:#ffffff;border:1px solid #e5e7eb;border-radius:12px;overflow:hidden;">
|
|
793
|
+
<tr><td style="padding:20px 24px 16px;border-bottom:1px solid #f0f0f0;">
|
|
794
|
+
<div style="font-size:15px;font-weight:700;color:#111827;letter-spacing:-0.01em;">ContextNest</div>
|
|
795
|
+
<div style="font-size:13px;color:#6b7280;margin-top:2px;">${safeNest}</div>
|
|
796
|
+
</td></tr>
|
|
797
|
+
<tr><td style="padding:24px;">
|
|
798
|
+
<table role="presentation" cellpadding="0" cellspacing="0"><tr>
|
|
799
|
+
<td valign="top" style="font-size:22px;line-height:1;padding-right:14px;">${meta.icon}</td>
|
|
800
|
+
<td valign="top" style="font-size:15px;line-height:1.55;color:#111827;border-left:3px solid ${meta.color};padding-left:14px;">${body}</td>
|
|
801
|
+
</tr></table>
|
|
802
|
+
</td></tr>
|
|
803
|
+
</table>
|
|
804
|
+
</div>`;
|
|
776
805
|
}
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
806
|
+
|
|
807
|
+
// src/notify/email.ts
|
|
808
|
+
var transporterPromise = null;
|
|
809
|
+
var configuredUrl = null;
|
|
810
|
+
var warnedOnce = false;
|
|
811
|
+
var SMTP_TIMEOUT_DEFAULTS = [
|
|
812
|
+
["connectionTimeout", 5e3],
|
|
813
|
+
["greetingTimeout", 5e3],
|
|
814
|
+
["socketTimeout", 1e4]
|
|
815
|
+
];
|
|
816
|
+
function withTimeouts(url) {
|
|
817
|
+
const query = url.includes("?") ? url.slice(url.indexOf("?") + 1) : "";
|
|
818
|
+
const missing = SMTP_TIMEOUT_DEFAULTS.filter(
|
|
819
|
+
([name]) => !new RegExp(`(^|&)${name}=`, "i").test(query)
|
|
820
|
+
).map(([name, ms]) => `${name}=${ms}`);
|
|
821
|
+
if (missing.length === 0) return url;
|
|
822
|
+
return url + (url.includes("?") ? "&" : "?") + missing.join("&");
|
|
823
|
+
}
|
|
824
|
+
var POOL_DEFAULTS = [
|
|
825
|
+
["pool", "true"],
|
|
826
|
+
["maxConnections", "1"],
|
|
827
|
+
["rateDelta", "1000"],
|
|
828
|
+
["rateLimit", "8"]
|
|
829
|
+
// messages per second
|
|
830
|
+
];
|
|
831
|
+
function withPool(url) {
|
|
832
|
+
const query = url.includes("?") ? url.slice(url.indexOf("?") + 1) : "";
|
|
833
|
+
const missing = POOL_DEFAULTS.filter(
|
|
834
|
+
([name]) => !new RegExp(`(^|&)${name}=`, "i").test(query)
|
|
835
|
+
).map(([name, val]) => `${name}=${val}`);
|
|
836
|
+
if (missing.length === 0) return url;
|
|
837
|
+
return url + (url.includes("?") ? "&" : "?") + missing.join("&");
|
|
838
|
+
}
|
|
839
|
+
async function verifySmtp(url) {
|
|
840
|
+
let transporter = null;
|
|
841
|
+
try {
|
|
842
|
+
const nodemailer = await import("nodemailer");
|
|
843
|
+
transporter = nodemailer.default.createTransport(withTimeouts(url), {
|
|
844
|
+
disableFileAccess: true,
|
|
845
|
+
disableUrlAccess: true
|
|
846
|
+
});
|
|
847
|
+
await transporter.verify();
|
|
848
|
+
return { ok: true };
|
|
849
|
+
} catch (err) {
|
|
850
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
851
|
+
} finally {
|
|
852
|
+
try {
|
|
853
|
+
transporter?.close?.();
|
|
854
|
+
} catch {
|
|
855
|
+
}
|
|
781
856
|
}
|
|
782
|
-
const db = getDb();
|
|
783
|
-
await db.transaction(async (tx) => {
|
|
784
|
-
await tx.run("DELETE FROM teams WHERE id = ?", [params.teamId]);
|
|
785
|
-
await removeTeamRefs(tx, params.teamId);
|
|
786
|
-
});
|
|
787
857
|
}
|
|
788
|
-
async function
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
858
|
+
async function getTransporter() {
|
|
859
|
+
const url = config.SMTP_URL;
|
|
860
|
+
if (!url) return null;
|
|
861
|
+
if (!transporterPromise || configuredUrl !== url) {
|
|
862
|
+
configuredUrl = url;
|
|
863
|
+
transporterPromise = import("nodemailer").then(
|
|
864
|
+
(nodemailer) => (
|
|
865
|
+
// withPool → connection pool + per-second rate limit, so a burst queues
|
|
866
|
+
// instead of tripping the provider's "too many emails" 550.
|
|
867
|
+
nodemailer.default.createTransport(withPool(withTimeouts(url)), {
|
|
868
|
+
// Per-message defaults (belt-and-braces; also set on each sendMail).
|
|
869
|
+
disableFileAccess: true,
|
|
870
|
+
disableUrlAccess: true
|
|
871
|
+
})
|
|
872
|
+
)
|
|
795
873
|
);
|
|
874
|
+
transporterPromise.catch(() => {
|
|
875
|
+
transporterPromise = null;
|
|
876
|
+
configuredUrl = null;
|
|
877
|
+
});
|
|
796
878
|
}
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
if (
|
|
806
|
-
|
|
807
|
-
|
|
879
|
+
return transporterPromise;
|
|
880
|
+
}
|
|
881
|
+
async function notifyEmailForNest(nestId, message, details) {
|
|
882
|
+
const from = config.NOTIFY_EMAIL_FROM;
|
|
883
|
+
const to = config.NOTIFY_EMAIL_TO;
|
|
884
|
+
if (!config.SMTP_URL || !from || !to) return;
|
|
885
|
+
try {
|
|
886
|
+
const transporter = await getTransporter();
|
|
887
|
+
if (!transporter) return;
|
|
888
|
+
let subject;
|
|
889
|
+
let text;
|
|
890
|
+
let html;
|
|
891
|
+
if (details) {
|
|
892
|
+
({ subject, text, html } = renderEmail(details));
|
|
893
|
+
} else {
|
|
894
|
+
let name = nestId;
|
|
895
|
+
try {
|
|
896
|
+
const row = await getDb().get("SELECT name FROM nests WHERE id = ?", [
|
|
897
|
+
nestId
|
|
898
|
+
]);
|
|
899
|
+
if (row?.name) name = row.name;
|
|
900
|
+
} catch {
|
|
901
|
+
}
|
|
902
|
+
const safeName = name.replace(/[\r\n]+/g, " ").trim();
|
|
903
|
+
subject = `ContextNest \xB7 ${safeName}`;
|
|
904
|
+
text = renderText(message);
|
|
905
|
+
html = renderHtml(message, safeName);
|
|
906
|
+
}
|
|
907
|
+
await transporter.sendMail({
|
|
908
|
+
from,
|
|
909
|
+
to,
|
|
910
|
+
subject,
|
|
911
|
+
text,
|
|
912
|
+
html,
|
|
913
|
+
disableFileAccess: true,
|
|
914
|
+
disableUrlAccess: true
|
|
915
|
+
});
|
|
916
|
+
} catch (err) {
|
|
917
|
+
if (!warnedOnce) {
|
|
918
|
+
warnedOnce = true;
|
|
919
|
+
console.warn(
|
|
920
|
+
"[email] notification failed (suppressing further warnings):",
|
|
921
|
+
err instanceof Error ? err.message : err
|
|
808
922
|
);
|
|
809
923
|
}
|
|
810
|
-
return [
|
|
811
|
-
...members,
|
|
812
|
-
{ userId: resolvedId, email: resolvedEmail, role: params.role }
|
|
813
|
-
];
|
|
814
|
-
});
|
|
815
|
-
return enrichTeam(rowToTeam(await loadTeamRow(params.teamId)));
|
|
816
|
-
}
|
|
817
|
-
async function resolveMemberIdentity(params) {
|
|
818
|
-
const db = getDb();
|
|
819
|
-
if (params.userId) {
|
|
820
|
-
const u = await db.get("SELECT email FROM users WHERE id = ?", [
|
|
821
|
-
params.userId
|
|
822
|
-
]);
|
|
823
|
-
if (!u) throw new ValidationError("User not found");
|
|
824
|
-
return { userId: params.userId, email: normalizeEmail(u.email ?? "") };
|
|
825
924
|
}
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
925
|
+
}
|
|
926
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
927
|
+
function recipientEnvelope(to, from) {
|
|
928
|
+
const recipients = Array.isArray(to) ? to : [to];
|
|
929
|
+
return recipients.length > 1 ? { to: from, bcc: recipients } : { to: recipients[0] };
|
|
930
|
+
}
|
|
931
|
+
function isRateLimited(err) {
|
|
932
|
+
const msg = String(err?.message ?? err);
|
|
933
|
+
const code = Number(err?.responseCode);
|
|
934
|
+
return /too many|rate limit|throttl|try again|4\.7\.\d|too many emails/i.test(msg) || code === 421 || code === 429 || code === 450 || code === 451 || code === 550 && /too many|rate/i.test(msg);
|
|
935
|
+
}
|
|
936
|
+
async function sendMailWithRetry(transporter, msg, attempts = 5) {
|
|
937
|
+
for (let i = 0; ; i++) {
|
|
938
|
+
try {
|
|
939
|
+
return await transporter.sendMail(msg);
|
|
940
|
+
} catch (err) {
|
|
941
|
+
if (i >= attempts - 1 || !isRateLimited(err)) throw err;
|
|
942
|
+
const wait = 1e3 * 2 ** i;
|
|
943
|
+
await sleep(wait);
|
|
944
|
+
}
|
|
829
945
|
}
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
);
|
|
834
|
-
if (
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
};
|
|
946
|
+
}
|
|
947
|
+
async function sendEmailToRecipient(to, details) {
|
|
948
|
+
const from = config.NOTIFY_EMAIL_FROM;
|
|
949
|
+
const hasRecipient = Array.isArray(to) ? to.length > 0 : !!to;
|
|
950
|
+
if (!config.SMTP_URL || !from || !hasRecipient) return;
|
|
951
|
+
try {
|
|
952
|
+
const transporter = await getTransporter();
|
|
953
|
+
if (!transporter) return;
|
|
954
|
+
const { subject, text, html } = renderEmail(details);
|
|
955
|
+
await sendMailWithRetry(transporter, {
|
|
956
|
+
from,
|
|
957
|
+
...recipientEnvelope(to, from),
|
|
958
|
+
subject,
|
|
959
|
+
text,
|
|
960
|
+
html,
|
|
961
|
+
disableFileAccess: true,
|
|
962
|
+
disableUrlAccess: true
|
|
963
|
+
});
|
|
964
|
+
} catch (err) {
|
|
965
|
+
if (!warnedOnce) {
|
|
966
|
+
warnedOnce = true;
|
|
967
|
+
console.warn(
|
|
968
|
+
"[email] notification failed (suppressing further warnings):",
|
|
969
|
+
err instanceof Error ? err.message : err
|
|
970
|
+
);
|
|
971
|
+
}
|
|
839
972
|
}
|
|
840
|
-
const { hashPassword } = await import("./keys-73STFJJB.js");
|
|
841
|
-
const userId = uuid();
|
|
842
|
-
await db.run(
|
|
843
|
-
"INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
|
|
844
|
-
[userId, requested, null, await hashPassword(uuid())]
|
|
845
|
-
);
|
|
846
|
-
return { userId, email: requested };
|
|
847
973
|
}
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
974
|
+
|
|
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";
|
|
986
|
+
}
|
|
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;
|
|
855
995
|
}
|
|
856
|
-
|
|
857
|
-
params.teamId,
|
|
858
|
-
() => params.members.map((m) => ({
|
|
859
|
-
userId: m.userId,
|
|
860
|
-
email: m.email,
|
|
861
|
-
role: m.role
|
|
862
|
-
}))
|
|
863
|
-
);
|
|
864
|
-
return enrichTeam(rowToTeam(await loadTeamRow(params.teamId)));
|
|
996
|
+
return segs;
|
|
865
997
|
}
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
const
|
|
869
|
-
const
|
|
870
|
-
|
|
871
|
-
|
|
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 ?? "" });
|
|
872
1009
|
}
|
|
873
|
-
|
|
874
|
-
|
|
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
|
+
})
|
|
1020
|
+
);
|
|
875
1021
|
}
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
});
|
|
882
|
-
return enrichTeam(rowToTeam(await loadTeamRow(params.teamId)));
|
|
883
|
-
}
|
|
884
|
-
async function removeMember(params) {
|
|
885
|
-
const row = await loadTeamRow(params.teamId);
|
|
886
|
-
if (managementRank(row, params.callerUserId, !!params.allowAdmin) < ROLE_RANK.admin) {
|
|
887
|
-
throw new ForbiddenError("Only a team admin or the owner can remove members.");
|
|
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(", "));
|
|
888
1027
|
}
|
|
889
|
-
|
|
890
|
-
const next = members.filter((m) => m.userId !== params.userId);
|
|
891
|
-
if (next.length === members.length) {
|
|
892
|
-
throw new NotFoundError("Team member not found");
|
|
893
|
-
}
|
|
894
|
-
return next;
|
|
895
|
-
});
|
|
896
|
-
return enrichTeam(rowToTeam(await loadTeamRow(params.teamId)));
|
|
897
|
-
}
|
|
898
|
-
async function mutateMembers(teamId, mutate) {
|
|
899
|
-
const db = getDb();
|
|
900
|
-
await db.transaction(async (tx) => {
|
|
901
|
-
const row = await tx.get("SELECT members FROM teams WHERE id = ?", [
|
|
902
|
-
teamId
|
|
903
|
-
]);
|
|
904
|
-
if (!row) throw new NotFoundError("Team not found");
|
|
905
|
-
const next = mutate(parseMembers(row.members));
|
|
906
|
-
await tx.run("UPDATE teams SET members = ?, updated_at = ? WHERE id = ?", [
|
|
907
|
-
JSON.stringify(next),
|
|
908
|
-
(/* @__PURE__ */ new Date()).toISOString(),
|
|
909
|
-
teamId
|
|
910
|
-
]);
|
|
911
|
-
});
|
|
1028
|
+
return written;
|
|
912
1029
|
}
|
|
913
|
-
function
|
|
914
|
-
if (!json) return [];
|
|
1030
|
+
function discardImportDir(dest) {
|
|
915
1031
|
try {
|
|
916
|
-
|
|
917
|
-
return Array.isArray(parsed) ? parsed : [];
|
|
1032
|
+
rmSync(dest, { recursive: true, force: true });
|
|
918
1033
|
} catch {
|
|
919
|
-
return [];
|
|
920
1034
|
}
|
|
921
1035
|
}
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
if (!row) throw new NotFoundError("Nest not found");
|
|
928
|
-
return parseSharedTeams(row.shared_teams);
|
|
1036
|
+
|
|
1037
|
+
// src/shared/paths.ts
|
|
1038
|
+
import { join as join3 } from "path";
|
|
1039
|
+
function nestStorageRoot() {
|
|
1040
|
+
return config.NEST_STORAGE_ROOT;
|
|
929
1041
|
}
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
return db.transaction(async (tx) => {
|
|
933
|
-
const team = await tx.get(
|
|
934
|
-
"SELECT id, name, owner_id, members FROM teams WHERE id = ?",
|
|
935
|
-
[params.teamId]
|
|
936
|
-
);
|
|
937
|
-
if (!team) throw new NotFoundError("Team not found");
|
|
938
|
-
if (managementRank(team, params.callerUserId, !!params.allowAdmin) < ROLE_RANK.editor) {
|
|
939
|
-
throw new ForbiddenError(
|
|
940
|
-
"You need to be an editor, admin, or owner of a team to share it. View-only members can't."
|
|
941
|
-
);
|
|
942
|
-
}
|
|
943
|
-
const nest = await tx.get("SELECT shared_teams FROM nests WHERE id = ?", [
|
|
944
|
-
params.nestId
|
|
945
|
-
]);
|
|
946
|
-
if (!nest) throw new NotFoundError("Nest not found");
|
|
947
|
-
const refs = parseSharedTeams(nest.shared_teams);
|
|
948
|
-
if (refs.some((r) => r.teamId === params.teamId)) {
|
|
949
|
-
throw new ConflictError("This team is already shared with the nest.");
|
|
950
|
-
}
|
|
951
|
-
refs.push({ teamId: team.id, teamName: team.name });
|
|
952
|
-
await tx.run("UPDATE nests SET shared_teams = ? WHERE id = ?", [
|
|
953
|
-
JSON.stringify(refs),
|
|
954
|
-
params.nestId
|
|
955
|
-
]);
|
|
956
|
-
const hasGovernanceRole = parseMembers(team.members).some(
|
|
957
|
-
(m) => m.role === "viewer" || m.role === "editor"
|
|
958
|
-
);
|
|
959
|
-
if (hasGovernanceRole) {
|
|
960
|
-
await tx.run("UPDATE nests SET stewardship_enabled = 1 WHERE id = ?", [
|
|
961
|
-
params.nestId
|
|
962
|
-
]);
|
|
963
|
-
}
|
|
964
|
-
return refs;
|
|
965
|
-
});
|
|
1042
|
+
function resolveNestPath(nestId) {
|
|
1043
|
+
return join3(nestStorageRoot(), nestId);
|
|
966
1044
|
}
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
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 [];
|
|
978
1077
|
}
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
const nests = await tx.all(
|
|
988
|
-
"SELECT id, shared_teams FROM nests WHERE shared_teams LIKE ?",
|
|
989
|
-
[`%${teamId}%`]
|
|
990
|
-
);
|
|
991
|
-
for (const n of nests) {
|
|
992
|
-
const refs = parseSharedTeams(n.shared_teams);
|
|
993
|
-
let changed = false;
|
|
994
|
-
for (const r of refs) {
|
|
995
|
-
if (r.teamId === teamId && r.teamName !== newName) {
|
|
996
|
-
r.teamName = newName;
|
|
997
|
-
changed = true;
|
|
998
|
-
}
|
|
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$/, ""));
|
|
999
1086
|
}
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
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
|
+
}
|
|
1005
1094
|
}
|
|
1095
|
+
return nodes;
|
|
1006
1096
|
}
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
)
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
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);
|
|
1021
1111
|
}
|
|
1112
|
+
return engine;
|
|
1022
1113
|
}
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
const refs = await listNestTeams(nestId).catch(() => []);
|
|
1026
|
-
if (refs.length === 0) return [];
|
|
1027
|
-
const db = getDb();
|
|
1028
|
-
const roles = /* @__PURE__ */ new Set();
|
|
1029
|
-
for (const ref of refs) {
|
|
1030
|
-
const row = await db.get("SELECT members FROM teams WHERE id = ?", [
|
|
1031
|
-
ref.teamId
|
|
1032
|
-
]);
|
|
1033
|
-
if (!row) continue;
|
|
1034
|
-
const member = parseMembers(row.members).find((m) => m.userId === userId);
|
|
1035
|
-
if (member) roles.add(member.role);
|
|
1114
|
+
evict(nestId) {
|
|
1115
|
+
this.cache.delete(nestId);
|
|
1036
1116
|
}
|
|
1037
|
-
|
|
1038
|
-
|
|
1117
|
+
};
|
|
1118
|
+
var engineCache = new NestEngineCache();
|
|
1039
1119
|
|
|
1040
|
-
// src/governance/
|
|
1041
|
-
function
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
return "editor";
|
|
1049
|
-
case "read":
|
|
1050
|
-
return "viewer";
|
|
1051
|
-
default:
|
|
1052
|
-
return null;
|
|
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();
|
|
1053
1128
|
}
|
|
1054
1129
|
}
|
|
1055
|
-
function
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
case "editor":
|
|
1060
|
-
return "write";
|
|
1061
|
-
case "viewer":
|
|
1062
|
-
return "read";
|
|
1063
|
-
default:
|
|
1064
|
-
return null;
|
|
1065
|
-
}
|
|
1130
|
+
function folderForNode(nodeId) {
|
|
1131
|
+
const segments = nodeId.split("/");
|
|
1132
|
+
const root = segments.indexOf("nodes");
|
|
1133
|
+
return segments.slice(root + 1, -1).join("/");
|
|
1066
1134
|
}
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
"owner",
|
|
1072
|
-
"admin",
|
|
1073
|
-
"editor",
|
|
1074
|
-
"reviewer",
|
|
1075
|
-
"viewer"
|
|
1076
|
-
];
|
|
1077
|
-
function primaryRole(roles) {
|
|
1078
|
-
for (const r of PRECEDENCE) if (roles.includes(r)) return r;
|
|
1079
|
-
return null;
|
|
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;
|
|
1080
1139
|
}
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
if (await isLicenseAdminUserId(userId)) return true;
|
|
1085
|
-
const row = await getDb().get(
|
|
1086
|
-
"SELECT email FROM users WHERE id = ?",
|
|
1087
|
-
[userId]
|
|
1088
|
-
);
|
|
1089
|
-
return !!row?.email && isSuperAdmin(row.email);
|
|
1140
|
+
async function titleForNode(nestId, nodeId) {
|
|
1141
|
+
const titles = await buildTitleMap(nestId);
|
|
1142
|
+
return titles.get(nodeId) || titleFallback(titles, nodeId);
|
|
1090
1143
|
}
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
};
|
|
1098
|
-
async function resolveNestPermission(nestId, userId) {
|
|
1099
|
-
const db = getDb();
|
|
1100
|
-
const nest = await db.get("SELECT user_id, visibility FROM nests WHERE id = ?", [nestId]);
|
|
1101
|
-
if (!nest) return "none";
|
|
1102
|
-
if (nest.user_id === userId) return "owner";
|
|
1103
|
-
if (nest.user_id === ANON_USER_ID && await isLicenseAdminUserId(userId)) {
|
|
1104
|
-
return "owner";
|
|
1105
|
-
}
|
|
1106
|
-
if (await isLicenseAdminUserId(userId)) return "admin";
|
|
1107
|
-
const caller = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
|
|
1108
|
-
if (caller?.email && isSuperAdmin(caller.email)) {
|
|
1109
|
-
return "admin";
|
|
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;
|
|
1110
1150
|
}
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
[
|
|
1116
|
-
);
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
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;
|
|
1121
1166
|
}
|
|
1122
|
-
if (best === "admin") return best;
|
|
1123
|
-
const stewardGrant = await db.get(
|
|
1124
|
-
`SELECT 1 FROM stewards s
|
|
1125
|
-
JOIN users u ON u.id = ?
|
|
1126
|
-
WHERE s.nest_id = ? AND s.is_active = 1
|
|
1127
|
-
AND LOWER(s.user_email) = LOWER(u.email)
|
|
1128
|
-
LIMIT 1`,
|
|
1129
|
-
[userId, nestId]
|
|
1130
|
-
);
|
|
1131
|
-
if (stewardGrant) best = higher(best, "read");
|
|
1132
|
-
if (nest.visibility === "public") best = higher(best, "read");
|
|
1133
|
-
return best;
|
|
1134
1167
|
}
|
|
1135
|
-
function
|
|
1136
|
-
|
|
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()}`;
|
|
1137
1174
|
}
|
|
1138
|
-
async function
|
|
1139
|
-
const
|
|
1140
|
-
const
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
const caller = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
|
|
1151
|
-
if (caller?.email && isSuperAdmin(caller.email)) return false;
|
|
1152
|
-
const stewardGrant = await db.get(
|
|
1153
|
-
`SELECT 1 FROM stewards s
|
|
1154
|
-
JOIN users u ON u.id = ?
|
|
1155
|
-
WHERE s.nest_id = ? AND s.is_active = 1
|
|
1156
|
-
AND LOWER(s.user_email) = LOWER(u.email)
|
|
1157
|
-
LIMIT 1`,
|
|
1158
|
-
[userId, nestId]
|
|
1159
|
-
);
|
|
1160
|
-
if (stewardGrant) return false;
|
|
1161
|
-
if ((await resolveTeamRolesForUser(nestId, userId)).length > 0) return false;
|
|
1162
|
-
return true;
|
|
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
|
+
};
|
|
1163
1187
|
}
|
|
1164
|
-
|
|
1165
|
-
// src/
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
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
|
+
}
|
|
1172
1199
|
}
|
|
1173
|
-
function
|
|
1174
|
-
return
|
|
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
|
+
};
|
|
1175
1211
|
}
|
|
1176
|
-
async function
|
|
1212
|
+
async function enrichTeam(team) {
|
|
1177
1213
|
const db = getDb();
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
const
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
[userId, slug]
|
|
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
|
|
1185
1220
|
);
|
|
1186
|
-
|
|
1187
|
-
n++;
|
|
1188
|
-
candidate = `${baseName} (${n})`;
|
|
1221
|
+
for (const r of rows) invited.set(r.id, r.is_invited);
|
|
1189
1222
|
}
|
|
1190
|
-
|
|
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
|
+
};
|
|
1191
1235
|
}
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
return !!row?.stewardship_enabled;
|
|
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
|
+
}
|
|
1199
1242
|
}
|
|
1200
|
-
async function
|
|
1243
|
+
async function loadTeamRow(teamId) {
|
|
1201
1244
|
const db = getDb();
|
|
1202
|
-
await db.
|
|
1203
|
-
|
|
1204
|
-
nestId
|
|
1245
|
+
const row = await db.get("SELECT * FROM teams WHERE id = ?", [
|
|
1246
|
+
teamId
|
|
1205
1247
|
]);
|
|
1248
|
+
if (!row) throw new NotFoundError("Team not found");
|
|
1249
|
+
return row;
|
|
1206
1250
|
}
|
|
1207
|
-
|
|
1251
|
+
var ROLE_RANK = {
|
|
1252
|
+
viewer: 1,
|
|
1253
|
+
editor: 2,
|
|
1254
|
+
admin: 3
|
|
1255
|
+
};
|
|
1256
|
+
var OWNER_RANK = 4;
|
|
1257
|
+
function managementRank(row, callerUserId, allowAdmin) {
|
|
1258
|
+
if (row.owner_id === callerUserId || allowAdmin) return OWNER_RANK;
|
|
1259
|
+
const m = parseMembers(row.members).find((x) => x.userId === callerUserId);
|
|
1260
|
+
return m ? ROLE_RANK[m.role] ?? 0 : 0;
|
|
1261
|
+
}
|
|
1262
|
+
async function createTeam(params) {
|
|
1263
|
+
const name = params.name?.trim();
|
|
1264
|
+
if (!name) throw new ValidationError("Team name is required");
|
|
1265
|
+
const db = getDb();
|
|
1266
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1267
|
+
const id = uuid();
|
|
1268
|
+
await db.run(
|
|
1269
|
+
"INSERT INTO teams (id, name, owner_id, members, source, external_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
1270
|
+
[
|
|
1271
|
+
id,
|
|
1272
|
+
name,
|
|
1273
|
+
params.ownerId,
|
|
1274
|
+
"[]",
|
|
1275
|
+
params.source ?? null,
|
|
1276
|
+
params.externalId ?? null,
|
|
1277
|
+
now,
|
|
1278
|
+
now
|
|
1279
|
+
]
|
|
1280
|
+
);
|
|
1281
|
+
return enrichTeam(rowToTeam(await loadTeamRow(id)));
|
|
1282
|
+
}
|
|
1283
|
+
async function findTeamByExternalId(params) {
|
|
1208
1284
|
const db = getDb();
|
|
1209
1285
|
const row = await db.get(
|
|
1210
|
-
"SELECT
|
|
1211
|
-
[
|
|
1286
|
+
"SELECT * FROM teams WHERE owner_id = ? AND source = ? AND external_id = ?",
|
|
1287
|
+
[params.ownerId, params.source, params.externalId]
|
|
1212
1288
|
);
|
|
1213
|
-
return
|
|
1289
|
+
return row ? enrichTeam(rowToTeam(row)) : null;
|
|
1214
1290
|
}
|
|
1215
|
-
async function
|
|
1291
|
+
async function listTeamsForUser(userId) {
|
|
1216
1292
|
const db = getDb();
|
|
1217
|
-
await db.
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1293
|
+
const rows = await db.all(
|
|
1294
|
+
"SELECT * FROM teams WHERE owner_id = ? OR members LIKE ? ORDER BY created_at",
|
|
1295
|
+
[userId, `%${userId}%`]
|
|
1296
|
+
);
|
|
1297
|
+
const mine = rows.filter(
|
|
1298
|
+
(r) => r.owner_id === userId || parseMembers(r.members).some((m) => m.userId === userId)
|
|
1299
|
+
);
|
|
1300
|
+
return Promise.all(mine.map((r) => enrichTeam(rowToTeam(r))));
|
|
1221
1301
|
}
|
|
1222
|
-
async function
|
|
1302
|
+
async function getTeam(teamId) {
|
|
1303
|
+
return enrichTeam(rowToTeam(await loadTeamRow(teamId)));
|
|
1304
|
+
}
|
|
1305
|
+
async function renameTeam(params) {
|
|
1306
|
+
const name = params.name?.trim();
|
|
1307
|
+
if (!name) throw new ValidationError("Team name is required");
|
|
1308
|
+
const row = await loadTeamRow(params.teamId);
|
|
1309
|
+
if (managementRank(row, params.callerUserId, !!params.allowAdmin) < ROLE_RANK.admin) {
|
|
1310
|
+
throw new ForbiddenError("Only a team admin or the owner can rename this team.");
|
|
1311
|
+
}
|
|
1223
1312
|
const db = getDb();
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
)
|
|
1231
|
-
await tx.run("UPDATE nests SET stewardship_enabled = 0 WHERE id = ?", [id]);
|
|
1232
|
-
return { stewards, pendingReviews };
|
|
1313
|
+
await db.transaction(async (tx) => {
|
|
1314
|
+
await tx.run("UPDATE teams SET name = ?, updated_at = ? WHERE id = ?", [
|
|
1315
|
+
name,
|
|
1316
|
+
(/* @__PURE__ */ new Date()).toISOString(),
|
|
1317
|
+
params.teamId
|
|
1318
|
+
]);
|
|
1319
|
+
await refreshTeamNameRefs(tx, params.teamId, name);
|
|
1233
1320
|
});
|
|
1321
|
+
return enrichTeam(rowToTeam(await loadTeamRow(params.teamId)));
|
|
1234
1322
|
}
|
|
1235
|
-
async function
|
|
1236
|
-
const
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
]);
|
|
1240
|
-
if (!existing) {
|
|
1241
|
-
throw new ValidationError("Nest not found");
|
|
1242
|
-
}
|
|
1243
|
-
const updates = [];
|
|
1244
|
-
const params = [];
|
|
1245
|
-
if (patch.name !== void 0) {
|
|
1246
|
-
const trimmed = patch.name.trim();
|
|
1247
|
-
if (!trimmed) throw new ValidationError("Name cannot be empty");
|
|
1248
|
-
const slug = toSlug(trimmed);
|
|
1249
|
-
if (!slug) throw new ValidationError("Name must contain at least one alphanumeric character");
|
|
1250
|
-
const conflict = await db.get(
|
|
1251
|
-
"SELECT id FROM nests WHERE user_id = ? AND slug = ? AND id != ? LIMIT 1",
|
|
1252
|
-
[existing.user_id, slug, nestId]
|
|
1253
|
-
);
|
|
1254
|
-
if (conflict) {
|
|
1255
|
-
throw new ValidationError(
|
|
1256
|
-
`Another nest already uses this name. Pick a different one.`
|
|
1257
|
-
);
|
|
1258
|
-
}
|
|
1259
|
-
updates.push("name = ?", "slug = ?");
|
|
1260
|
-
params.push(trimmed, slug);
|
|
1261
|
-
}
|
|
1262
|
-
if (patch.description !== void 0) {
|
|
1263
|
-
updates.push("description = ?");
|
|
1264
|
-
params.push(patch.description || null);
|
|
1265
|
-
}
|
|
1266
|
-
if (updates.length === 0) {
|
|
1267
|
-
return existing;
|
|
1323
|
+
async function deleteTeam(params) {
|
|
1324
|
+
const row = await loadTeamRow(params.teamId);
|
|
1325
|
+
if (managementRank(row, params.callerUserId, !!params.allowAdmin) < OWNER_RANK) {
|
|
1326
|
+
throw new ForbiddenError("Only the team owner can delete this team.");
|
|
1268
1327
|
}
|
|
1269
|
-
params.push(nestId);
|
|
1270
|
-
await db.run(
|
|
1271
|
-
`UPDATE nests SET ${updates.join(", ")} WHERE id = ?`,
|
|
1272
|
-
params
|
|
1273
|
-
);
|
|
1274
|
-
return await db.get("SELECT * FROM nests WHERE id = ?", [nestId]);
|
|
1275
|
-
}
|
|
1276
|
-
async function createNest(userId, name, description) {
|
|
1277
|
-
const id = uuid2();
|
|
1278
1328
|
const db = getDb();
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1329
|
+
await db.transaction(async (tx) => {
|
|
1330
|
+
await tx.run("DELETE FROM teams WHERE id = ?", [params.teamId]);
|
|
1331
|
+
await removeTeamRefs(tx, params.teamId);
|
|
1332
|
+
});
|
|
1333
|
+
}
|
|
1334
|
+
async function addMember(params) {
|
|
1335
|
+
assertValidRole(params.role);
|
|
1336
|
+
const row = await loadTeamRow(params.teamId);
|
|
1337
|
+
const rank = managementRank(row, params.callerUserId, !!params.allowAdmin);
|
|
1338
|
+
if (rank < ROLE_RANK.editor) {
|
|
1339
|
+
throw new ForbiddenError(
|
|
1340
|
+
"Only an editor or admin member (or the owner) can add people to this team."
|
|
1285
1341
|
);
|
|
1286
1342
|
}
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
[userId, slug]
|
|
1290
|
-
);
|
|
1291
|
-
if (conflict) {
|
|
1292
|
-
throw new ValidationError(
|
|
1293
|
-
"You already have a nest with this name. Pick a different one."
|
|
1294
|
-
);
|
|
1343
|
+
if (rank < OWNER_RANK && ROLE_RANK[params.role] > rank) {
|
|
1344
|
+
throw new ForbiddenError("You can't give a member a higher role than your own.");
|
|
1295
1345
|
}
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
)
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1346
|
+
if (!params.userId && params.email && !isEmailish(params.email)) {
|
|
1347
|
+
throw new ValidationError("Enter a valid email address.");
|
|
1348
|
+
}
|
|
1349
|
+
const { userId: resolvedId, email: resolvedEmail } = await resolveMemberIdentity(params);
|
|
1350
|
+
await mutateMembers(params.teamId, (members) => {
|
|
1351
|
+
if (members.some((m) => m.userId === resolvedId)) {
|
|
1352
|
+
throw new ConflictError(
|
|
1353
|
+
`${resolvedEmail || "This user"} is already a member of this team. Change their role instead of adding them again.`
|
|
1354
|
+
);
|
|
1355
|
+
}
|
|
1356
|
+
return [
|
|
1357
|
+
...members,
|
|
1358
|
+
{ userId: resolvedId, email: resolvedEmail, role: params.role }
|
|
1359
|
+
];
|
|
1360
|
+
});
|
|
1361
|
+
return enrichTeam(rowToTeam(await loadTeamRow(params.teamId)));
|
|
1307
1362
|
}
|
|
1308
|
-
async function
|
|
1309
|
-
const
|
|
1310
|
-
if (
|
|
1311
|
-
|
|
1363
|
+
async function resolveMemberIdentity(params) {
|
|
1364
|
+
const db = getDb();
|
|
1365
|
+
if (params.userId) {
|
|
1366
|
+
const u = await db.get("SELECT email FROM users WHERE id = ?", [
|
|
1367
|
+
params.userId
|
|
1368
|
+
]);
|
|
1369
|
+
if (!u) throw new ValidationError("User not found");
|
|
1370
|
+
return { userId: params.userId, email: normalizeEmail(u.email ?? "") };
|
|
1312
1371
|
}
|
|
1313
|
-
|
|
1314
|
-
|
|
1372
|
+
const requested = params.email ? normalizeEmail(params.email) : void 0;
|
|
1373
|
+
if (!requested) {
|
|
1374
|
+
throw new ValidationError("user_id or email is required");
|
|
1315
1375
|
}
|
|
1316
|
-
const
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
console.error("[nests] import write failed", dest, err);
|
|
1326
|
-
discardImportDir(dest);
|
|
1327
|
-
throw new ValidationError("Failed to import folder");
|
|
1376
|
+
const existing = await db.get(
|
|
1377
|
+
"SELECT id, email FROM users WHERE LOWER(email) = ?",
|
|
1378
|
+
[requested]
|
|
1379
|
+
);
|
|
1380
|
+
if (existing) {
|
|
1381
|
+
return {
|
|
1382
|
+
userId: existing.id,
|
|
1383
|
+
email: normalizeEmail(existing.email ?? requested)
|
|
1384
|
+
};
|
|
1328
1385
|
}
|
|
1386
|
+
const { hashPassword } = await import("./keys-73STFJJB.js");
|
|
1387
|
+
const userId = uuid();
|
|
1329
1388
|
await db.run(
|
|
1330
|
-
"INSERT INTO
|
|
1331
|
-
[
|
|
1389
|
+
"INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
|
|
1390
|
+
[userId, requested, null, await hashPassword(uuid())]
|
|
1332
1391
|
);
|
|
1333
|
-
|
|
1334
|
-
return await db.get("SELECT * FROM nests WHERE id = ?", [id]);
|
|
1392
|
+
return { userId, email: requested };
|
|
1335
1393
|
}
|
|
1336
|
-
async function
|
|
1337
|
-
const
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
);
|
|
1343
|
-
}
|
|
1344
|
-
const includeAnon = config.AUTH_MODE === "open" || await isLicenseAdminUserId(userId);
|
|
1345
|
-
if (!includeAnon) {
|
|
1346
|
-
return await db.all(
|
|
1347
|
-
"SELECT * FROM nests WHERE user_id = ? ORDER BY created_at DESC",
|
|
1348
|
-
[userId]
|
|
1394
|
+
async function replaceTeamRoster(params) {
|
|
1395
|
+
for (const m of params.members) assertValidRole(m.role);
|
|
1396
|
+
const row = await loadTeamRow(params.teamId);
|
|
1397
|
+
if (managementRank(row, params.callerUserId, !!params.allowAdmin) < ROLE_RANK.admin) {
|
|
1398
|
+
throw new ForbiddenError(
|
|
1399
|
+
"Only a team admin or the owner can replace this team's roster."
|
|
1349
1400
|
);
|
|
1350
1401
|
}
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1402
|
+
await mutateMembers(
|
|
1403
|
+
params.teamId,
|
|
1404
|
+
() => params.members.map((m) => ({
|
|
1405
|
+
userId: m.userId,
|
|
1406
|
+
email: m.email,
|
|
1407
|
+
role: m.role
|
|
1408
|
+
}))
|
|
1354
1409
|
);
|
|
1410
|
+
return enrichTeam(rowToTeam(await loadTeamRow(params.teamId)));
|
|
1355
1411
|
}
|
|
1356
|
-
async function
|
|
1357
|
-
|
|
1358
|
-
const
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
const myTeamIds = /* @__PURE__ */ new Set();
|
|
1363
|
-
for (const t of teams) {
|
|
1364
|
-
try {
|
|
1365
|
-
const members = JSON.parse(t.members || "[]");
|
|
1366
|
-
if (Array.isArray(members) && members.some((m) => m?.userId === userId)) {
|
|
1367
|
-
myTeamIds.add(t.id);
|
|
1368
|
-
}
|
|
1369
|
-
} catch {
|
|
1370
|
-
}
|
|
1412
|
+
async function updateMemberRole(params) {
|
|
1413
|
+
assertValidRole(params.role);
|
|
1414
|
+
const row = await loadTeamRow(params.teamId);
|
|
1415
|
+
const rank = managementRank(row, params.callerUserId, !!params.allowAdmin);
|
|
1416
|
+
if (rank < ROLE_RANK.admin) {
|
|
1417
|
+
throw new ForbiddenError("Only a team admin or the owner can change roles.");
|
|
1371
1418
|
}
|
|
1372
|
-
if (
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
return candidates.filter((n) => {
|
|
1381
|
-
try {
|
|
1382
|
-
const refs = JSON.parse(n.shared_teams || "[]");
|
|
1383
|
-
return Array.isArray(refs) && refs.some((r) => myTeamIds.has(r?.teamId));
|
|
1384
|
-
} catch {
|
|
1385
|
-
return false;
|
|
1386
|
-
}
|
|
1419
|
+
if (rank < OWNER_RANK && ROLE_RANK[params.role] > rank) {
|
|
1420
|
+
throw new ForbiddenError("You can't set a role higher than your own.");
|
|
1421
|
+
}
|
|
1422
|
+
await mutateMembers(params.teamId, (members) => {
|
|
1423
|
+
const member = members.find((m) => m.userId === params.userId);
|
|
1424
|
+
if (!member) throw new NotFoundError("Team member not found");
|
|
1425
|
+
member.role = params.role;
|
|
1426
|
+
return members;
|
|
1387
1427
|
});
|
|
1428
|
+
return enrichTeam(rowToTeam(await loadTeamRow(params.teamId)));
|
|
1388
1429
|
}
|
|
1389
|
-
async function
|
|
1390
|
-
const
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
LEFT JOIN nest_collaborators nc
|
|
1394
|
-
ON nc.nest_id = n.id AND nc.user_id = ?
|
|
1395
|
-
LEFT JOIN users u ON u.id = ?
|
|
1396
|
-
LEFT JOIN stewards s
|
|
1397
|
-
ON s.nest_id = n.id AND s.is_active = 1
|
|
1398
|
-
AND LOWER(s.user_email) = LOWER(u.email)
|
|
1399
|
-
WHERE n.user_id != ?
|
|
1400
|
-
AND (nc.user_id IS NOT NULL OR s.id IS NOT NULL)
|
|
1401
|
-
ORDER BY n.created_at DESC`,
|
|
1402
|
-
[userId, userId, userId]
|
|
1403
|
-
);
|
|
1404
|
-
const seen = new Set(direct.map((n) => n.id));
|
|
1405
|
-
for (const n of await nestsSharedViaTeams(userId)) {
|
|
1406
|
-
if (!seen.has(n.id)) {
|
|
1407
|
-
direct.push(n);
|
|
1408
|
-
seen.add(n.id);
|
|
1409
|
-
}
|
|
1430
|
+
async function removeMember(params) {
|
|
1431
|
+
const row = await loadTeamRow(params.teamId);
|
|
1432
|
+
if (managementRank(row, params.callerUserId, !!params.allowAdmin) < ROLE_RANK.admin) {
|
|
1433
|
+
throw new ForbiddenError("Only a team admin or the owner can remove members.");
|
|
1410
1434
|
}
|
|
1411
|
-
|
|
1412
|
-
const
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
);
|
|
1416
|
-
for (const n of anon) {
|
|
1417
|
-
if (!seen.has(n.id)) {
|
|
1418
|
-
direct.push(n);
|
|
1419
|
-
seen.add(n.id);
|
|
1420
|
-
}
|
|
1435
|
+
await mutateMembers(params.teamId, (members) => {
|
|
1436
|
+
const next = members.filter((m) => m.userId !== params.userId);
|
|
1437
|
+
if (next.length === members.length) {
|
|
1438
|
+
throw new NotFoundError("Team member not found");
|
|
1421
1439
|
}
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
);
|
|
1426
|
-
return direct;
|
|
1440
|
+
return next;
|
|
1441
|
+
});
|
|
1442
|
+
return enrichTeam(rowToTeam(await loadTeamRow(params.teamId)));
|
|
1427
1443
|
}
|
|
1428
|
-
async function
|
|
1444
|
+
async function mutateMembers(teamId, mutate) {
|
|
1429
1445
|
const db = getDb();
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
OR s.id IS NOT NULL
|
|
1443
|
-
${includeAnon ? "OR n.user_id = ?" : ""}
|
|
1444
|
-
ORDER BY n.created_at DESC`,
|
|
1445
|
-
params
|
|
1446
|
-
);
|
|
1447
|
-
const seen = new Set(rows.map((n) => n.id));
|
|
1448
|
-
for (const n of await nestsSharedViaTeams(userId)) {
|
|
1449
|
-
if (!seen.has(n.id)) {
|
|
1450
|
-
rows.push(n);
|
|
1451
|
-
seen.add(n.id);
|
|
1452
|
-
}
|
|
1453
|
-
}
|
|
1454
|
-
return rows;
|
|
1446
|
+
await db.transaction(async (tx) => {
|
|
1447
|
+
const row = await tx.get("SELECT members FROM teams WHERE id = ?", [
|
|
1448
|
+
teamId
|
|
1449
|
+
]);
|
|
1450
|
+
if (!row) throw new NotFoundError("Team not found");
|
|
1451
|
+
const next = mutate(parseMembers(row.members));
|
|
1452
|
+
await tx.run("UPDATE teams SET members = ?, updated_at = ? WHERE id = ?", [
|
|
1453
|
+
JSON.stringify(next),
|
|
1454
|
+
(/* @__PURE__ */ new Date()).toISOString(),
|
|
1455
|
+
teamId
|
|
1456
|
+
]);
|
|
1457
|
+
});
|
|
1455
1458
|
}
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
WHERE nc.nest_id = n.id AND nc.user_id = ?
|
|
1465
|
-
)
|
|
1466
|
-
ORDER BY n.created_at DESC`,
|
|
1467
|
-
[userId, userId]
|
|
1468
|
-
);
|
|
1459
|
+
function parseSharedTeams(json) {
|
|
1460
|
+
if (!json) return [];
|
|
1461
|
+
try {
|
|
1462
|
+
const parsed = JSON.parse(json);
|
|
1463
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
1464
|
+
} catch {
|
|
1465
|
+
return [];
|
|
1466
|
+
}
|
|
1469
1467
|
}
|
|
1470
|
-
async function
|
|
1468
|
+
async function listNestTeams(nestId) {
|
|
1471
1469
|
const db = getDb();
|
|
1472
|
-
|
|
1470
|
+
const row = await db.get("SELECT shared_teams FROM nests WHERE id = ?", [
|
|
1471
|
+
nestId
|
|
1472
|
+
]);
|
|
1473
|
+
if (!row) throw new NotFoundError("Nest not found");
|
|
1474
|
+
return parseSharedTeams(row.shared_teams);
|
|
1473
1475
|
}
|
|
1474
|
-
async function
|
|
1476
|
+
async function shareTeamWithNest(params) {
|
|
1475
1477
|
const db = getDb();
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
await tx.
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1478
|
+
let sharedMembers = [];
|
|
1479
|
+
const refs = await db.transaction(async (tx) => {
|
|
1480
|
+
const team = await tx.get(
|
|
1481
|
+
"SELECT id, name, owner_id, members FROM teams WHERE id = ?",
|
|
1482
|
+
[params.teamId]
|
|
1483
|
+
);
|
|
1484
|
+
if (!team) throw new NotFoundError("Team not found");
|
|
1485
|
+
if (managementRank(team, params.callerUserId, !!params.allowAdmin) < ROLE_RANK.editor) {
|
|
1486
|
+
throw new ForbiddenError(
|
|
1487
|
+
"You need to be an editor, admin, or owner of a team to share it. View-only members can't."
|
|
1488
|
+
);
|
|
1486
1489
|
}
|
|
1487
|
-
await tx.
|
|
1488
|
-
|
|
1490
|
+
const nest = await tx.get("SELECT shared_teams FROM nests WHERE id = ?", [
|
|
1491
|
+
params.nestId
|
|
1492
|
+
]);
|
|
1493
|
+
if (!nest) throw new NotFoundError("Nest not found");
|
|
1494
|
+
const refs2 = parseSharedTeams(nest.shared_teams);
|
|
1495
|
+
if (refs2.some((r) => r.teamId === params.teamId)) {
|
|
1496
|
+
throw new ConflictError("This team is already shared with the nest.");
|
|
1497
|
+
}
|
|
1498
|
+
refs2.push({ teamId: team.id, teamName: team.name });
|
|
1499
|
+
await tx.run("UPDATE nests SET shared_teams = ? WHERE id = ?", [
|
|
1500
|
+
JSON.stringify(refs2),
|
|
1501
|
+
params.nestId
|
|
1502
|
+
]);
|
|
1503
|
+
const members = parseMembers(team.members);
|
|
1504
|
+
sharedMembers = members;
|
|
1505
|
+
const hasGovernanceRole = members.some(
|
|
1506
|
+
(m) => m.role === "viewer" || m.role === "editor"
|
|
1507
|
+
);
|
|
1508
|
+
if (hasGovernanceRole) {
|
|
1509
|
+
await tx.run("UPDATE nests SET stewardship_enabled = 1 WHERE id = ?", [
|
|
1510
|
+
params.nestId
|
|
1511
|
+
]);
|
|
1512
|
+
}
|
|
1513
|
+
return refs2;
|
|
1489
1514
|
});
|
|
1490
|
-
const path = resolveNestPath(nestId);
|
|
1491
1515
|
try {
|
|
1492
|
-
|
|
1516
|
+
const caller = await db.get("SELECT email FROM users WHERE id = ?", [
|
|
1517
|
+
params.callerUserId
|
|
1518
|
+
]);
|
|
1519
|
+
const callerEmail = caller?.email?.toLowerCase();
|
|
1520
|
+
const shareNestName = await nestName(params.nestId);
|
|
1521
|
+
const link = docLink(params.nestId, void 0, params.baseUrl);
|
|
1522
|
+
const byRole = /* @__PURE__ */ new Map();
|
|
1523
|
+
for (const m of sharedMembers) {
|
|
1524
|
+
if (!m.email || m.email.toLowerCase() === callerEmail) continue;
|
|
1525
|
+
const arr = byRole.get(m.role) ?? [];
|
|
1526
|
+
arr.push(m.email);
|
|
1527
|
+
byRole.set(m.role, arr);
|
|
1528
|
+
}
|
|
1529
|
+
for (const [role, emails] of byRole) {
|
|
1530
|
+
if (emails.length === 0) continue;
|
|
1531
|
+
void sendEmailToRecipient(emails, {
|
|
1532
|
+
status: "shared",
|
|
1533
|
+
docTitle: shareNestName,
|
|
1534
|
+
path: shareNestName,
|
|
1535
|
+
link,
|
|
1536
|
+
permission: role,
|
|
1537
|
+
by: caller?.email || void 0
|
|
1538
|
+
});
|
|
1539
|
+
}
|
|
1493
1540
|
} catch (err) {
|
|
1494
|
-
console.
|
|
1541
|
+
console.error("[notify] team-share email fan-out failed", params.nestId, err);
|
|
1495
1542
|
}
|
|
1496
|
-
|
|
1543
|
+
return refs;
|
|
1497
1544
|
}
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
"QWEN.md"
|
|
1510
|
-
]);
|
|
1511
|
-
var FlatNestStorage = class extends NestStorage2 {
|
|
1512
|
-
async discoverDocuments() {
|
|
1513
|
-
const root = this.root;
|
|
1514
|
-
let entries;
|
|
1515
|
-
try {
|
|
1516
|
-
entries = readdirSync(root, { recursive: true, withFileTypes: true });
|
|
1517
|
-
} catch (err) {
|
|
1518
|
-
console.error("[FlatNestStorage] cannot read folder", root, err);
|
|
1519
|
-
return [];
|
|
1520
|
-
}
|
|
1521
|
-
const ids = [];
|
|
1522
|
-
for (const e of entries) {
|
|
1523
|
-
if (!e.isFile() || !e.name.endsWith(".md")) continue;
|
|
1524
|
-
if (META_FILES.has(e.name)) continue;
|
|
1525
|
-
const dir = e.parentPath ?? e.path ?? root;
|
|
1526
|
-
const rel = relative(root, join4(dir, e.name)).split(sep).join("/");
|
|
1527
|
-
if (rel.split("/").some(isSkippedSegment)) continue;
|
|
1528
|
-
ids.push(rel.replace(/\.md$/, ""));
|
|
1545
|
+
async function unshareTeamFromNest(params) {
|
|
1546
|
+
const db = getDb();
|
|
1547
|
+
return db.transaction(async (tx) => {
|
|
1548
|
+
const nest = await tx.get("SELECT shared_teams FROM nests WHERE id = ?", [
|
|
1549
|
+
params.nestId
|
|
1550
|
+
]);
|
|
1551
|
+
if (!nest) throw new NotFoundError("Nest not found");
|
|
1552
|
+
const refs = parseSharedTeams(nest.shared_teams);
|
|
1553
|
+
const next = refs.filter((r) => r.teamId !== params.teamId);
|
|
1554
|
+
if (next.length === refs.length) {
|
|
1555
|
+
throw new NotFoundError("Team is not shared with this nest.");
|
|
1529
1556
|
}
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1557
|
+
await tx.run("UPDATE nests SET shared_teams = ? WHERE id = ?", [
|
|
1558
|
+
JSON.stringify(next),
|
|
1559
|
+
params.nestId
|
|
1560
|
+
]);
|
|
1561
|
+
return next;
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1564
|
+
async function refreshTeamNameRefs(tx, teamId, newName) {
|
|
1565
|
+
const nests = await tx.all(
|
|
1566
|
+
"SELECT id, shared_teams FROM nests WHERE shared_teams LIKE ?",
|
|
1567
|
+
[`%${teamId}%`]
|
|
1568
|
+
);
|
|
1569
|
+
for (const n of nests) {
|
|
1570
|
+
const refs = parseSharedTeams(n.shared_teams);
|
|
1571
|
+
let changed = false;
|
|
1572
|
+
for (const r of refs) {
|
|
1573
|
+
if (r.teamId === teamId && r.teamName !== newName) {
|
|
1574
|
+
r.teamName = newName;
|
|
1575
|
+
changed = true;
|
|
1536
1576
|
}
|
|
1537
1577
|
}
|
|
1538
|
-
|
|
1578
|
+
if (changed) {
|
|
1579
|
+
await tx.run("UPDATE nests SET shared_teams = ? WHERE id = ?", [
|
|
1580
|
+
JSON.stringify(refs),
|
|
1581
|
+
n.id
|
|
1582
|
+
]);
|
|
1583
|
+
}
|
|
1539
1584
|
}
|
|
1540
|
-
}
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1585
|
+
}
|
|
1586
|
+
async function removeTeamRefs(tx, teamId) {
|
|
1587
|
+
const nests = await tx.all(
|
|
1588
|
+
"SELECT id, shared_teams FROM nests WHERE shared_teams LIKE ?",
|
|
1589
|
+
[`%${teamId}%`]
|
|
1590
|
+
);
|
|
1591
|
+
for (const n of nests) {
|
|
1592
|
+
const refs = parseSharedTeams(n.shared_teams);
|
|
1593
|
+
const next = refs.filter((r) => r.teamId !== teamId);
|
|
1594
|
+
if (next.length !== refs.length) {
|
|
1595
|
+
await tx.run("UPDATE nests SET shared_teams = ? WHERE id = ?", [
|
|
1596
|
+
JSON.stringify(next),
|
|
1597
|
+
n.id
|
|
1598
|
+
]);
|
|
1554
1599
|
}
|
|
1555
|
-
return engine;
|
|
1556
1600
|
}
|
|
1557
|
-
|
|
1558
|
-
|
|
1601
|
+
}
|
|
1602
|
+
async function resolveTeamRolesForUser(nestId, userId) {
|
|
1603
|
+
const refs = await listNestTeams(nestId).catch(() => []);
|
|
1604
|
+
if (refs.length === 0) return [];
|
|
1605
|
+
const db = getDb();
|
|
1606
|
+
const roles = /* @__PURE__ */ new Set();
|
|
1607
|
+
for (const ref of refs) {
|
|
1608
|
+
const row = await db.get("SELECT members FROM teams WHERE id = ?", [
|
|
1609
|
+
ref.teamId
|
|
1610
|
+
]);
|
|
1611
|
+
if (!row) continue;
|
|
1612
|
+
const member = parseMembers(row.members).find((m) => m.userId === userId);
|
|
1613
|
+
if (member) roles.add(member.role);
|
|
1559
1614
|
}
|
|
1560
|
-
|
|
1561
|
-
|
|
1615
|
+
return [...roles];
|
|
1616
|
+
}
|
|
1562
1617
|
|
|
1563
|
-
// src/governance/
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1618
|
+
// src/governance/roles.ts
|
|
1619
|
+
function collabPermToRole(permission) {
|
|
1620
|
+
switch (permission) {
|
|
1621
|
+
case "owner":
|
|
1622
|
+
return "owner";
|
|
1623
|
+
case "admin":
|
|
1624
|
+
return "admin";
|
|
1625
|
+
case "write":
|
|
1626
|
+
return "editor";
|
|
1627
|
+
case "read":
|
|
1628
|
+
return "viewer";
|
|
1629
|
+
default:
|
|
1630
|
+
return null;
|
|
1571
1631
|
}
|
|
1572
1632
|
}
|
|
1573
|
-
function
|
|
1574
|
-
|
|
1633
|
+
function teamRoleToPermission(role) {
|
|
1634
|
+
switch (role) {
|
|
1635
|
+
case "admin":
|
|
1636
|
+
return "admin";
|
|
1637
|
+
case "editor":
|
|
1638
|
+
return "write";
|
|
1639
|
+
case "viewer":
|
|
1640
|
+
return "read";
|
|
1641
|
+
default:
|
|
1642
|
+
return null;
|
|
1643
|
+
}
|
|
1575
1644
|
}
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1645
|
+
var includesAny = (roles, wanted) => roles.some((r) => wanted.includes(r));
|
|
1646
|
+
var canViewWith = (roles) => roles.length > 0;
|
|
1647
|
+
var canEditWith = (roles) => includesAny(roles, ["owner", "admin", "editor"]);
|
|
1648
|
+
var canApproveWith = (roles) => includesAny(roles, ["owner", "admin", "reviewer"]);
|
|
1649
|
+
var PRECEDENCE = [
|
|
1650
|
+
"owner",
|
|
1651
|
+
"admin",
|
|
1652
|
+
"editor",
|
|
1653
|
+
"reviewer",
|
|
1654
|
+
"viewer"
|
|
1655
|
+
];
|
|
1656
|
+
function primaryRole(roles) {
|
|
1657
|
+
for (const r of PRECEDENCE) if (roles.includes(r)) return r;
|
|
1658
|
+
return null;
|
|
1581
1659
|
}
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1660
|
+
|
|
1661
|
+
// src/shared/access.ts
|
|
1662
|
+
async function isServerAdminUserId(userId) {
|
|
1663
|
+
if (await isLicenseAdminUserId(userId)) return true;
|
|
1664
|
+
const row = await getDb().get(
|
|
1665
|
+
"SELECT email FROM users WHERE id = ?",
|
|
1666
|
+
[userId]
|
|
1667
|
+
);
|
|
1668
|
+
return !!row?.email && isSuperAdmin(row.email);
|
|
1591
1669
|
}
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1670
|
+
var PERMISSION_LEVELS = {
|
|
1671
|
+
none: 0,
|
|
1672
|
+
read: 1,
|
|
1673
|
+
write: 2,
|
|
1674
|
+
admin: 3,
|
|
1675
|
+
owner: 4
|
|
1676
|
+
};
|
|
1677
|
+
async function resolveNestPermission(nestId, userId) {
|
|
1678
|
+
const db = getDb();
|
|
1679
|
+
const nest = await db.get("SELECT user_id, visibility FROM nests WHERE id = ?", [nestId]);
|
|
1680
|
+
if (!nest) return "none";
|
|
1681
|
+
if (nest.user_id === userId) return "owner";
|
|
1682
|
+
if (nest.user_id === ANON_USER_ID && await isLicenseAdminUserId(userId)) {
|
|
1683
|
+
return "owner";
|
|
1684
|
+
}
|
|
1685
|
+
if (await isLicenseAdminUserId(userId)) return "admin";
|
|
1686
|
+
const caller = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
|
|
1687
|
+
if (caller?.email && isSuperAdmin(caller.email)) {
|
|
1688
|
+
return "admin";
|
|
1689
|
+
}
|
|
1690
|
+
const higher = (a, b) => b && PERMISSION_LEVELS[b] > PERMISSION_LEVELS[a] ? b : a;
|
|
1691
|
+
let best = "none";
|
|
1692
|
+
const directGrant = await db.get(
|
|
1693
|
+
"SELECT permission FROM nest_collaborators WHERE nest_id = ? AND user_id = ?",
|
|
1694
|
+
[nestId, userId]
|
|
1695
|
+
);
|
|
1696
|
+
if (directGrant) best = higher(best, directGrant.permission);
|
|
1697
|
+
if (best === "admin") return best;
|
|
1698
|
+
for (const role of await resolveTeamRolesForUser(nestId, userId)) {
|
|
1699
|
+
best = higher(best, teamRoleToPermission(role));
|
|
1700
|
+
}
|
|
1701
|
+
if (best === "admin") return best;
|
|
1702
|
+
const stewardGrant = await db.get(
|
|
1703
|
+
`SELECT 1 FROM stewards s
|
|
1704
|
+
JOIN users u ON u.id = ?
|
|
1705
|
+
WHERE s.nest_id = ? AND s.is_active = 1
|
|
1706
|
+
AND LOWER(s.user_email) = LOWER(u.email)
|
|
1707
|
+
LIMIT 1`,
|
|
1708
|
+
[userId, nestId]
|
|
1709
|
+
);
|
|
1710
|
+
if (stewardGrant) best = higher(best, "read");
|
|
1711
|
+
if (nest.visibility === "public") best = higher(best, "read");
|
|
1712
|
+
return best;
|
|
1598
1713
|
}
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
const docTitle = titles.get(nodeId) || nodeId;
|
|
1602
|
-
const folder = folderForNode(nodeId);
|
|
1603
|
-
const name = await nestName(nestId);
|
|
1604
|
-
return {
|
|
1605
|
-
docTitle,
|
|
1606
|
-
// Slack one-liner label ("Title (in Folder)"), same as describeNode().
|
|
1607
|
-
label: folder ? `${docTitle} (in ${folder})` : docTitle,
|
|
1608
|
-
path: folder ? `${name} / ${folder}` : name,
|
|
1609
|
-
link: docLink(nestId, nodeId, baseUrl)
|
|
1610
|
-
};
|
|
1714
|
+
function permissionLevel(p) {
|
|
1715
|
+
return PERMISSION_LEVELS[p] ?? 0;
|
|
1611
1716
|
}
|
|
1612
|
-
|
|
1613
|
-
// src/governance/stewardship-service.ts
|
|
1614
|
-
async function assignSteward(data) {
|
|
1717
|
+
async function isPublicReader(nestId, userId) {
|
|
1615
1718
|
const db = getDb();
|
|
1616
|
-
const id =
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1719
|
+
const nest = await db.get("SELECT user_id, visibility FROM nests WHERE id = ?", [nestId]);
|
|
1720
|
+
if (!nest) return false;
|
|
1721
|
+
if (nest.visibility !== "public") return false;
|
|
1722
|
+
if (nest.user_id === userId) return false;
|
|
1723
|
+
const directGrant = await db.get(
|
|
1724
|
+
"SELECT 1 FROM nest_collaborators WHERE nest_id = ? AND user_id = ?",
|
|
1725
|
+
[nestId, userId]
|
|
1726
|
+
);
|
|
1727
|
+
if (directGrant) return false;
|
|
1728
|
+
if (await isLicenseAdminUserId(userId)) return false;
|
|
1729
|
+
const caller = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
|
|
1730
|
+
if (caller?.email && isSuperAdmin(caller.email)) return false;
|
|
1731
|
+
const stewardGrant = await db.get(
|
|
1732
|
+
`SELECT 1 FROM stewards s
|
|
1733
|
+
JOIN users u ON u.id = ?
|
|
1734
|
+
WHERE s.nest_id = ? AND s.is_active = 1
|
|
1735
|
+
AND LOWER(s.user_email) = LOWER(u.email)
|
|
1736
|
+
LIMIT 1`,
|
|
1737
|
+
[userId, nestId]
|
|
1633
1738
|
);
|
|
1634
|
-
|
|
1739
|
+
if (stewardGrant) return false;
|
|
1740
|
+
if ((await resolveTeamRolesForUser(nestId, userId)).length > 0) return false;
|
|
1741
|
+
return true;
|
|
1635
1742
|
}
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1743
|
+
|
|
1744
|
+
// src/governance/tag-index-service.ts
|
|
1745
|
+
function normalizeTag(raw) {
|
|
1746
|
+
return raw.trim().replace(/^#+/, "").toLowerCase();
|
|
1639
1747
|
}
|
|
1640
|
-
|
|
1641
|
-
async function updateSteward(id, update) {
|
|
1748
|
+
async function syncNodeTags(nestId, nodeId, tags) {
|
|
1642
1749
|
const db = getDb();
|
|
1643
|
-
const
|
|
1644
|
-
|
|
1645
|
-
|
|
1750
|
+
const normalized = Array.from(
|
|
1751
|
+
new Set(
|
|
1752
|
+
tags.filter((t) => typeof t === "string").map(normalizeTag).filter(Boolean)
|
|
1753
|
+
)
|
|
1646
1754
|
);
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
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]
|
|
1654
1763
|
);
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
let nodePattern = current.node_pattern ?? null;
|
|
1658
|
-
let tagName = current.tag_name ?? null;
|
|
1659
|
-
if (update.scope) {
|
|
1660
|
-
scope = update.scope;
|
|
1661
|
-
nodePattern = null;
|
|
1662
|
-
tagName = null;
|
|
1663
|
-
switch (scope) {
|
|
1664
|
-
case "document":
|
|
1665
|
-
if (!update.documentId)
|
|
1666
|
-
throw new ValidationError("documentId required for document scope");
|
|
1667
|
-
nodePattern = update.documentId;
|
|
1668
|
-
break;
|
|
1669
|
-
case "tag":
|
|
1670
|
-
if (!update.tagName)
|
|
1671
|
-
throw new ValidationError("tagName required for tag scope");
|
|
1672
|
-
tagName = update.tagName.trim().replace(/^#+/, "").toLowerCase();
|
|
1673
|
-
break;
|
|
1674
|
-
case "nest":
|
|
1675
|
-
break;
|
|
1764
|
+
for (const tag of normalized) {
|
|
1765
|
+
await tx.run(insertSql, [nestId, nodeId, tag]);
|
|
1676
1766
|
}
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
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
|
+
|
|
1777
|
+
// src/nests/service.ts
|
|
1778
|
+
async function isImportedNest(nestId) {
|
|
1779
|
+
const row = await getDb().get(
|
|
1780
|
+
"SELECT is_imported FROM nests WHERE id = ?",
|
|
1781
|
+
[nestId]
|
|
1782
|
+
);
|
|
1783
|
+
return !!row?.is_imported;
|
|
1784
|
+
}
|
|
1785
|
+
function toSlug(name) {
|
|
1786
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1787
|
+
}
|
|
1788
|
+
async function uniqueNestName(userId, baseName) {
|
|
1789
|
+
const db = getDb();
|
|
1790
|
+
let candidate = baseName;
|
|
1791
|
+
let n = 0;
|
|
1792
|
+
while (n < 1e3) {
|
|
1793
|
+
const slug = toSlug(candidate);
|
|
1794
|
+
const hit = await db.get(
|
|
1795
|
+
"SELECT 1 FROM nests WHERE user_id = ? AND slug = ? LIMIT 1",
|
|
1796
|
+
[userId, slug]
|
|
1684
1797
|
);
|
|
1685
|
-
if (
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
`"${current.user_email}" is already a steward of ${scopeLabel} with the "${dup.role}" role.`
|
|
1689
|
-
);
|
|
1690
|
-
}
|
|
1798
|
+
if (!hit) return candidate;
|
|
1799
|
+
n++;
|
|
1800
|
+
candidate = `${baseName} (${n})`;
|
|
1691
1801
|
}
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1802
|
+
return candidate;
|
|
1803
|
+
}
|
|
1804
|
+
async function isStewardshipEnabled(nestId) {
|
|
1805
|
+
const db = getDb();
|
|
1806
|
+
const row = await db.get(
|
|
1807
|
+
"SELECT stewardship_enabled FROM nests WHERE id = ?",
|
|
1808
|
+
[nestId]
|
|
1695
1809
|
);
|
|
1696
|
-
|
|
1697
|
-
|
|
1810
|
+
return !!row?.stewardship_enabled;
|
|
1811
|
+
}
|
|
1812
|
+
async function setStewardshipEnabled(nestId, enabled) {
|
|
1813
|
+
const db = getDb();
|
|
1814
|
+
await db.run("UPDATE nests SET stewardship_enabled = ? WHERE id = ?", [
|
|
1815
|
+
enabled ? 1 : 0,
|
|
1816
|
+
nestId
|
|
1817
|
+
]);
|
|
1698
1818
|
}
|
|
1699
|
-
async function
|
|
1700
|
-
|
|
1819
|
+
async function nestAllowsSelfApprove(nestId) {
|
|
1820
|
+
const db = getDb();
|
|
1821
|
+
const row = await db.get(
|
|
1822
|
+
"SELECT allow_self_approve FROM nests WHERE id = ?",
|
|
1823
|
+
[nestId]
|
|
1824
|
+
);
|
|
1825
|
+
return !!row?.allow_self_approve;
|
|
1701
1826
|
}
|
|
1702
|
-
async function
|
|
1827
|
+
async function setAllowSelfApprove(nestId, allow) {
|
|
1703
1828
|
const db = getDb();
|
|
1704
|
-
|
|
1705
|
-
|
|
1829
|
+
await db.run("UPDATE nests SET allow_self_approve = ? WHERE id = ?", [
|
|
1830
|
+
allow ? 1 : 0,
|
|
1831
|
+
nestId
|
|
1832
|
+
]);
|
|
1706
1833
|
}
|
|
1707
|
-
async function
|
|
1834
|
+
async function nestReviewsPrimeOnly(nestId) {
|
|
1708
1835
|
const db = getDb();
|
|
1709
|
-
const
|
|
1710
|
-
"SELECT
|
|
1836
|
+
const row = await db.get(
|
|
1837
|
+
"SELECT prime_only_review FROM nests WHERE id = ?",
|
|
1711
1838
|
[nestId]
|
|
1712
1839
|
);
|
|
1713
|
-
return
|
|
1840
|
+
return !!row?.prime_only_review;
|
|
1714
1841
|
}
|
|
1715
|
-
async function
|
|
1842
|
+
async function setPrimeOnlyReview(nestId, enabled) {
|
|
1716
1843
|
const db = getDb();
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
args.push(params.scope);
|
|
1722
|
-
}
|
|
1723
|
-
if (params.scopeTarget) {
|
|
1724
|
-
sql += " AND (node_pattern = ? OR tag_name = ?)";
|
|
1725
|
-
args.push(params.scopeTarget, params.scopeTarget);
|
|
1726
|
-
}
|
|
1727
|
-
return (await db.all(sql, args)).map(rowToSteward);
|
|
1844
|
+
await db.run("UPDATE nests SET prime_only_review = ? WHERE id = ?", [
|
|
1845
|
+
enabled ? 1 : 0,
|
|
1846
|
+
nestId
|
|
1847
|
+
]);
|
|
1728
1848
|
}
|
|
1729
|
-
async function
|
|
1849
|
+
async function nestPrimeTags(nestId) {
|
|
1730
1850
|
const db = getDb();
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
if (params.search) {
|
|
1738
|
-
sql += " AND (user_email LIKE ? OR tag_name LIKE ? OR node_pattern LIKE ?)";
|
|
1739
|
-
const like = `%${params.search.toLowerCase()}%`;
|
|
1740
|
-
args.push(like, like, like);
|
|
1741
|
-
}
|
|
1742
|
-
sql += " ORDER BY scope, COALESCE(node_pattern, tag_name, ''), user_email";
|
|
1743
|
-
const stewards = (await db.all(sql, args)).map(rowToSteward);
|
|
1744
|
-
return enrichWithTitles(params.nestId, stewards);
|
|
1745
|
-
}
|
|
1746
|
-
async function enrichWithTitles(nestId, stewards) {
|
|
1747
|
-
if (!stewards.some((s) => s.scope === "document")) return stewards;
|
|
1748
|
-
const titleByNodeId = await buildTitleMap(nestId);
|
|
1749
|
-
for (const s of stewards) {
|
|
1750
|
-
if (s.scope === "document" && s.nodePattern) {
|
|
1751
|
-
s.nodeTitle = titleByNodeId.get(s.nodePattern);
|
|
1752
|
-
}
|
|
1753
|
-
}
|
|
1754
|
-
return stewards;
|
|
1755
|
-
}
|
|
1756
|
-
async function createStewardRecord(params) {
|
|
1757
|
-
if (params.users.length === 0) {
|
|
1758
|
-
throw new Error("At least one user is required");
|
|
1759
|
-
}
|
|
1760
|
-
let nodePattern;
|
|
1761
|
-
let tagName;
|
|
1762
|
-
switch (params.scope) {
|
|
1763
|
-
case "document":
|
|
1764
|
-
if (!params.documentId) throw new Error("documentId required for document scope");
|
|
1765
|
-
nodePattern = params.documentId;
|
|
1766
|
-
break;
|
|
1767
|
-
case "tag":
|
|
1768
|
-
if (!params.tagName) throw new Error("tagName required for tag scope");
|
|
1769
|
-
tagName = params.tagName.trim().replace(/^#+/, "").toLowerCase();
|
|
1770
|
-
break;
|
|
1771
|
-
case "nest":
|
|
1772
|
-
break;
|
|
1773
|
-
}
|
|
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) {
|
|
1774
1857
|
const db = getDb();
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
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
|
+
}
|
|
1871
|
+
async function disableStewardshipAndWipeGovernance(nestId) {
|
|
1872
|
+
const db = getDb();
|
|
1873
|
+
return db.transaction(async (tx) => {
|
|
1874
|
+
const id = nestId;
|
|
1875
|
+
const stewards = (await tx.run("DELETE FROM stewards WHERE nest_id = ?", [id])).changes;
|
|
1876
|
+
const pendingReviews = (await tx.run(
|
|
1877
|
+
"DELETE FROM review_requests WHERE nest_id = ? AND status = 'pending'",
|
|
1878
|
+
[id]
|
|
1879
|
+
)).changes;
|
|
1880
|
+
await tx.run("UPDATE nests SET stewardship_enabled = 0 WHERE id = ?", [id]);
|
|
1881
|
+
return { stewards, pendingReviews };
|
|
1882
|
+
});
|
|
1883
|
+
}
|
|
1884
|
+
async function renameNest(nestId, patch) {
|
|
1885
|
+
const db = getDb();
|
|
1886
|
+
const existing = await db.get("SELECT * FROM nests WHERE id = ?", [
|
|
1887
|
+
nestId
|
|
1888
|
+
]);
|
|
1889
|
+
if (!existing) {
|
|
1890
|
+
throw new ValidationError("Nest not found");
|
|
1891
|
+
}
|
|
1892
|
+
const updates = [];
|
|
1893
|
+
const params = [];
|
|
1894
|
+
if (patch.name !== void 0) {
|
|
1895
|
+
const trimmed = patch.name.trim();
|
|
1896
|
+
if (!trimmed) throw new ValidationError("Name cannot be empty");
|
|
1897
|
+
const slug = toSlug(trimmed);
|
|
1898
|
+
if (!slug) throw new ValidationError("Name must contain at least one alphanumeric character");
|
|
1899
|
+
const conflict = await db.get(
|
|
1900
|
+
"SELECT id FROM nests WHERE user_id = ? AND slug = ? AND id != ? LIMIT 1",
|
|
1901
|
+
[existing.user_id, slug, nestId]
|
|
1902
|
+
);
|
|
1903
|
+
if (conflict) {
|
|
1790
1904
|
throw new ValidationError(
|
|
1791
|
-
|
|
1905
|
+
`Another nest already uses this name. Pick a different one.`
|
|
1792
1906
|
);
|
|
1793
1907
|
}
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1908
|
+
updates.push("name = ?", "slug = ?");
|
|
1909
|
+
params.push(trimmed, slug);
|
|
1910
|
+
}
|
|
1911
|
+
if (patch.description !== void 0) {
|
|
1912
|
+
updates.push("description = ?");
|
|
1913
|
+
params.push(patch.description || null);
|
|
1914
|
+
}
|
|
1915
|
+
if (updates.length === 0) {
|
|
1916
|
+
return existing;
|
|
1917
|
+
}
|
|
1918
|
+
params.push(nestId);
|
|
1919
|
+
await db.run(
|
|
1920
|
+
`UPDATE nests SET ${updates.join(", ")} WHERE id = ?`,
|
|
1921
|
+
params
|
|
1922
|
+
);
|
|
1923
|
+
return await db.get("SELECT * FROM nests WHERE id = ?", [nestId]);
|
|
1924
|
+
}
|
|
1925
|
+
async function createNest(userId, name, description) {
|
|
1926
|
+
const id = uuid2();
|
|
1927
|
+
const db = getDb();
|
|
1928
|
+
const trimmed = name.trim();
|
|
1929
|
+
if (!trimmed) throw new ValidationError("Name cannot be empty");
|
|
1930
|
+
const slug = toSlug(trimmed);
|
|
1931
|
+
if (!slug) {
|
|
1932
|
+
throw new ValidationError(
|
|
1933
|
+
"Name must contain at least one alphanumeric character"
|
|
1806
1934
|
);
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
"
|
|
1815
|
-
[email]
|
|
1935
|
+
}
|
|
1936
|
+
const conflict = await db.get(
|
|
1937
|
+
"SELECT id FROM nests WHERE user_id = ? AND slug = ? LIMIT 1",
|
|
1938
|
+
[userId, slug]
|
|
1939
|
+
);
|
|
1940
|
+
if (conflict) {
|
|
1941
|
+
throw new ValidationError(
|
|
1942
|
+
"You already have a nest with this name. Pick a different one."
|
|
1816
1943
|
);
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1944
|
+
}
|
|
1945
|
+
const visibility = userId === ANON_USER_ID ? "public" : "private";
|
|
1946
|
+
await db.run(
|
|
1947
|
+
"INSERT INTO nests (id, user_id, name, slug, description, visibility) VALUES (?, ?, ?, ?, ?, ?)",
|
|
1948
|
+
[id, userId, trimmed, slug, description || null, visibility]
|
|
1949
|
+
);
|
|
1950
|
+
const path = resolveNestPath(id);
|
|
1951
|
+
mkdirSync(path, { recursive: true });
|
|
1952
|
+
const storage = new NestStorage3(path);
|
|
1953
|
+
await storage.init(trimmed);
|
|
1954
|
+
trackEvent("nest.create", { nestId: id, userId });
|
|
1955
|
+
return await db.get("SELECT * FROM nests WHERE id = ?", [id]);
|
|
1956
|
+
}
|
|
1957
|
+
async function importNest(userId, name, files) {
|
|
1958
|
+
const nm = (name || "").trim();
|
|
1959
|
+
if (!nm) {
|
|
1960
|
+
throw new ValidationError("name is required");
|
|
1961
|
+
}
|
|
1962
|
+
if (!Array.isArray(files)) {
|
|
1963
|
+
throw new ValidationError("files are required");
|
|
1964
|
+
}
|
|
1965
|
+
const db = getDb();
|
|
1966
|
+
const id = uuid2();
|
|
1967
|
+
const slug = toSlug(nm);
|
|
1968
|
+
const visibility = userId === ANON_USER_ID ? "public" : "private";
|
|
1969
|
+
const dest = resolveNestPath(id);
|
|
1970
|
+
mkdirSync(dest, { recursive: true });
|
|
1971
|
+
try {
|
|
1972
|
+
await writeImportedFiles(dest, files);
|
|
1973
|
+
} catch (err) {
|
|
1974
|
+
console.error("[nests] import write failed", dest, err);
|
|
1975
|
+
discardImportDir(dest);
|
|
1976
|
+
throw new ValidationError("Failed to import folder");
|
|
1839
1977
|
}
|
|
1840
1978
|
await db.run(
|
|
1841
|
-
"
|
|
1842
|
-
[
|
|
1843
|
-
);
|
|
1844
|
-
return results;
|
|
1845
|
-
}
|
|
1846
|
-
async function resolveStewardsForNode(nestId, nodeId) {
|
|
1847
|
-
return (await resolve(nestId, nodeId)).stewards;
|
|
1848
|
-
}
|
|
1849
|
-
async function resolveStewardsWithFallback(nestId, nodeId) {
|
|
1850
|
-
return resolve(nestId, nodeId);
|
|
1851
|
-
}
|
|
1852
|
-
async function resolve(nestId, nodeId) {
|
|
1853
|
-
const db = getDb();
|
|
1854
|
-
const rows = await db.all(
|
|
1855
|
-
`
|
|
1856
|
-
SELECT s.*, 1 AS priority, ('document: ' || s.node_pattern) AS match_source
|
|
1857
|
-
FROM stewards s
|
|
1858
|
-
WHERE s.nest_id = ? AND s.is_active = 1 AND s.scope = 'document'
|
|
1859
|
-
AND s.node_pattern = ?
|
|
1860
|
-
UNION ALL
|
|
1861
|
-
SELECT s.*, 2 AS priority, ('tag: ' || s.tag_name) AS match_source
|
|
1862
|
-
FROM stewards s
|
|
1863
|
-
JOIN node_tag_index nt
|
|
1864
|
-
ON nt.nest_id = s.nest_id
|
|
1865
|
-
AND nt.tag_name = s.tag_name
|
|
1866
|
-
WHERE s.nest_id = ? AND s.is_active = 1 AND s.scope = 'tag'
|
|
1867
|
-
AND nt.node_id = ?
|
|
1868
|
-
UNION ALL
|
|
1869
|
-
SELECT s.*, 3 AS priority, 'nest-level steward' AS match_source
|
|
1870
|
-
FROM stewards s
|
|
1871
|
-
WHERE s.nest_id = ? AND s.is_active = 1 AND s.scope = 'nest'
|
|
1872
|
-
ORDER BY priority ASC, user_email ASC
|
|
1873
|
-
`,
|
|
1874
|
-
[
|
|
1875
|
-
nestId,
|
|
1876
|
-
nodeId,
|
|
1877
|
-
// document branch
|
|
1878
|
-
nestId,
|
|
1879
|
-
nodeId,
|
|
1880
|
-
// tag branch
|
|
1881
|
-
nestId
|
|
1882
|
-
// nest branch
|
|
1883
|
-
]
|
|
1884
|
-
);
|
|
1885
|
-
const resolved = rows.map((row) => ({
|
|
1886
|
-
steward: rowToSteward(row),
|
|
1887
|
-
priority: row.priority,
|
|
1888
|
-
source: row.match_source
|
|
1889
|
-
}));
|
|
1890
|
-
if (resolved.length > 0) {
|
|
1891
|
-
return { stewards: resolved, fallbackToOwner: false };
|
|
1892
|
-
}
|
|
1893
|
-
const owner = await db.get(
|
|
1894
|
-
`SELECT u.email FROM nests n
|
|
1895
|
-
JOIN users u ON u.id = n.user_id
|
|
1896
|
-
WHERE n.id = ?`,
|
|
1897
|
-
[nestId]
|
|
1979
|
+
"INSERT INTO nests (id, user_id, name, slug, description, visibility, is_imported) VALUES (?, ?, ?, ?, ?, ?, 1)",
|
|
1980
|
+
[id, userId, nm, slug, null, visibility]
|
|
1898
1981
|
);
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
fallbackToOwner: true,
|
|
1902
|
-
ownerEmail: owner?.email
|
|
1903
|
-
};
|
|
1982
|
+
trackEvent("nest.import", { nestId: id, userId });
|
|
1983
|
+
return await db.get("SELECT * FROM nests WHERE id = ?", [id]);
|
|
1904
1984
|
}
|
|
1905
|
-
async function
|
|
1985
|
+
async function listNests(userId) {
|
|
1906
1986
|
const db = getDb();
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1987
|
+
if (userId === ANON_USER_ID) {
|
|
1988
|
+
return await db.all(
|
|
1989
|
+
"SELECT * FROM nests WHERE user_id = ? ORDER BY created_at DESC",
|
|
1990
|
+
[ANON_USER_ID]
|
|
1991
|
+
);
|
|
1992
|
+
}
|
|
1993
|
+
const includeAnon = config.AUTH_MODE === "open" || await isLicenseAdminUserId(userId);
|
|
1994
|
+
if (!includeAnon) {
|
|
1995
|
+
return await db.all(
|
|
1996
|
+
"SELECT * FROM nests WHERE user_id = ? ORDER BY created_at DESC",
|
|
1997
|
+
[userId]
|
|
1998
|
+
);
|
|
1999
|
+
}
|
|
2000
|
+
return await db.all(
|
|
2001
|
+
"SELECT * FROM nests WHERE user_id = ? OR user_id = ? ORDER BY created_at DESC",
|
|
2002
|
+
[userId, ANON_USER_ID]
|
|
1910
2003
|
);
|
|
1911
|
-
return row?.id;
|
|
1912
2004
|
}
|
|
1913
|
-
async function
|
|
2005
|
+
async function nestsSharedViaTeams(userId) {
|
|
1914
2006
|
const db = getDb();
|
|
1915
|
-
const
|
|
1916
|
-
"SELECT
|
|
1917
|
-
[
|
|
2007
|
+
const teams = await db.all(
|
|
2008
|
+
"SELECT id, members FROM teams WHERE members LIKE ?",
|
|
2009
|
+
[`%${userId}%`]
|
|
1918
2010
|
);
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
2011
|
+
const myTeamIds = /* @__PURE__ */ new Set();
|
|
2012
|
+
for (const t of teams) {
|
|
2013
|
+
try {
|
|
2014
|
+
const members = JSON.parse(t.members || "[]");
|
|
2015
|
+
if (Array.isArray(members) && members.some((m) => m?.userId === userId)) {
|
|
2016
|
+
myTeamIds.add(t.id);
|
|
2017
|
+
}
|
|
2018
|
+
} catch {
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
if (myTeamIds.size === 0) return [];
|
|
2022
|
+
const ids = [...myTeamIds];
|
|
2023
|
+
const candidates = await db.all(
|
|
2024
|
+
`SELECT * FROM nests
|
|
2025
|
+
WHERE user_id != ?
|
|
2026
|
+
AND (${ids.map(() => "shared_teams LIKE ?").join(" OR ")})`,
|
|
2027
|
+
[userId, ...ids.map((id) => `%${id}%`)]
|
|
1926
2028
|
);
|
|
1927
|
-
return
|
|
2029
|
+
return candidates.filter((n) => {
|
|
2030
|
+
try {
|
|
2031
|
+
const refs = JSON.parse(n.shared_teams || "[]");
|
|
2032
|
+
return Array.isArray(refs) && refs.some((r) => myTeamIds.has(r?.teamId));
|
|
2033
|
+
} catch {
|
|
2034
|
+
return false;
|
|
2035
|
+
}
|
|
2036
|
+
});
|
|
1928
2037
|
}
|
|
1929
|
-
async function
|
|
1930
|
-
const userId = await userIdForEmail(userEmail);
|
|
1931
|
-
if (!userId) return null;
|
|
2038
|
+
async function listSharedNests(userId) {
|
|
1932
2039
|
const db = getDb();
|
|
1933
|
-
const
|
|
1934
|
-
|
|
1935
|
-
|
|
2040
|
+
const direct = await db.all(
|
|
2041
|
+
`SELECT DISTINCT n.* FROM nests n
|
|
2042
|
+
LEFT JOIN nest_collaborators nc
|
|
2043
|
+
ON nc.nest_id = n.id AND nc.user_id = ?
|
|
2044
|
+
LEFT JOIN users u ON u.id = ?
|
|
2045
|
+
LEFT JOIN stewards s
|
|
2046
|
+
ON s.nest_id = n.id AND s.is_active = 1
|
|
2047
|
+
AND LOWER(s.user_email) = LOWER(u.email)
|
|
2048
|
+
WHERE n.user_id != ?
|
|
2049
|
+
AND (nc.user_id IS NOT NULL OR s.id IS NOT NULL)
|
|
2050
|
+
ORDER BY n.created_at DESC`,
|
|
2051
|
+
[userId, userId, userId]
|
|
1936
2052
|
);
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
getStewardRolesForUser(nestId, userEmail),
|
|
1943
|
-
resolveTeamRolesForUser(nestId, userId)
|
|
1944
|
-
]);
|
|
1945
|
-
return [
|
|
1946
|
-
...new Set(
|
|
1947
|
-
[collabPermToRole(grantRaw), ...stewardRoles, ...teamRoles].filter(
|
|
1948
|
-
Boolean
|
|
1949
|
-
)
|
|
1950
|
-
)
|
|
1951
|
-
];
|
|
1952
|
-
}
|
|
1953
|
-
async function resolveUserRoles(nestId, userEmail, opts) {
|
|
1954
|
-
const roles = /* @__PURE__ */ new Set();
|
|
1955
|
-
if (isSuperAdmin(userEmail)) roles.add("admin");
|
|
1956
|
-
const owner = await getNestOwnerEmail(nestId);
|
|
1957
|
-
if (owner && owner.toLowerCase() === userEmail.toLowerCase()) {
|
|
1958
|
-
roles.add("owner");
|
|
1959
|
-
}
|
|
1960
|
-
const userId = await userIdForEmail(userEmail);
|
|
1961
|
-
if (userId) {
|
|
1962
|
-
const collabRole = collabPermToRole(await resolveNestPermission(nestId, userId));
|
|
1963
|
-
if (collabRole) roles.add(collabRole);
|
|
1964
|
-
for (const role of await resolveTeamRolesForUser(nestId, userId)) {
|
|
1965
|
-
roles.add(role);
|
|
2053
|
+
const seen = new Set(direct.map((n) => n.id));
|
|
2054
|
+
for (const n of await nestsSharedViaTeams(userId)) {
|
|
2055
|
+
if (!seen.has(n.id)) {
|
|
2056
|
+
direct.push(n);
|
|
2057
|
+
seen.add(n.id);
|
|
1966
2058
|
}
|
|
1967
2059
|
}
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
}
|
|
1979
|
-
async function canCreateInNest(nestId, userEmail) {
|
|
1980
|
-
if (config.AUTH_MODE === "open" || isSuperAdmin(userEmail)) return true;
|
|
1981
|
-
const userId = await userIdForEmail(userEmail);
|
|
1982
|
-
if (userId) {
|
|
1983
|
-
const perm = await resolveNestPermission(nestId, userId);
|
|
1984
|
-
if (perm === "owner" || perm === "admin" || perm === "write") return true;
|
|
2060
|
+
if (await isServerAdminUserId(userId)) {
|
|
2061
|
+
const anon = await db.all(
|
|
2062
|
+
"SELECT * FROM nests WHERE user_id = ? ORDER BY created_at DESC",
|
|
2063
|
+
[ANON_USER_ID]
|
|
2064
|
+
);
|
|
2065
|
+
for (const n of anon) {
|
|
2066
|
+
if (!seen.has(n.id)) {
|
|
2067
|
+
direct.push(n);
|
|
2068
|
+
seen.add(n.id);
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
1985
2071
|
}
|
|
1986
|
-
|
|
1987
|
-
(
|
|
2072
|
+
direct.sort(
|
|
2073
|
+
(a, b) => a.created_at === b.created_at ? 0 : a.created_at < b.created_at ? 1 : -1
|
|
1988
2074
|
);
|
|
2075
|
+
return direct;
|
|
1989
2076
|
}
|
|
1990
|
-
async function
|
|
2077
|
+
async function listVisibleNests(userId) {
|
|
1991
2078
|
const db = getDb();
|
|
1992
|
-
const
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
2079
|
+
const includeAnon = config.AUTH_MODE === "open" || await isServerAdminUserId(userId);
|
|
2080
|
+
const params = includeAnon ? [userId, userId, userId, ANON_USER_ID] : [userId, userId, userId];
|
|
2081
|
+
const rows = await db.all(
|
|
2082
|
+
`SELECT DISTINCT n.* FROM nests n
|
|
2083
|
+
LEFT JOIN nest_collaborators nc
|
|
2084
|
+
ON nc.nest_id = n.id AND nc.user_id = ?
|
|
2085
|
+
LEFT JOIN users u ON u.id = ?
|
|
2086
|
+
LEFT JOIN stewards s
|
|
2087
|
+
ON s.nest_id = n.id AND s.is_active = 1
|
|
2088
|
+
AND LOWER(s.user_email) = LOWER(u.email)
|
|
2089
|
+
WHERE n.user_id = ?
|
|
2090
|
+
OR nc.user_id IS NOT NULL
|
|
2091
|
+
OR s.id IS NOT NULL
|
|
2092
|
+
${includeAnon ? "OR n.user_id = ?" : ""}
|
|
2093
|
+
ORDER BY n.created_at DESC`,
|
|
2094
|
+
params
|
|
1997
2095
|
);
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
}
|
|
2005
|
-
if (isSuperAdmin(userEmail)) {
|
|
2006
|
-
return { allowed: true, reason: "super admin", role: "super_admin" };
|
|
2007
|
-
}
|
|
2008
|
-
if (canEditWith(roles)) {
|
|
2009
|
-
return {
|
|
2010
|
-
allowed: true,
|
|
2011
|
-
reason: "editor access (collaborator or steward)",
|
|
2012
|
-
role: roles.includes("admin") ? "admin" : "editor"
|
|
2013
|
-
};
|
|
2096
|
+
const seen = new Set(rows.map((n) => n.id));
|
|
2097
|
+
for (const n of await nestsSharedViaTeams(userId)) {
|
|
2098
|
+
if (!seen.has(n.id)) {
|
|
2099
|
+
rows.push(n);
|
|
2100
|
+
seen.add(n.id);
|
|
2101
|
+
}
|
|
2014
2102
|
}
|
|
2015
|
-
return
|
|
2103
|
+
return rows;
|
|
2016
2104
|
}
|
|
2017
|
-
async function
|
|
2105
|
+
async function listPublicNests(userId) {
|
|
2018
2106
|
const db = getDb();
|
|
2019
|
-
|
|
2020
|
-
`SELECT
|
|
2021
|
-
WHERE
|
|
2022
|
-
|
|
2023
|
-
|
|
2107
|
+
return await db.all(
|
|
2108
|
+
`SELECT n.* FROM nests n
|
|
2109
|
+
WHERE n.visibility = 'public'
|
|
2110
|
+
AND n.user_id != ?
|
|
2111
|
+
AND NOT EXISTS (
|
|
2112
|
+
SELECT 1 FROM nest_collaborators nc
|
|
2113
|
+
WHERE nc.nest_id = n.id AND nc.user_id = ?
|
|
2114
|
+
)
|
|
2115
|
+
ORDER BY n.created_at DESC`,
|
|
2116
|
+
[userId, userId]
|
|
2024
2117
|
);
|
|
2025
|
-
return row?.author ?? null;
|
|
2026
2118
|
}
|
|
2027
|
-
async function
|
|
2119
|
+
async function getNest(nestId) {
|
|
2028
2120
|
const db = getDb();
|
|
2029
|
-
|
|
2030
|
-
`SELECT requested_by FROM review_requests
|
|
2031
|
-
WHERE nest_id = ? AND node_id = ? AND status = 'pending'
|
|
2032
|
-
ORDER BY requested_at DESC LIMIT 1`,
|
|
2033
|
-
[nestId, nodeId]
|
|
2034
|
-
);
|
|
2035
|
-
return row?.requested_by ?? null;
|
|
2036
|
-
}
|
|
2037
|
-
async function canUserApprove(nestId, nodeId, userEmail) {
|
|
2038
|
-
const roles = await resolveUserRoles(nestId, userEmail, { nodeId });
|
|
2039
|
-
const isOwner = roles.includes("owner");
|
|
2040
|
-
const isSuper = isSuperAdmin(userEmail);
|
|
2041
|
-
const allowSelf = await nestAllowsSelfApprove(nestId);
|
|
2042
|
-
const hasStewardApprove = roles.includes("admin") || roles.includes("reviewer");
|
|
2043
|
-
const actor = await getPendingReviewRequester(nestId, nodeId) ?? await getCurrentVersionAuthor(nestId, nodeId);
|
|
2044
|
-
const isOwnSubmission = !!actor && actor.toLowerCase() === userEmail.toLowerCase();
|
|
2045
|
-
if (hasStewardApprove) {
|
|
2046
|
-
if (isOwnSubmission && !((isOwner || isSuper) && allowSelf)) {
|
|
2047
|
-
return {
|
|
2048
|
-
allowed: false,
|
|
2049
|
-
reason: "You submitted this version for review, so you can't approve it yourself. Ask another reviewer to approve it (separation of duties).",
|
|
2050
|
-
role: roles.includes("admin") ? "admin" : "reviewer"
|
|
2051
|
-
};
|
|
2052
|
-
}
|
|
2053
|
-
return {
|
|
2054
|
-
allowed: true,
|
|
2055
|
-
reason: "reviewer access (collaborator or steward)",
|
|
2056
|
-
role: roles.includes("admin") ? "admin" : "reviewer"
|
|
2057
|
-
};
|
|
2058
|
-
}
|
|
2059
|
-
if (isOwner || isSuper) {
|
|
2060
|
-
if (allowSelf) {
|
|
2061
|
-
return {
|
|
2062
|
-
allowed: true,
|
|
2063
|
-
reason: isOwner ? "nest owner (self-approve enabled)" : "super admin",
|
|
2064
|
-
role: isOwner ? "owner" : "super_admin"
|
|
2065
|
-
};
|
|
2066
|
-
}
|
|
2067
|
-
return {
|
|
2068
|
-
allowed: false,
|
|
2069
|
-
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.",
|
|
2070
|
-
role: isOwner ? "owner" : "super_admin"
|
|
2071
|
-
};
|
|
2072
|
-
}
|
|
2073
|
-
const held = primaryRole(roles);
|
|
2074
|
-
return {
|
|
2075
|
-
allowed: false,
|
|
2076
|
-
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.",
|
|
2077
|
-
role: held
|
|
2078
|
-
};
|
|
2079
|
-
}
|
|
2080
|
-
async function canUserAccess(nestId, nodeId, userEmail) {
|
|
2081
|
-
const roles = await resolveUserRoles(nestId, userEmail, { nodeId });
|
|
2082
|
-
if (roles.includes("owner")) {
|
|
2083
|
-
return { allowed: true, reason: "nest owner", role: "owner" };
|
|
2084
|
-
}
|
|
2085
|
-
if (isSuperAdmin(userEmail)) {
|
|
2086
|
-
return { allowed: true, reason: "super admin", role: "super_admin" };
|
|
2087
|
-
}
|
|
2088
|
-
if (canViewWith(roles)) {
|
|
2089
|
-
return {
|
|
2090
|
-
allowed: true,
|
|
2091
|
-
reason: "collaborator or steward access",
|
|
2092
|
-
role: primaryRole(roles)
|
|
2093
|
-
};
|
|
2094
|
-
}
|
|
2095
|
-
return { allowed: false, reason: "no collaborator or steward access", role: null };
|
|
2121
|
+
return await db.get("SELECT * FROM nests WHERE id = ?", [nestId]) || null;
|
|
2096
2122
|
}
|
|
2097
|
-
async function
|
|
2123
|
+
async function deleteNest(nestId) {
|
|
2098
2124
|
const db = getDb();
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
"
|
|
2102
|
-
[
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
"
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
for (const entry of entries) {
|
|
2110
|
-
const user = await db.get(
|
|
2111
|
-
"SELECT id FROM users WHERE email = ?",
|
|
2112
|
-
[entry.email]
|
|
2113
|
-
);
|
|
2114
|
-
const rawRole = entry.role || "reviewer";
|
|
2115
|
-
const role = rawRole === "admin" ? "reviewer" : rawRole;
|
|
2116
|
-
await assignSteward({
|
|
2117
|
-
nestId,
|
|
2118
|
-
scope,
|
|
2119
|
-
nodePattern: target?.nodePattern,
|
|
2120
|
-
tagName: target?.tagName ? target.tagName.trim().replace(/^#+/, "").toLowerCase() : void 0,
|
|
2121
|
-
userEmail: entry.email.toLowerCase(),
|
|
2122
|
-
userId: user?.id,
|
|
2123
|
-
role,
|
|
2124
|
-
assignedBy: "config",
|
|
2125
|
-
assignedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2126
|
-
isActive: true
|
|
2127
|
-
});
|
|
2128
|
-
count++;
|
|
2129
|
-
}
|
|
2130
|
-
};
|
|
2131
|
-
if (config2.nest) {
|
|
2132
|
-
await addEntries("nest", config2.nest);
|
|
2133
|
-
}
|
|
2134
|
-
if (config2.tags) {
|
|
2135
|
-
for (const [tagName, entries] of Object.entries(config2.tags)) {
|
|
2136
|
-
await addEntries("tag", entries, { tagName });
|
|
2137
|
-
}
|
|
2138
|
-
}
|
|
2139
|
-
if (config2.documents) {
|
|
2140
|
-
for (const [docPattern, entries] of Object.entries(config2.documents)) {
|
|
2141
|
-
await addEntries("document", entries, { nodePattern: docPattern });
|
|
2125
|
+
await db.transaction(async (tx) => {
|
|
2126
|
+
const id = nestId;
|
|
2127
|
+
await tx.run("DELETE FROM approved_versions WHERE nest_id = ?", [id]);
|
|
2128
|
+
await tx.run("DELETE FROM node_versions WHERE nest_id = ?", [id]);
|
|
2129
|
+
await tx.run("DELETE FROM review_requests WHERE nest_id = ?", [id]);
|
|
2130
|
+
await tx.run("DELETE FROM stewards WHERE nest_id = ?", [id]);
|
|
2131
|
+
await tx.run("DELETE FROM nest_collaborators WHERE nest_id = ?", [id]);
|
|
2132
|
+
try {
|
|
2133
|
+
await tx.run("DELETE FROM node_tag_index WHERE nest_id = ?", [id]);
|
|
2134
|
+
} catch {
|
|
2142
2135
|
}
|
|
2136
|
+
await tx.run("DELETE FROM api_keys WHERE nest_id = ?", [id]);
|
|
2137
|
+
await tx.run("DELETE FROM nests WHERE id = ?", [id]);
|
|
2138
|
+
});
|
|
2139
|
+
const path = resolveNestPath(nestId);
|
|
2140
|
+
try {
|
|
2141
|
+
rmSync2(path, { recursive: true, force: true });
|
|
2142
|
+
} catch (err) {
|
|
2143
|
+
console.warn(`[nests] failed to remove nest directory ${path}:`, err);
|
|
2143
2144
|
}
|
|
2144
|
-
|
|
2145
|
-
}
|
|
2146
|
-
function rowToSteward(row) {
|
|
2147
|
-
return {
|
|
2148
|
-
id: row.id,
|
|
2149
|
-
nestId: row.nest_id,
|
|
2150
|
-
scope: row.scope,
|
|
2151
|
-
nodePattern: row.node_pattern || void 0,
|
|
2152
|
-
tagName: row.tag_name || void 0,
|
|
2153
|
-
userEmail: row.user_email,
|
|
2154
|
-
userId: row.user_id || void 0,
|
|
2155
|
-
role: row.role,
|
|
2156
|
-
assignedBy: row.assigned_by,
|
|
2157
|
-
assignedAt: row.assigned_at,
|
|
2158
|
-
isActive: !!row.is_active
|
|
2159
|
-
};
|
|
2145
|
+
trackEvent("nest.delete", { nestId });
|
|
2160
2146
|
}
|
|
2161
2147
|
|
|
2162
2148
|
export {
|
|
2149
|
+
nowExpr,
|
|
2150
|
+
insertOrIgnore,
|
|
2151
|
+
insertOrReplace,
|
|
2163
2152
|
trackEvent,
|
|
2164
2153
|
startTelemetryLoop,
|
|
2165
2154
|
persistSetting,
|
|
@@ -2177,31 +2166,23 @@ export {
|
|
|
2177
2166
|
getAccessConfig,
|
|
2178
2167
|
isSuperAdmin,
|
|
2179
2168
|
isConfigSuperAdmin,
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
getTeam,
|
|
2184
|
-
renameTeam,
|
|
2185
|
-
deleteTeam,
|
|
2186
|
-
addMember,
|
|
2187
|
-
resolveMemberIdentity,
|
|
2188
|
-
replaceTeamRoster,
|
|
2189
|
-
updateMemberRole,
|
|
2190
|
-
removeMember,
|
|
2191
|
-
listNestTeams,
|
|
2192
|
-
shareTeamWithNest,
|
|
2193
|
-
unshareTeamFromNest,
|
|
2194
|
-
isServerAdminUserId,
|
|
2195
|
-
resolveNestPermission,
|
|
2196
|
-
permissionLevel,
|
|
2197
|
-
isPublicReader,
|
|
2169
|
+
verifySmtp,
|
|
2170
|
+
notifyEmailForNest,
|
|
2171
|
+
sendEmailToRecipient,
|
|
2198
2172
|
nestStorageRoot,
|
|
2199
2173
|
resolveNestPath,
|
|
2174
|
+
normalizeTag,
|
|
2175
|
+
syncNodeTags,
|
|
2176
|
+
removeNodeFromTagIndex,
|
|
2200
2177
|
uniqueNestName,
|
|
2201
2178
|
isStewardshipEnabled,
|
|
2202
2179
|
setStewardshipEnabled,
|
|
2203
2180
|
nestAllowsSelfApprove,
|
|
2204
2181
|
setAllowSelfApprove,
|
|
2182
|
+
nestReviewsPrimeOnly,
|
|
2183
|
+
setPrimeOnlyReview,
|
|
2184
|
+
nestPrimeTags,
|
|
2185
|
+
setPrimeTags,
|
|
2205
2186
|
disableStewardshipAndWipeGovernance,
|
|
2206
2187
|
renameNest,
|
|
2207
2188
|
createNest,
|
|
@@ -2214,30 +2195,35 @@ export {
|
|
|
2214
2195
|
deleteNest,
|
|
2215
2196
|
engineCache,
|
|
2216
2197
|
buildTitleMap,
|
|
2198
|
+
folderForNode,
|
|
2217
2199
|
describeNode,
|
|
2200
|
+
titleForNode,
|
|
2201
|
+
prettyFolderPath,
|
|
2218
2202
|
nestName,
|
|
2219
2203
|
docLink,
|
|
2220
2204
|
buildDocContext,
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2205
|
+
createTeam,
|
|
2206
|
+
findTeamByExternalId,
|
|
2207
|
+
listTeamsForUser,
|
|
2208
|
+
getTeam,
|
|
2209
|
+
renameTeam,
|
|
2210
|
+
deleteTeam,
|
|
2211
|
+
addMember,
|
|
2212
|
+
resolveMemberIdentity,
|
|
2213
|
+
replaceTeamRoster,
|
|
2214
|
+
updateMemberRole,
|
|
2215
|
+
removeMember,
|
|
2216
|
+
listNestTeams,
|
|
2217
|
+
shareTeamWithNest,
|
|
2218
|
+
unshareTeamFromNest,
|
|
2219
|
+
resolveTeamRolesForUser,
|
|
2220
|
+
collabPermToRole,
|
|
2221
|
+
canViewWith,
|
|
2222
|
+
canEditWith,
|
|
2223
|
+
canApproveWith,
|
|
2224
|
+
primaryRole,
|
|
2225
|
+
isServerAdminUserId,
|
|
2226
|
+
resolveNestPermission,
|
|
2227
|
+
permissionLevel,
|
|
2228
|
+
isPublicReader
|
|
2243
2229
|
};
|