@promptowl/contextnest-community 1.14.0 → 1.15.0

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