@promptowl/contextnest-community 1.12.0 → 1.13.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.
@@ -13,8 +13,9 @@ import {
13
13
  } from "./chunk-XQ46F76G.js";
14
14
  import {
15
15
  config,
16
- getDb
17
- } from "./chunk-3M7677XW.js";
16
+ getDb,
17
+ isEmailish
18
+ } from "./chunk-5QQ7WKAI.js";
18
19
  import {
19
20
  ANON_USER_ID
20
21
  } from "./chunk-SLTQACJW.js";
@@ -138,12 +139,13 @@ import {
138
139
  } from "@promptowl/contextnest-engine";
139
140
 
140
141
  // src/nests/service.ts
141
- import { rmSync as rmSync2, mkdirSync as mkdirSync2 } from "fs";
142
- import { v4 as uuid } from "uuid";
142
+ import { rmSync as rmSync2, mkdirSync } from "fs";
143
+ import { v4 as uuid2 } from "uuid";
143
144
  import { NestStorage } from "@promptowl/contextnest-engine";
144
145
 
145
146
  // src/fs/vault-import.ts
146
- import { mkdirSync, writeFileSync, rmSync } from "fs";
147
+ import { rmSync } from "fs";
148
+ import { writeFile, mkdir } from "fs/promises";
147
149
  import { join as join2, dirname, isAbsolute } from "path";
148
150
  function isSkippedSegment(segment) {
149
151
  return segment.startsWith(".") || segment === "node_modules" || segment === "_suggestions";
@@ -158,8 +160,9 @@ function safeSegments(rel) {
158
160
  }
159
161
  return segs;
160
162
  }
161
- function writeImportedFiles(dest, files) {
162
- let written = 0;
163
+ var WRITE_CONCURRENCY = 32;
164
+ async function writeImportedFiles(dest, files) {
165
+ const targets = [];
163
166
  const skipped = [];
164
167
  for (const f of files) {
165
168
  const segs = safeSegments(f?.path || "");
@@ -167,10 +170,19 @@ function writeImportedFiles(dest, files) {
167
170
  skipped.push(f?.path || "(empty)");
168
171
  continue;
169
172
  }
170
- const full = join2(dest, ...segs);
171
- mkdirSync(dirname(full), { recursive: true });
172
- writeFileSync(full, f.content ?? "", "utf-8");
173
- written++;
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
+ );
174
186
  }
175
187
  console.log(
176
188
  `[import] writeImportedFiles: received=${files.length} written=${written} skipped=${skipped.length}`
@@ -627,926 +639,975 @@ async function _validateLicenseImpl(forceFresh) {
627
639
  }
628
640
  }
629
641
 
630
- // src/nests/service.ts
631
- async function isImportedNest(nestId) {
632
- const row = await getDb().get(
633
- "SELECT is_imported FROM nests WHERE id = ?",
634
- [nestId]
635
- );
636
- return !!row?.is_imported;
642
+ // src/governance/teams-service.ts
643
+ import { v4 as uuid } from "uuid";
644
+ var VALID_TEAM_ROLES = ["admin", "editor", "viewer"];
645
+ function parseMembers(json) {
646
+ if (!json) return [];
647
+ try {
648
+ const parsed = JSON.parse(json);
649
+ return Array.isArray(parsed) ? parsed : [];
650
+ } catch {
651
+ return [];
652
+ }
637
653
  }
638
- function toSlug(name) {
639
- return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
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
+ };
640
665
  }
641
- async function uniqueNestName(userId, baseName) {
666
+ async function enrichTeam(team) {
642
667
  const db = getDb();
643
- let candidate = baseName;
644
- let n = 0;
645
- while (n < 1e3) {
646
- const slug = toSlug(candidate);
647
- const hit = await db.get(
648
- "SELECT 1 FROM nests WHERE user_id = ? AND slug = ? LIMIT 1",
649
- [userId, slug]
668
+ const ids = team.members.map((m) => m.userId);
669
+ const invited = /* @__PURE__ */ new Map();
670
+ if (ids.length) {
671
+ const rows = await db.all(
672
+ `SELECT id, is_invited FROM users WHERE id IN (${ids.map(() => "?").join(",")})`,
673
+ ids
650
674
  );
651
- if (!hit) return candidate;
652
- n++;
653
- candidate = `${baseName} (${n})`;
675
+ for (const r of rows) invited.set(r.id, r.is_invited);
654
676
  }
655
- return candidate;
677
+ const owner = await db.get("SELECT email FROM users WHERE id = ?", [
678
+ team.owner_id
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
+ };
656
689
  }
657
- async function isStewardshipEnabled(nestId) {
658
- const db = getDb();
659
- const row = await db.get(
660
- "SELECT stewardship_enabled FROM nests WHERE id = ?",
661
- [nestId]
662
- );
663
- return !!row?.stewardship_enabled;
690
+ function assertValidRole(role) {
691
+ if (!VALID_TEAM_ROLES.includes(role)) {
692
+ throw new ValidationError(
693
+ "role must be admin, editor, or viewer"
694
+ );
695
+ }
664
696
  }
665
- async function setStewardshipEnabled(nestId, enabled) {
697
+ async function loadTeamRow(teamId) {
666
698
  const db = getDb();
667
- await db.run("UPDATE nests SET stewardship_enabled = ? WHERE id = ?", [
668
- enabled ? 1 : 0,
669
- nestId
699
+ const row = await db.get("SELECT * FROM teams WHERE id = ?", [
700
+ teamId
670
701
  ]);
702
+ if (!row) throw new NotFoundError("Team not found");
703
+ return row;
671
704
  }
672
- async function nestAllowsSelfApprove(nestId) {
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)));
736
+ }
737
+ async function findTeamByExternalId(params) {
673
738
  const db = getDb();
674
739
  const row = await db.get(
675
- "SELECT allow_self_approve FROM nests WHERE id = ?",
676
- [nestId]
740
+ "SELECT * FROM teams WHERE owner_id = ? AND source = ? AND external_id = ?",
741
+ [params.ownerId, params.source, params.externalId]
677
742
  );
678
- return !!row?.allow_self_approve;
743
+ return row ? enrichTeam(rowToTeam(row)) : null;
679
744
  }
680
- async function setAllowSelfApprove(nestId, allow) {
745
+ async function listTeamsForUser(userId) {
681
746
  const db = getDb();
682
- await db.run("UPDATE nests SET allow_self_approve = ? WHERE id = ?", [
683
- allow ? 1 : 0,
684
- nestId
685
- ]);
747
+ const rows = await db.all(
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))));
686
755
  }
687
- async function disableStewardshipAndWipeGovernance(nestId) {
756
+ async function getTeam(teamId) {
757
+ return enrichTeam(rowToTeam(await loadTeamRow(teamId)));
758
+ }
759
+ async function renameTeam(params) {
760
+ const name = params.name?.trim();
761
+ if (!name) throw new ValidationError("Team name is required");
762
+ const row = await loadTeamRow(params.teamId);
763
+ if (managementRank(row, params.callerUserId, !!params.allowAdmin) < ROLE_RANK.admin) {
764
+ throw new ForbiddenError("Only a team admin or the owner can rename this team.");
765
+ }
688
766
  const db = getDb();
689
- return db.transaction(async (tx) => {
690
- const id = nestId;
691
- const stewards = (await tx.run("DELETE FROM stewards WHERE nest_id = ?", [id])).changes;
692
- const pendingReviews = (await tx.run(
693
- "DELETE FROM review_requests WHERE nest_id = ? AND status = 'pending'",
694
- [id]
695
- )).changes;
696
- await tx.run("UPDATE nests SET stewardship_enabled = 0 WHERE id = ?", [id]);
697
- return { stewards, pendingReviews };
767
+ await db.transaction(async (tx) => {
768
+ await tx.run("UPDATE teams SET name = ?, updated_at = ? WHERE id = ?", [
769
+ name,
770
+ (/* @__PURE__ */ new Date()).toISOString(),
771
+ params.teamId
772
+ ]);
773
+ await refreshTeamNameRefs(tx, params.teamId, name);
698
774
  });
775
+ return enrichTeam(rowToTeam(await loadTeamRow(params.teamId)));
699
776
  }
700
- async function renameNest(nestId, patch) {
701
- const db = getDb();
702
- const existing = await db.get("SELECT * FROM nests WHERE id = ?", [
703
- nestId
704
- ]);
705
- if (!existing) {
706
- throw new ValidationError("Nest not found");
777
+ async function deleteTeam(params) {
778
+ const row = await loadTeamRow(params.teamId);
779
+ if (managementRank(row, params.callerUserId, !!params.allowAdmin) < OWNER_RANK) {
780
+ throw new ForbiddenError("Only the team owner can delete this team.");
707
781
  }
708
- const updates = [];
709
- const params = [];
710
- if (patch.name !== void 0) {
711
- const trimmed = patch.name.trim();
712
- if (!trimmed) throw new ValidationError("Name cannot be empty");
713
- const slug = toSlug(trimmed);
714
- if (!slug) throw new ValidationError("Name must contain at least one alphanumeric character");
715
- const conflict = await db.get(
716
- "SELECT id FROM nests WHERE user_id = ? AND slug = ? AND id != ? LIMIT 1",
717
- [existing.user_id, slug, nestId]
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
+ }
788
+ async function addMember(params) {
789
+ assertValidRole(params.role);
790
+ const row = await loadTeamRow(params.teamId);
791
+ const rank = managementRank(row, params.callerUserId, !!params.allowAdmin);
792
+ if (rank < ROLE_RANK.editor) {
793
+ throw new ForbiddenError(
794
+ "Only an editor or admin member (or the owner) can add people to this team."
718
795
  );
719
- if (conflict) {
720
- throw new ValidationError(
721
- `Another nest already uses this name. Pick a different one.`
796
+ }
797
+ if (rank < OWNER_RANK && ROLE_RANK[params.role] > rank) {
798
+ throw new ForbiddenError("You can't give a member a higher role than your own.");
799
+ }
800
+ if (!params.userId && params.email && !isEmailish(params.email)) {
801
+ throw new ValidationError("Enter a valid email address.");
802
+ }
803
+ const { userId: resolvedId, email: resolvedEmail } = await resolveMemberIdentity(params);
804
+ await mutateMembers(params.teamId, (members) => {
805
+ if (members.some((m) => m.userId === resolvedId)) {
806
+ throw new ConflictError(
807
+ `${resolvedEmail || "This user"} is already a member of this team. Change their role instead of adding them again.`
722
808
  );
723
809
  }
724
- updates.push("name = ?", "slug = ?");
725
- params.push(trimmed, slug);
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 ?? "") };
726
825
  }
727
- if (patch.description !== void 0) {
728
- updates.push("description = ?");
729
- params.push(patch.description || null);
826
+ const requested = params.email ? normalizeEmail(params.email) : void 0;
827
+ if (!requested) {
828
+ throw new ValidationError("user_id or email is required");
730
829
  }
731
- if (updates.length === 0) {
732
- return existing;
830
+ const existing = await db.get(
831
+ "SELECT id, email FROM users WHERE LOWER(email) = ?",
832
+ [requested]
833
+ );
834
+ if (existing) {
835
+ return {
836
+ userId: existing.id,
837
+ email: normalizeEmail(existing.email ?? requested)
838
+ };
733
839
  }
734
- params.push(nestId);
840
+ const { hashPassword } = await import("./keys-73STFJJB.js");
841
+ const userId = uuid();
735
842
  await db.run(
736
- `UPDATE nests SET ${updates.join(", ")} WHERE id = ?`,
737
- params
843
+ "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
844
+ [userId, requested, null, await hashPassword(uuid())]
738
845
  );
739
- return await db.get("SELECT * FROM nests WHERE id = ?", [nestId]);
846
+ return { userId, email: requested };
740
847
  }
741
- async function createNest(userId, name, description) {
742
- const id = uuid();
743
- const slug = toSlug(name);
744
- const db = getDb();
745
- const visibility = userId === ANON_USER_ID ? "public" : "private";
746
- await db.run(
747
- "INSERT INTO nests (id, user_id, name, slug, description, visibility) VALUES (?, ?, ?, ?, ?, ?)",
748
- [id, userId, name, slug, description || null, visibility]
848
+ async function replaceTeamRoster(params) {
849
+ for (const m of params.members) assertValidRole(m.role);
850
+ const row = await loadTeamRow(params.teamId);
851
+ if (managementRank(row, params.callerUserId, !!params.allowAdmin) < ROLE_RANK.admin) {
852
+ throw new ForbiddenError(
853
+ "Only a team admin or the owner can replace this team's roster."
854
+ );
855
+ }
856
+ await mutateMembers(
857
+ params.teamId,
858
+ () => params.members.map((m) => ({
859
+ userId: m.userId,
860
+ email: m.email,
861
+ role: m.role
862
+ }))
749
863
  );
750
- const path = resolveNestPath(id);
751
- mkdirSync2(path, { recursive: true });
752
- const storage = new NestStorage(path);
753
- await storage.init(name);
754
- trackEvent("nest.create", { nestId: id, userId });
755
- return await db.get("SELECT * FROM nests WHERE id = ?", [id]);
864
+ return enrichTeam(rowToTeam(await loadTeamRow(params.teamId)));
756
865
  }
757
- async function importNest(userId, name, files) {
758
- const nm = (name || "").trim();
759
- if (!nm) {
760
- throw new ValidationError("name is required");
866
+ async function updateMemberRole(params) {
867
+ assertValidRole(params.role);
868
+ const row = await loadTeamRow(params.teamId);
869
+ const rank = managementRank(row, params.callerUserId, !!params.allowAdmin);
870
+ if (rank < ROLE_RANK.admin) {
871
+ throw new ForbiddenError("Only a team admin or the owner can change roles.");
761
872
  }
762
- if (!Array.isArray(files)) {
763
- throw new ValidationError("files are required");
873
+ if (rank < OWNER_RANK && ROLE_RANK[params.role] > rank) {
874
+ throw new ForbiddenError("You can't set a role higher than your own.");
875
+ }
876
+ await mutateMembers(params.teamId, (members) => {
877
+ const member = members.find((m) => m.userId === params.userId);
878
+ if (!member) throw new NotFoundError("Team member not found");
879
+ member.role = params.role;
880
+ return members;
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.");
764
888
  }
889
+ await mutateMembers(params.teamId, (members) => {
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) {
765
899
  const db = getDb();
766
- const id = uuid();
767
- const slug = toSlug(nm);
768
- const visibility = userId === ANON_USER_ID ? "public" : "private";
769
- const dest = resolveNestPath(id);
770
- mkdirSync2(dest, { recursive: true });
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
+ });
912
+ }
913
+ function parseSharedTeams(json) {
914
+ if (!json) return [];
771
915
  try {
772
- writeImportedFiles(dest, files);
773
- } catch (err) {
774
- console.error("[nests] import write failed", dest, err);
775
- discardImportDir(dest);
776
- throw new ValidationError("Failed to import folder");
916
+ const parsed = JSON.parse(json);
917
+ return Array.isArray(parsed) ? parsed : [];
918
+ } catch {
919
+ return [];
777
920
  }
778
- await db.run(
779
- "INSERT INTO nests (id, user_id, name, slug, description, visibility, is_imported) VALUES (?, ?, ?, ?, ?, ?, 1)",
780
- [id, userId, nm, slug, null, visibility]
781
- );
782
- trackEvent("nest.import", { nestId: id, userId });
783
- return await db.get("SELECT * FROM nests WHERE id = ?", [id]);
784
921
  }
785
- async function listNests(userId) {
922
+ async function listNestTeams(nestId) {
786
923
  const db = getDb();
787
- if (userId === ANON_USER_ID) {
788
- return await db.all(
789
- "SELECT * FROM nests WHERE user_id = ? ORDER BY created_at DESC",
790
- [ANON_USER_ID]
924
+ const row = await db.get("SELECT shared_teams FROM nests WHERE id = ?", [
925
+ nestId
926
+ ]);
927
+ if (!row) throw new NotFoundError("Nest not found");
928
+ return parseSharedTeams(row.shared_teams);
929
+ }
930
+ async function shareTeamWithNest(params) {
931
+ const db = getDb();
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]
791
936
  );
792
- }
793
- const includeAnon = config.AUTH_MODE === "open" || await isLicenseAdminUserId(userId);
794
- if (!includeAnon) {
795
- return await db.all(
796
- "SELECT * FROM nests WHERE user_id = ? ORDER BY created_at DESC",
797
- [userId]
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"
798
958
  );
799
- }
800
- return await db.all(
801
- "SELECT * FROM nests WHERE user_id = ? OR user_id = ? ORDER BY created_at DESC",
802
- [userId, ANON_USER_ID]
803
- );
959
+ if (hasGovernanceRole) {
960
+ await tx.run("UPDATE nests SET stewardship_enabled = 1 WHERE id = ?", [
961
+ params.nestId
962
+ ]);
963
+ }
964
+ return refs;
965
+ });
804
966
  }
805
- async function nestsSharedViaTeams(userId) {
967
+ async function unshareTeamFromNest(params) {
806
968
  const db = getDb();
807
- const teams = await db.all(
808
- "SELECT id, members FROM teams WHERE members LIKE ?",
809
- [`%${userId}%`]
969
+ return db.transaction(async (tx) => {
970
+ const nest = await tx.get("SELECT shared_teams FROM nests WHERE id = ?", [
971
+ params.nestId
972
+ ]);
973
+ if (!nest) throw new NotFoundError("Nest not found");
974
+ const refs = parseSharedTeams(nest.shared_teams);
975
+ const next = refs.filter((r) => r.teamId !== params.teamId);
976
+ if (next.length === refs.length) {
977
+ throw new NotFoundError("Team is not shared with this nest.");
978
+ }
979
+ await tx.run("UPDATE nests SET shared_teams = ? WHERE id = ?", [
980
+ JSON.stringify(next),
981
+ params.nestId
982
+ ]);
983
+ return next;
984
+ });
985
+ }
986
+ async function refreshTeamNameRefs(tx, teamId, newName) {
987
+ const nests = await tx.all(
988
+ "SELECT id, shared_teams FROM nests WHERE shared_teams LIKE ?",
989
+ [`%${teamId}%`]
810
990
  );
811
- const myTeamIds = /* @__PURE__ */ new Set();
812
- for (const t of teams) {
813
- try {
814
- const members = JSON.parse(t.members || "[]");
815
- if (Array.isArray(members) && members.some((m) => m?.userId === userId)) {
816
- myTeamIds.add(t.id);
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;
817
998
  }
818
- } catch {
999
+ }
1000
+ if (changed) {
1001
+ await tx.run("UPDATE nests SET shared_teams = ? WHERE id = ?", [
1002
+ JSON.stringify(refs),
1003
+ n.id
1004
+ ]);
819
1005
  }
820
1006
  }
821
- if (myTeamIds.size === 0) return [];
822
- const ids = [...myTeamIds];
823
- const candidates = await db.all(
824
- `SELECT * FROM nests
825
- WHERE user_id != ?
826
- AND (${ids.map(() => "shared_teams LIKE ?").join(" OR ")})`,
827
- [userId, ...ids.map((id) => `%${id}%`)]
1007
+ }
1008
+ async function removeTeamRefs(tx, teamId) {
1009
+ const nests = await tx.all(
1010
+ "SELECT id, shared_teams FROM nests WHERE shared_teams LIKE ?",
1011
+ [`%${teamId}%`]
828
1012
  );
829
- return candidates.filter((n) => {
830
- try {
831
- const refs = JSON.parse(n.shared_teams || "[]");
832
- return Array.isArray(refs) && refs.some((r) => myTeamIds.has(r?.teamId));
833
- } catch {
834
- return false;
1013
+ for (const n of nests) {
1014
+ const refs = parseSharedTeams(n.shared_teams);
1015
+ const next = refs.filter((r) => r.teamId !== teamId);
1016
+ if (next.length !== refs.length) {
1017
+ await tx.run("UPDATE nests SET shared_teams = ? WHERE id = ?", [
1018
+ JSON.stringify(next),
1019
+ n.id
1020
+ ]);
835
1021
  }
836
- });
1022
+ }
837
1023
  }
838
- async function listSharedNests(userId) {
1024
+ async function resolveTeamRolesForUser(nestId, userId) {
1025
+ const refs = await listNestTeams(nestId).catch(() => []);
1026
+ if (refs.length === 0) return [];
839
1027
  const db = getDb();
840
- const caller = await db.get("SELECT email FROM users WHERE id = ?", [
841
- userId
842
- ]);
843
- const isServerAdmin = await isLicenseAdminUserId(userId) || (caller?.email ? isSuperAdmin(caller.email) : false);
844
- if (isServerAdmin) {
845
- return await db.all(
846
- "SELECT * FROM nests WHERE user_id != ? ORDER BY created_at DESC",
847
- [userId]
848
- );
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);
849
1036
  }
850
- const direct = await db.all(
851
- `SELECT DISTINCT n.* FROM nests n
852
- LEFT JOIN nest_collaborators nc
853
- ON nc.nest_id = n.id AND nc.user_id = ?
854
- LEFT JOIN users u ON u.id = ?
855
- LEFT JOIN stewards s
856
- ON s.nest_id = n.id AND s.is_active = 1
857
- AND LOWER(s.user_email) = LOWER(u.email)
858
- WHERE n.user_id != ?
859
- AND (nc.user_id IS NOT NULL OR s.id IS NOT NULL)
860
- ORDER BY n.created_at DESC`,
861
- [userId, userId, userId]
862
- );
863
- const seen = new Set(direct.map((n) => n.id));
864
- for (const n of await nestsSharedViaTeams(userId)) {
865
- if (!seen.has(n.id)) {
866
- direct.push(n);
867
- seen.add(n.id);
868
- }
869
- }
870
- direct.sort(
871
- (a, b) => a.created_at === b.created_at ? 0 : a.created_at < b.created_at ? 1 : -1
872
- );
873
- return direct;
874
- }
875
- async function listPublicNests(userId) {
876
- const db = getDb();
877
- return await db.all(
878
- `SELECT n.* FROM nests n
879
- WHERE n.visibility = 'public'
880
- AND n.user_id != ?
881
- AND NOT EXISTS (
882
- SELECT 1 FROM nest_collaborators nc
883
- WHERE nc.nest_id = n.id AND nc.user_id = ?
884
- )
885
- ORDER BY n.created_at DESC`,
886
- [userId, userId]
887
- );
888
- }
889
- async function getNest(nestId) {
890
- const db = getDb();
891
- return await db.get("SELECT * FROM nests WHERE id = ?", [nestId]) || null;
892
- }
893
- async function deleteNest(nestId) {
894
- const db = getDb();
895
- await db.transaction(async (tx) => {
896
- const id = nestId;
897
- await tx.run("DELETE FROM approved_versions WHERE nest_id = ?", [id]);
898
- await tx.run("DELETE FROM node_versions WHERE nest_id = ?", [id]);
899
- await tx.run("DELETE FROM review_requests WHERE nest_id = ?", [id]);
900
- await tx.run("DELETE FROM stewards WHERE nest_id = ?", [id]);
901
- await tx.run("DELETE FROM nest_collaborators WHERE nest_id = ?", [id]);
902
- try {
903
- await tx.run("DELETE FROM node_tag_index WHERE nest_id = ?", [id]);
904
- } catch {
905
- }
906
- await tx.run("DELETE FROM api_keys WHERE nest_id = ?", [id]);
907
- await tx.run("DELETE FROM nests WHERE id = ?", [id]);
908
- });
909
- const path = resolveNestPath(nestId);
910
- try {
911
- rmSync2(path, { recursive: true, force: true });
912
- } catch (err) {
913
- console.warn(`[nests] failed to remove nest directory ${path}:`, err);
914
- }
915
- trackEvent("nest.delete", { nestId });
1037
+ return [...roles];
916
1038
  }
917
1039
 
918
- // src/nodes/flat-storage.ts
919
- import { readdirSync } from "fs";
920
- import { join as join4, relative, sep } from "path";
921
- import { NestStorage as NestStorage2 } from "@promptowl/contextnest-engine";
922
- var META_FILES = /* @__PURE__ */ new Set([
923
- "INDEX.md",
924
- "CONTEXT.md",
925
- "CLAUDE.md",
926
- "GEMINI.md",
927
- "AGENTS.md",
928
- "QWEN.md"
929
- ]);
930
- var FlatNestStorage = class extends NestStorage2 {
931
- async discoverDocuments() {
932
- const root = this.root;
933
- let entries;
934
- try {
935
- entries = readdirSync(root, { recursive: true, withFileTypes: true });
936
- } catch (err) {
937
- console.error("[FlatNestStorage] cannot read folder", root, err);
938
- return [];
939
- }
940
- const ids = [];
941
- for (const e of entries) {
942
- if (!e.isFile() || !e.name.endsWith(".md")) continue;
943
- if (META_FILES.has(e.name)) continue;
944
- const dir = e.parentPath ?? e.path ?? root;
945
- const rel = relative(root, join4(dir, e.name)).split(sep).join("/");
946
- if (rel.split("/").some(isSkippedSegment)) continue;
947
- ids.push(rel.replace(/\.md$/, ""));
948
- }
949
- const nodes = [];
950
- for (const id of ids.sort()) {
951
- try {
952
- nodes.push(await this.readDocument(id));
953
- } catch (err) {
954
- console.error("[FlatNestStorage] skipped unreadable doc", id, err);
955
- }
956
- }
957
- return nodes;
958
- }
959
- };
960
-
961
- // src/nodes/engine.ts
962
- var NestEngineCache = class {
963
- cache = /* @__PURE__ */ new Map();
964
- async get(nestId) {
965
- let engine = this.cache.get(nestId);
966
- if (!engine) {
967
- const nestPath = resolveNestPath(nestId);
968
- const storage = await isImportedNest(nestId) ? new FlatNestStorage(nestPath) : new NestStorage3(nestPath);
969
- const query = new GraphQueryEngine(storage);
970
- const versions = new VersionManager(storage);
971
- engine = { storage, query, versions };
972
- this.cache.set(nestId, engine);
973
- }
974
- return engine;
975
- }
976
- evict(nestId) {
977
- this.cache.delete(nestId);
978
- }
979
- };
980
- var engineCache = new NestEngineCache();
981
-
982
- // src/governance/title-resolver.ts
983
- async function buildTitleMap(nestId) {
984
- try {
985
- const { storage } = await engineCache.get(nestId);
986
- const docs = await storage.discoverDocuments();
987
- return new Map(docs.map((d) => [d.id, d.frontmatter.title]));
988
- } catch {
989
- return /* @__PURE__ */ new Map();
1040
+ // src/governance/roles.ts
1041
+ function collabPermToRole(permission) {
1042
+ switch (permission) {
1043
+ case "owner":
1044
+ return "owner";
1045
+ case "admin":
1046
+ return "admin";
1047
+ case "write":
1048
+ return "editor";
1049
+ case "read":
1050
+ return "viewer";
1051
+ default:
1052
+ return null;
990
1053
  }
991
1054
  }
992
- function folderForNode(nodeId) {
993
- return nodeId.replace(/^nodes\//, "").split("/").slice(0, -1).join("/");
994
- }
995
- async function describeNode(nestId, nodeId) {
996
- const titles = await buildTitleMap(nestId);
997
- const title = titles.get(nodeId) || nodeId;
998
- const folder = folderForNode(nodeId);
999
- return folder ? `${title} (in ${folder})` : title;
1000
- }
1001
- async function nestName(nestId) {
1002
- try {
1003
- const row = await getDb().get("SELECT name FROM nests WHERE id = ?", [
1004
- nestId
1005
- ]);
1006
- return row?.name || nestId;
1007
- } catch {
1008
- return nestId;
1055
+ function teamRoleToPermission(role) {
1056
+ switch (role) {
1057
+ case "admin":
1058
+ return "admin";
1059
+ case "editor":
1060
+ return "write";
1061
+ case "viewer":
1062
+ return "read";
1063
+ default:
1064
+ return null;
1009
1065
  }
1010
1066
  }
1011
- function docLink(nestId, nodeId, baseUrl) {
1012
- const base = baseUrl || config.PUBLIC_BASE_URL;
1013
- if (!base) return "";
1014
- const q = new URLSearchParams({ nest: nestId });
1015
- if (nodeId) q.set("doc", nodeId);
1016
- return `${base.replace(/\/$/, "")}/?${q.toString()}`;
1017
- }
1018
- async function buildDocContext(nestId, nodeId, baseUrl) {
1019
- const titles = await buildTitleMap(nestId);
1020
- const docTitle = titles.get(nodeId) || nodeId;
1021
- const folder = folderForNode(nodeId);
1022
- const name = await nestName(nestId);
1023
- return {
1024
- docTitle,
1025
- // Slack one-liner label ("Title (in Folder)"), same as describeNode().
1026
- label: folder ? `${docTitle} (in ${folder})` : docTitle,
1027
- path: folder ? `${name} / ${folder}` : name,
1028
- link: docLink(nestId, nodeId, baseUrl)
1029
- };
1067
+ var includesAny = (roles, wanted) => roles.some((r) => wanted.includes(r));
1068
+ var canViewWith = (roles) => roles.length > 0;
1069
+ var canEditWith = (roles) => includesAny(roles, ["owner", "admin", "editor"]);
1070
+ var PRECEDENCE = [
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;
1030
1080
  }
1031
1081
 
1032
- // src/governance/teams-service.ts
1033
- import { v4 as uuid2 } from "uuid";
1034
- var VALID_TEAM_ROLES = ["admin", "editor", "viewer"];
1035
- function parseMembers(json) {
1036
- if (!json) return [];
1037
- try {
1038
- const parsed = JSON.parse(json);
1039
- return Array.isArray(parsed) ? parsed : [];
1040
- } catch {
1041
- return [];
1082
+ // src/shared/access.ts
1083
+ async function isServerAdminUserId(userId) {
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);
1090
+ }
1091
+ var PERMISSION_LEVELS = {
1092
+ none: 0,
1093
+ read: 1,
1094
+ write: 2,
1095
+ admin: 3,
1096
+ owner: 4
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";
1110
+ }
1111
+ const higher = (a, b) => b && PERMISSION_LEVELS[b] > PERMISSION_LEVELS[a] ? b : a;
1112
+ let best = "none";
1113
+ const directGrant = await db.get(
1114
+ "SELECT permission FROM nest_collaborators WHERE nest_id = ? AND user_id = ?",
1115
+ [nestId, userId]
1116
+ );
1117
+ if (directGrant) best = higher(best, directGrant.permission);
1118
+ if (best === "admin") return best;
1119
+ for (const role of await resolveTeamRolesForUser(nestId, userId)) {
1120
+ best = higher(best, teamRoleToPermission(role));
1042
1121
  }
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;
1043
1134
  }
1044
- function rowToTeam(row) {
1045
- return {
1046
- id: row.id,
1047
- name: row.name,
1048
- owner_id: row.owner_id,
1049
- members: parseMembers(row.members),
1050
- source: row.source ?? null,
1051
- external_id: row.external_id ?? null,
1052
- created_at: row.created_at,
1053
- updated_at: row.updated_at
1054
- };
1135
+ function permissionLevel(p) {
1136
+ return PERMISSION_LEVELS[p] ?? 0;
1055
1137
  }
1056
- async function enrichTeam(team) {
1138
+ async function isPublicReader(nestId, userId) {
1057
1139
  const db = getDb();
1058
- const ids = team.members.map((m) => m.userId);
1059
- const invited = /* @__PURE__ */ new Map();
1060
- if (ids.length) {
1061
- const rows = await db.all(
1062
- `SELECT id, is_invited FROM users WHERE id IN (${ids.map(() => "?").join(",")})`,
1063
- ids
1064
- );
1065
- for (const r of rows) invited.set(r.id, r.is_invited);
1066
- }
1067
- const owner = await db.get("SELECT email FROM users WHERE id = ?", [
1068
- team.owner_id
1069
- ]);
1070
- return {
1071
- ...team,
1072
- owner_email: owner?.email,
1073
- // Unknown id (deleted user) treat as not registered.
1074
- members: team.members.map((m) => ({
1075
- ...m,
1076
- registered: (invited.get(m.userId) ?? 1) === 0
1077
- }))
1078
- };
1140
+ const nest = await db.get("SELECT user_id, visibility FROM nests WHERE id = ?", [nestId]);
1141
+ if (!nest) return false;
1142
+ if (nest.visibility !== "public") return false;
1143
+ if (nest.user_id === userId) return false;
1144
+ const directGrant = await db.get(
1145
+ "SELECT 1 FROM nest_collaborators WHERE nest_id = ? AND user_id = ?",
1146
+ [nestId, userId]
1147
+ );
1148
+ if (directGrant) return false;
1149
+ if (await isLicenseAdminUserId(userId)) return false;
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;
1079
1163
  }
1080
- function assertValidRole(role) {
1081
- if (!VALID_TEAM_ROLES.includes(role)) {
1082
- throw new ValidationError(
1083
- "role must be admin, editor, or viewer"
1164
+
1165
+ // src/nests/service.ts
1166
+ async function isImportedNest(nestId) {
1167
+ const row = await getDb().get(
1168
+ "SELECT is_imported FROM nests WHERE id = ?",
1169
+ [nestId]
1170
+ );
1171
+ return !!row?.is_imported;
1172
+ }
1173
+ function toSlug(name) {
1174
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1175
+ }
1176
+ async function uniqueNestName(userId, baseName) {
1177
+ const db = getDb();
1178
+ let candidate = baseName;
1179
+ let n = 0;
1180
+ while (n < 1e3) {
1181
+ const slug = toSlug(candidate);
1182
+ const hit = await db.get(
1183
+ "SELECT 1 FROM nests WHERE user_id = ? AND slug = ? LIMIT 1",
1184
+ [userId, slug]
1084
1185
  );
1186
+ if (!hit) return candidate;
1187
+ n++;
1188
+ candidate = `${baseName} (${n})`;
1085
1189
  }
1190
+ return candidate;
1086
1191
  }
1087
- async function loadTeamRow(teamId) {
1192
+ async function isStewardshipEnabled(nestId) {
1088
1193
  const db = getDb();
1089
- const row = await db.get("SELECT * FROM teams WHERE id = ?", [
1090
- teamId
1091
- ]);
1092
- if (!row) throw new NotFoundError("Team not found");
1093
- return row;
1094
- }
1095
- var ROLE_RANK = {
1096
- viewer: 1,
1097
- editor: 2,
1098
- admin: 3
1099
- };
1100
- var OWNER_RANK = 4;
1101
- function managementRank(row, callerUserId, allowAdmin) {
1102
- if (row.owner_id === callerUserId || allowAdmin) return OWNER_RANK;
1103
- const m = parseMembers(row.members).find((x) => x.userId === callerUserId);
1104
- return m ? ROLE_RANK[m.role] ?? 0 : 0;
1194
+ const row = await db.get(
1195
+ "SELECT stewardship_enabled FROM nests WHERE id = ?",
1196
+ [nestId]
1197
+ );
1198
+ return !!row?.stewardship_enabled;
1105
1199
  }
1106
- async function createTeam(params) {
1107
- const name = params.name?.trim();
1108
- if (!name) throw new ValidationError("Team name is required");
1200
+ async function setStewardshipEnabled(nestId, enabled) {
1109
1201
  const db = getDb();
1110
- const now = (/* @__PURE__ */ new Date()).toISOString();
1111
- const id = uuid2();
1112
- await db.run(
1113
- "INSERT INTO teams (id, name, owner_id, members, source, external_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
1114
- [
1115
- id,
1116
- name,
1117
- params.ownerId,
1118
- "[]",
1119
- params.source ?? null,
1120
- params.externalId ?? null,
1121
- now,
1122
- now
1123
- ]
1124
- );
1125
- return enrichTeam(rowToTeam(await loadTeamRow(id)));
1202
+ await db.run("UPDATE nests SET stewardship_enabled = ? WHERE id = ?", [
1203
+ enabled ? 1 : 0,
1204
+ nestId
1205
+ ]);
1126
1206
  }
1127
- async function findTeamByExternalId(params) {
1207
+ async function nestAllowsSelfApprove(nestId) {
1128
1208
  const db = getDb();
1129
1209
  const row = await db.get(
1130
- "SELECT * FROM teams WHERE owner_id = ? AND source = ? AND external_id = ?",
1131
- [params.ownerId, params.source, params.externalId]
1210
+ "SELECT allow_self_approve FROM nests WHERE id = ?",
1211
+ [nestId]
1132
1212
  );
1133
- return row ? enrichTeam(rowToTeam(row)) : null;
1213
+ return !!row?.allow_self_approve;
1134
1214
  }
1135
- async function listTeamsForUser(userId) {
1215
+ async function setAllowSelfApprove(nestId, allow) {
1136
1216
  const db = getDb();
1137
- const rows = await db.all(
1138
- "SELECT * FROM teams WHERE owner_id = ? OR members LIKE ? ORDER BY created_at",
1139
- [userId, `%${userId}%`]
1140
- );
1141
- const mine = rows.filter(
1142
- (r) => r.owner_id === userId || parseMembers(r.members).some((m) => m.userId === userId)
1143
- );
1144
- return Promise.all(mine.map((r) => enrichTeam(rowToTeam(r))));
1145
- }
1146
- async function getTeam(teamId) {
1147
- return enrichTeam(rowToTeam(await loadTeamRow(teamId)));
1217
+ await db.run("UPDATE nests SET allow_self_approve = ? WHERE id = ?", [
1218
+ allow ? 1 : 0,
1219
+ nestId
1220
+ ]);
1148
1221
  }
1149
- async function renameTeam(params) {
1150
- const name = params.name?.trim();
1151
- if (!name) throw new ValidationError("Team name is required");
1152
- const row = await loadTeamRow(params.teamId);
1153
- if (managementRank(row, params.callerUserId, !!params.allowAdmin) < ROLE_RANK.admin) {
1154
- throw new ForbiddenError("Only a team admin or the owner can rename this team.");
1155
- }
1222
+ async function disableStewardshipAndWipeGovernance(nestId) {
1156
1223
  const db = getDb();
1157
- await db.transaction(async (tx) => {
1158
- await tx.run("UPDATE teams SET name = ?, updated_at = ? WHERE id = ?", [
1159
- name,
1160
- (/* @__PURE__ */ new Date()).toISOString(),
1161
- params.teamId
1162
- ]);
1163
- await refreshTeamNameRefs(tx, params.teamId, name);
1224
+ return db.transaction(async (tx) => {
1225
+ const id = nestId;
1226
+ const stewards = (await tx.run("DELETE FROM stewards WHERE nest_id = ?", [id])).changes;
1227
+ const pendingReviews = (await tx.run(
1228
+ "DELETE FROM review_requests WHERE nest_id = ? AND status = 'pending'",
1229
+ [id]
1230
+ )).changes;
1231
+ await tx.run("UPDATE nests SET stewardship_enabled = 0 WHERE id = ?", [id]);
1232
+ return { stewards, pendingReviews };
1164
1233
  });
1165
- return enrichTeam(rowToTeam(await loadTeamRow(params.teamId)));
1166
1234
  }
1167
- async function deleteTeam(params) {
1168
- const row = await loadTeamRow(params.teamId);
1169
- if (managementRank(row, params.callerUserId, !!params.allowAdmin) < OWNER_RANK) {
1170
- throw new ForbiddenError("Only the team owner can delete this team.");
1171
- }
1235
+ async function renameNest(nestId, patch) {
1172
1236
  const db = getDb();
1173
- await db.transaction(async (tx) => {
1174
- await tx.run("DELETE FROM teams WHERE id = ?", [params.teamId]);
1175
- await removeTeamRefs(tx, params.teamId);
1176
- });
1177
- }
1178
- async function addMember(params) {
1179
- assertValidRole(params.role);
1180
- const row = await loadTeamRow(params.teamId);
1181
- const rank = managementRank(row, params.callerUserId, !!params.allowAdmin);
1182
- if (rank < ROLE_RANK.editor) {
1183
- throw new ForbiddenError(
1184
- "Only an editor or admin member (or the owner) can add people to this team."
1185
- );
1186
- }
1187
- if (rank < OWNER_RANK && ROLE_RANK[params.role] > rank) {
1188
- throw new ForbiddenError("You can't give a member a higher role than your own.");
1237
+ const existing = await db.get("SELECT * FROM nests WHERE id = ?", [
1238
+ nestId
1239
+ ]);
1240
+ if (!existing) {
1241
+ throw new ValidationError("Nest not found");
1189
1242
  }
1190
- const { userId: resolvedId, email: resolvedEmail } = await resolveMemberIdentity(params);
1191
- await mutateMembers(params.teamId, (members) => {
1192
- if (members.some((m) => m.userId === resolvedId)) {
1193
- throw new ConflictError(
1194
- `${resolvedEmail || "This user"} is already a member of this team. Change their role instead of adding them again.`
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.`
1195
1257
  );
1196
1258
  }
1197
- return [
1198
- ...members,
1199
- { userId: resolvedId, email: resolvedEmail, role: params.role }
1200
- ];
1201
- });
1202
- return enrichTeam(rowToTeam(await loadTeamRow(params.teamId)));
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;
1268
+ }
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]);
1203
1275
  }
1204
- async function resolveMemberIdentity(params) {
1276
+ async function createNest(userId, name, description) {
1277
+ const id = uuid2();
1205
1278
  const db = getDb();
1206
- if (params.userId) {
1207
- const u = await db.get("SELECT email FROM users WHERE id = ?", [
1208
- params.userId
1209
- ]);
1210
- if (!u) throw new ValidationError("User not found");
1211
- return { userId: params.userId, email: normalizeEmail(u.email ?? "") };
1279
+ const trimmed = name.trim();
1280
+ if (!trimmed) throw new ValidationError("Name cannot be empty");
1281
+ const slug = toSlug(trimmed);
1282
+ if (!slug) {
1283
+ throw new ValidationError(
1284
+ "Name must contain at least one alphanumeric character"
1285
+ );
1212
1286
  }
1213
- const requested = params.email ? normalizeEmail(params.email) : void 0;
1214
- if (!requested) {
1215
- throw new ValidationError("user_id or email is required");
1287
+ const conflict = await db.get(
1288
+ "SELECT id FROM nests WHERE user_id = ? AND slug = ? LIMIT 1",
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
+ );
1216
1295
  }
1217
- const existing = await db.get(
1218
- "SELECT id, email FROM users WHERE LOWER(email) = ?",
1219
- [requested]
1296
+ const visibility = userId === ANON_USER_ID ? "public" : "private";
1297
+ await db.run(
1298
+ "INSERT INTO nests (id, user_id, name, slug, description, visibility) VALUES (?, ?, ?, ?, ?, ?)",
1299
+ [id, userId, trimmed, slug, description || null, visibility]
1220
1300
  );
1221
- if (existing) {
1222
- return {
1223
- userId: existing.id,
1224
- email: normalizeEmail(existing.email ?? requested)
1225
- };
1301
+ const path = resolveNestPath(id);
1302
+ mkdirSync(path, { recursive: true });
1303
+ const storage = new NestStorage(path);
1304
+ await storage.init(trimmed);
1305
+ trackEvent("nest.create", { nestId: id, userId });
1306
+ return await db.get("SELECT * FROM nests WHERE id = ?", [id]);
1307
+ }
1308
+ async function importNest(userId, name, files) {
1309
+ const nm = (name || "").trim();
1310
+ if (!nm) {
1311
+ throw new ValidationError("name is required");
1312
+ }
1313
+ if (!Array.isArray(files)) {
1314
+ throw new ValidationError("files are required");
1315
+ }
1316
+ const db = getDb();
1317
+ const id = uuid2();
1318
+ const slug = toSlug(nm);
1319
+ const visibility = userId === ANON_USER_ID ? "public" : "private";
1320
+ const dest = resolveNestPath(id);
1321
+ mkdirSync(dest, { recursive: true });
1322
+ try {
1323
+ await writeImportedFiles(dest, files);
1324
+ } catch (err) {
1325
+ console.error("[nests] import write failed", dest, err);
1326
+ discardImportDir(dest);
1327
+ throw new ValidationError("Failed to import folder");
1226
1328
  }
1227
- const { hashPassword } = await import("./keys-73STFJJB.js");
1228
- const userId = uuid2();
1229
1329
  await db.run(
1230
- "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
1231
- [userId, requested, null, await hashPassword(uuid2())]
1330
+ "INSERT INTO nests (id, user_id, name, slug, description, visibility, is_imported) VALUES (?, ?, ?, ?, ?, ?, 1)",
1331
+ [id, userId, nm, slug, null, visibility]
1232
1332
  );
1233
- return { userId, email: requested };
1333
+ trackEvent("nest.import", { nestId: id, userId });
1334
+ return await db.get("SELECT * FROM nests WHERE id = ?", [id]);
1234
1335
  }
1235
- async function replaceTeamRoster(params) {
1236
- for (const m of params.members) assertValidRole(m.role);
1237
- const row = await loadTeamRow(params.teamId);
1238
- if (managementRank(row, params.callerUserId, !!params.allowAdmin) < ROLE_RANK.admin) {
1239
- throw new ForbiddenError(
1240
- "Only a team admin or the owner can replace this team's roster."
1336
+ async function listNests(userId) {
1337
+ const db = getDb();
1338
+ if (userId === ANON_USER_ID) {
1339
+ return await db.all(
1340
+ "SELECT * FROM nests WHERE user_id = ? ORDER BY created_at DESC",
1341
+ [ANON_USER_ID]
1241
1342
  );
1242
1343
  }
1243
- await mutateMembers(
1244
- params.teamId,
1245
- () => params.members.map((m) => ({
1246
- userId: m.userId,
1247
- email: m.email,
1248
- role: m.role
1249
- }))
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]
1349
+ );
1350
+ }
1351
+ return await db.all(
1352
+ "SELECT * FROM nests WHERE user_id = ? OR user_id = ? ORDER BY created_at DESC",
1353
+ [userId, ANON_USER_ID]
1250
1354
  );
1251
- return enrichTeam(rowToTeam(await loadTeamRow(params.teamId)));
1252
1355
  }
1253
- async function updateMemberRole(params) {
1254
- assertValidRole(params.role);
1255
- const row = await loadTeamRow(params.teamId);
1256
- const rank = managementRank(row, params.callerUserId, !!params.allowAdmin);
1257
- if (rank < ROLE_RANK.admin) {
1258
- throw new ForbiddenError("Only a team admin or the owner can change roles.");
1259
- }
1260
- if (rank < OWNER_RANK && ROLE_RANK[params.role] > rank) {
1261
- throw new ForbiddenError("You can't set a role higher than your own.");
1356
+ async function nestsSharedViaTeams(userId) {
1357
+ const db = getDb();
1358
+ const teams = await db.all(
1359
+ "SELECT id, members FROM teams WHERE members LIKE ?",
1360
+ [`%${userId}%`]
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
+ }
1262
1371
  }
1263
- await mutateMembers(params.teamId, (members) => {
1264
- const member = members.find((m) => m.userId === params.userId);
1265
- if (!member) throw new NotFoundError("Team member not found");
1266
- member.role = params.role;
1267
- return members;
1372
+ if (myTeamIds.size === 0) return [];
1373
+ const ids = [...myTeamIds];
1374
+ const candidates = await db.all(
1375
+ `SELECT * FROM nests
1376
+ WHERE user_id != ?
1377
+ AND (${ids.map(() => "shared_teams LIKE ?").join(" OR ")})`,
1378
+ [userId, ...ids.map((id) => `%${id}%`)]
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
+ }
1268
1387
  });
1269
- return enrichTeam(rowToTeam(await loadTeamRow(params.teamId)));
1270
1388
  }
1271
- async function removeMember(params) {
1272
- const row = await loadTeamRow(params.teamId);
1273
- if (managementRank(row, params.callerUserId, !!params.allowAdmin) < ROLE_RANK.admin) {
1274
- throw new ForbiddenError("Only a team admin or the owner can remove members.");
1389
+ async function listSharedNests(userId) {
1390
+ const db = getDb();
1391
+ const direct = await db.all(
1392
+ `SELECT DISTINCT n.* FROM nests n
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
+ }
1275
1410
  }
1276
- await mutateMembers(params.teamId, (members) => {
1277
- const next = members.filter((m) => m.userId !== params.userId);
1278
- if (next.length === members.length) {
1279
- throw new NotFoundError("Team member not found");
1411
+ if (await isServerAdminUserId(userId)) {
1412
+ const anon = await db.all(
1413
+ "SELECT * FROM nests WHERE user_id = ? ORDER BY created_at DESC",
1414
+ [ANON_USER_ID]
1415
+ );
1416
+ for (const n of anon) {
1417
+ if (!seen.has(n.id)) {
1418
+ direct.push(n);
1419
+ seen.add(n.id);
1420
+ }
1280
1421
  }
1281
- return next;
1282
- });
1283
- return enrichTeam(rowToTeam(await loadTeamRow(params.teamId)));
1422
+ }
1423
+ direct.sort(
1424
+ (a, b) => a.created_at === b.created_at ? 0 : a.created_at < b.created_at ? 1 : -1
1425
+ );
1426
+ return direct;
1284
1427
  }
1285
- async function mutateMembers(teamId, mutate) {
1428
+ async function listVisibleNests(userId) {
1286
1429
  const db = getDb();
1287
- await db.transaction(async (tx) => {
1288
- const row = await tx.get("SELECT members FROM teams WHERE id = ?", [
1289
- teamId
1290
- ]);
1291
- if (!row) throw new NotFoundError("Team not found");
1292
- const next = mutate(parseMembers(row.members));
1293
- await tx.run("UPDATE teams SET members = ?, updated_at = ? WHERE id = ?", [
1294
- JSON.stringify(next),
1295
- (/* @__PURE__ */ new Date()).toISOString(),
1296
- teamId
1297
- ]);
1298
- });
1299
- }
1300
- function parseSharedTeams(json) {
1301
- if (!json) return [];
1302
- try {
1303
- const parsed = JSON.parse(json);
1304
- return Array.isArray(parsed) ? parsed : [];
1305
- } catch {
1306
- return [];
1430
+ const includeAnon = config.AUTH_MODE === "open" || await isServerAdminUserId(userId);
1431
+ const params = includeAnon ? [userId, userId, userId, ANON_USER_ID] : [userId, userId, userId];
1432
+ const rows = await db.all(
1433
+ `SELECT DISTINCT n.* FROM nests n
1434
+ LEFT JOIN nest_collaborators nc
1435
+ ON nc.nest_id = n.id AND nc.user_id = ?
1436
+ LEFT JOIN users u ON u.id = ?
1437
+ LEFT JOIN stewards s
1438
+ ON s.nest_id = n.id AND s.is_active = 1
1439
+ AND LOWER(s.user_email) = LOWER(u.email)
1440
+ WHERE n.user_id = ?
1441
+ OR nc.user_id IS NOT NULL
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
+ }
1307
1453
  }
1454
+ return rows;
1308
1455
  }
1309
- async function listNestTeams(nestId) {
1456
+ async function listPublicNests(userId) {
1310
1457
  const db = getDb();
1311
- const row = await db.get("SELECT shared_teams FROM nests WHERE id = ?", [
1312
- nestId
1313
- ]);
1314
- if (!row) throw new NotFoundError("Nest not found");
1315
- return parseSharedTeams(row.shared_teams);
1458
+ return await db.all(
1459
+ `SELECT n.* FROM nests n
1460
+ WHERE n.visibility = 'public'
1461
+ AND n.user_id != ?
1462
+ AND NOT EXISTS (
1463
+ SELECT 1 FROM nest_collaborators nc
1464
+ WHERE nc.nest_id = n.id AND nc.user_id = ?
1465
+ )
1466
+ ORDER BY n.created_at DESC`,
1467
+ [userId, userId]
1468
+ );
1316
1469
  }
1317
- async function shareTeamWithNest(params) {
1470
+ async function getNest(nestId) {
1318
1471
  const db = getDb();
1319
- return db.transaction(async (tx) => {
1320
- const team = await tx.get(
1321
- "SELECT id, name, owner_id, members FROM teams WHERE id = ?",
1322
- [params.teamId]
1323
- );
1324
- if (!team) throw new NotFoundError("Team not found");
1325
- if (managementRank(team, params.callerUserId, !!params.allowAdmin) < ROLE_RANK.editor) {
1326
- throw new ForbiddenError(
1327
- "You need to be an editor, admin, or owner of a team to share it. View-only members can't."
1328
- );
1329
- }
1330
- const nest = await tx.get("SELECT shared_teams FROM nests WHERE id = ?", [
1331
- params.nestId
1332
- ]);
1333
- if (!nest) throw new NotFoundError("Nest not found");
1334
- const refs = parseSharedTeams(nest.shared_teams);
1335
- if (refs.some((r) => r.teamId === params.teamId)) {
1336
- throw new ConflictError("This team is already shared with the nest.");
1337
- }
1338
- refs.push({ teamId: team.id, teamName: team.name });
1339
- await tx.run("UPDATE nests SET shared_teams = ? WHERE id = ?", [
1340
- JSON.stringify(refs),
1341
- params.nestId
1342
- ]);
1343
- const hasGovernanceRole = parseMembers(team.members).some(
1344
- (m) => m.role === "viewer" || m.role === "editor"
1345
- );
1346
- if (hasGovernanceRole) {
1347
- await tx.run("UPDATE nests SET stewardship_enabled = 1 WHERE id = ?", [
1348
- params.nestId
1349
- ]);
1472
+ return await db.get("SELECT * FROM nests WHERE id = ?", [nestId]) || null;
1473
+ }
1474
+ async function deleteNest(nestId) {
1475
+ const db = getDb();
1476
+ await db.transaction(async (tx) => {
1477
+ const id = nestId;
1478
+ await tx.run("DELETE FROM approved_versions WHERE nest_id = ?", [id]);
1479
+ await tx.run("DELETE FROM node_versions WHERE nest_id = ?", [id]);
1480
+ await tx.run("DELETE FROM review_requests WHERE nest_id = ?", [id]);
1481
+ await tx.run("DELETE FROM stewards WHERE nest_id = ?", [id]);
1482
+ await tx.run("DELETE FROM nest_collaborators WHERE nest_id = ?", [id]);
1483
+ try {
1484
+ await tx.run("DELETE FROM node_tag_index WHERE nest_id = ?", [id]);
1485
+ } catch {
1350
1486
  }
1351
- return refs;
1487
+ await tx.run("DELETE FROM api_keys WHERE nest_id = ?", [id]);
1488
+ await tx.run("DELETE FROM nests WHERE id = ?", [id]);
1352
1489
  });
1490
+ const path = resolveNestPath(nestId);
1491
+ try {
1492
+ rmSync2(path, { recursive: true, force: true });
1493
+ } catch (err) {
1494
+ console.warn(`[nests] failed to remove nest directory ${path}:`, err);
1495
+ }
1496
+ trackEvent("nest.delete", { nestId });
1353
1497
  }
1354
- async function unshareTeamFromNest(params) {
1355
- const db = getDb();
1356
- return db.transaction(async (tx) => {
1357
- const nest = await tx.get("SELECT shared_teams FROM nests WHERE id = ?", [
1358
- params.nestId
1359
- ]);
1360
- if (!nest) throw new NotFoundError("Nest not found");
1361
- const refs = parseSharedTeams(nest.shared_teams);
1362
- const next = refs.filter((r) => r.teamId !== params.teamId);
1363
- if (next.length === refs.length) {
1364
- throw new NotFoundError("Team is not shared with this nest.");
1498
+
1499
+ // src/nodes/flat-storage.ts
1500
+ import { readdirSync } from "fs";
1501
+ import { join as join4, relative, sep } from "path";
1502
+ import { NestStorage as NestStorage2 } from "@promptowl/contextnest-engine";
1503
+ var META_FILES = /* @__PURE__ */ new Set([
1504
+ "INDEX.md",
1505
+ "CONTEXT.md",
1506
+ "CLAUDE.md",
1507
+ "GEMINI.md",
1508
+ "AGENTS.md",
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 [];
1365
1520
  }
1366
- await tx.run("UPDATE nests SET shared_teams = ? WHERE id = ?", [
1367
- JSON.stringify(next),
1368
- params.nestId
1369
- ]);
1370
- return next;
1371
- });
1372
- }
1373
- async function refreshTeamNameRefs(tx, teamId, newName) {
1374
- const nests = await tx.all(
1375
- "SELECT id, shared_teams FROM nests WHERE shared_teams LIKE ?",
1376
- [`%${teamId}%`]
1377
- );
1378
- for (const n of nests) {
1379
- const refs = parseSharedTeams(n.shared_teams);
1380
- let changed = false;
1381
- for (const r of refs) {
1382
- if (r.teamId === teamId && r.teamName !== newName) {
1383
- r.teamName = newName;
1384
- changed = true;
1385
- }
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$/, ""));
1386
1529
  }
1387
- if (changed) {
1388
- await tx.run("UPDATE nests SET shared_teams = ? WHERE id = ?", [
1389
- JSON.stringify(refs),
1390
- n.id
1391
- ]);
1530
+ const nodes = [];
1531
+ for (const id of ids.sort()) {
1532
+ try {
1533
+ nodes.push(await this.readDocument(id));
1534
+ } catch (err) {
1535
+ console.error("[FlatNestStorage] skipped unreadable doc", id, err);
1536
+ }
1392
1537
  }
1538
+ return nodes;
1393
1539
  }
1394
- }
1395
- async function removeTeamRefs(tx, teamId) {
1396
- const nests = await tx.all(
1397
- "SELECT id, shared_teams FROM nests WHERE shared_teams LIKE ?",
1398
- [`%${teamId}%`]
1399
- );
1400
- for (const n of nests) {
1401
- const refs = parseSharedTeams(n.shared_teams);
1402
- const next = refs.filter((r) => r.teamId !== teamId);
1403
- if (next.length !== refs.length) {
1404
- await tx.run("UPDATE nests SET shared_teams = ? WHERE id = ?", [
1405
- JSON.stringify(next),
1406
- n.id
1407
- ]);
1540
+ };
1541
+
1542
+ // src/nodes/engine.ts
1543
+ var NestEngineCache = class {
1544
+ cache = /* @__PURE__ */ new Map();
1545
+ async get(nestId) {
1546
+ let engine = this.cache.get(nestId);
1547
+ if (!engine) {
1548
+ const nestPath = resolveNestPath(nestId);
1549
+ const storage = await isImportedNest(nestId) ? new FlatNestStorage(nestPath) : new NestStorage3(nestPath);
1550
+ const query = new GraphQueryEngine(storage);
1551
+ const versions = new VersionManager(storage);
1552
+ engine = { storage, query, versions };
1553
+ this.cache.set(nestId, engine);
1408
1554
  }
1555
+ return engine;
1409
1556
  }
1410
- }
1411
- async function resolveTeamRolesForUser(nestId, userId) {
1412
- const refs = await listNestTeams(nestId).catch(() => []);
1413
- if (refs.length === 0) return [];
1414
- const db = getDb();
1415
- const roles = /* @__PURE__ */ new Set();
1416
- for (const ref of refs) {
1417
- const row = await db.get("SELECT members FROM teams WHERE id = ?", [
1418
- ref.teamId
1419
- ]);
1420
- if (!row) continue;
1421
- const member = parseMembers(row.members).find((m) => m.userId === userId);
1422
- if (member) roles.add(member.role);
1557
+ evict(nestId) {
1558
+ this.cache.delete(nestId);
1423
1559
  }
1424
- return [...roles];
1425
- }
1560
+ };
1561
+ var engineCache = new NestEngineCache();
1426
1562
 
1427
- // src/governance/roles.ts
1428
- function collabPermToRole(permission) {
1429
- switch (permission) {
1430
- case "owner":
1431
- return "owner";
1432
- case "admin":
1433
- return "admin";
1434
- case "write":
1435
- return "editor";
1436
- case "read":
1437
- return "viewer";
1438
- default:
1439
- return null;
1440
- }
1441
- }
1442
- function teamRoleToPermission(role) {
1443
- switch (role) {
1444
- case "admin":
1445
- return "admin";
1446
- case "editor":
1447
- return "write";
1448
- case "viewer":
1449
- return "read";
1450
- default:
1451
- return null;
1563
+ // src/governance/title-resolver.ts
1564
+ async function buildTitleMap(nestId) {
1565
+ try {
1566
+ const { storage } = await engineCache.get(nestId);
1567
+ const docs = await storage.discoverDocuments();
1568
+ return new Map(docs.map((d) => [d.id, d.frontmatter.title]));
1569
+ } catch {
1570
+ return /* @__PURE__ */ new Map();
1452
1571
  }
1453
1572
  }
1454
- var includesAny = (roles, wanted) => roles.some((r) => wanted.includes(r));
1455
- var canViewWith = (roles) => roles.length > 0;
1456
- var canEditWith = (roles) => includesAny(roles, ["owner", "admin", "editor"]);
1457
- var PRECEDENCE = [
1458
- "owner",
1459
- "admin",
1460
- "editor",
1461
- "reviewer",
1462
- "viewer"
1463
- ];
1464
- function primaryRole(roles) {
1465
- for (const r of PRECEDENCE) if (roles.includes(r)) return r;
1466
- return null;
1573
+ function folderForNode(nodeId) {
1574
+ return nodeId.replace(/^nodes\//, "").split("/").slice(0, -1).join("/");
1467
1575
  }
1468
-
1469
- // src/shared/access.ts
1470
- async function isServerAdminUserId(userId) {
1471
- if (await isLicenseAdminUserId(userId)) return true;
1472
- const row = await getDb().get(
1473
- "SELECT email FROM users WHERE id = ?",
1474
- [userId]
1475
- );
1476
- return !!row?.email && isSuperAdmin(row.email);
1576
+ async function describeNode(nestId, nodeId) {
1577
+ const titles = await buildTitleMap(nestId);
1578
+ const title = titles.get(nodeId) || nodeId;
1579
+ const folder = folderForNode(nodeId);
1580
+ return folder ? `${title} (in ${folder})` : title;
1477
1581
  }
1478
- var PERMISSION_LEVELS = {
1479
- none: 0,
1480
- read: 1,
1481
- write: 2,
1482
- admin: 3,
1483
- owner: 4
1484
- };
1485
- async function resolveNestPermission(nestId, userId) {
1486
- const db = getDb();
1487
- const nest = await db.get("SELECT user_id, visibility FROM nests WHERE id = ?", [nestId]);
1488
- if (!nest) return "none";
1489
- if (nest.user_id === userId) return "owner";
1490
- if (nest.user_id === ANON_USER_ID && await isLicenseAdminUserId(userId)) {
1491
- return "owner";
1492
- }
1493
- if (await isLicenseAdminUserId(userId)) return "admin";
1494
- const caller = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
1495
- if (caller?.email && isSuperAdmin(caller.email)) {
1496
- return "admin";
1497
- }
1498
- const higher = (a, b) => b && PERMISSION_LEVELS[b] > PERMISSION_LEVELS[a] ? b : a;
1499
- let best = "none";
1500
- const directGrant = await db.get(
1501
- "SELECT permission FROM nest_collaborators WHERE nest_id = ? AND user_id = ?",
1502
- [nestId, userId]
1503
- );
1504
- if (directGrant) best = higher(best, directGrant.permission);
1505
- if (best === "admin") return best;
1506
- for (const role of await resolveTeamRolesForUser(nestId, userId)) {
1507
- best = higher(best, teamRoleToPermission(role));
1582
+ async function nestName(nestId) {
1583
+ try {
1584
+ const row = await getDb().get("SELECT name FROM nests WHERE id = ?", [
1585
+ nestId
1586
+ ]);
1587
+ return row?.name || nestId;
1588
+ } catch {
1589
+ return nestId;
1508
1590
  }
1509
- if (best === "admin") return best;
1510
- const stewardGrant = await db.get(
1511
- `SELECT 1 FROM stewards s
1512
- JOIN users u ON u.id = ?
1513
- WHERE s.nest_id = ? AND s.is_active = 1
1514
- AND LOWER(s.user_email) = LOWER(u.email)
1515
- LIMIT 1`,
1516
- [userId, nestId]
1517
- );
1518
- if (stewardGrant) best = higher(best, "read");
1519
- if (nest.visibility === "public") best = higher(best, "read");
1520
- return best;
1521
1591
  }
1522
- function permissionLevel(p) {
1523
- return PERMISSION_LEVELS[p] ?? 0;
1592
+ function docLink(nestId, nodeId, baseUrl) {
1593
+ const base = baseUrl || config.PUBLIC_BASE_URL;
1594
+ if (!base) return "";
1595
+ const q = new URLSearchParams({ nest: nestId });
1596
+ if (nodeId) q.set("doc", nodeId);
1597
+ return `${base.replace(/\/$/, "")}/?${q.toString()}`;
1524
1598
  }
1525
- async function isPublicReader(nestId, userId) {
1526
- const db = getDb();
1527
- const nest = await db.get("SELECT user_id, visibility FROM nests WHERE id = ?", [nestId]);
1528
- if (!nest) return false;
1529
- if (nest.visibility !== "public") return false;
1530
- if (nest.user_id === userId) return false;
1531
- const directGrant = await db.get(
1532
- "SELECT 1 FROM nest_collaborators WHERE nest_id = ? AND user_id = ?",
1533
- [nestId, userId]
1534
- );
1535
- if (directGrant) return false;
1536
- if (await isLicenseAdminUserId(userId)) return false;
1537
- const caller = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
1538
- if (caller?.email && isSuperAdmin(caller.email)) return false;
1539
- const stewardGrant = await db.get(
1540
- `SELECT 1 FROM stewards s
1541
- JOIN users u ON u.id = ?
1542
- WHERE s.nest_id = ? AND s.is_active = 1
1543
- AND LOWER(s.user_email) = LOWER(u.email)
1544
- LIMIT 1`,
1545
- [userId, nestId]
1546
- );
1547
- if (stewardGrant) return false;
1548
- if ((await resolveTeamRolesForUser(nestId, userId)).length > 0) return false;
1549
- return true;
1599
+ async function buildDocContext(nestId, nodeId, baseUrl) {
1600
+ const titles = await buildTitleMap(nestId);
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
+ };
1550
1611
  }
1551
1612
 
1552
1613
  // src/governance/stewardship-service.ts
@@ -1717,6 +1778,9 @@ async function createStewardRecord(params) {
1717
1778
  for (const user of params.users) {
1718
1779
  const email = user.email.trim().toLowerCase();
1719
1780
  if (!email) continue;
1781
+ if (!isEmailish(email)) {
1782
+ throw new ValidationError(`"${user.email}" is not a valid email address.`);
1783
+ }
1720
1784
  if (email === actor) {
1721
1785
  throw new ValidationError(
1722
1786
  "You already manage this nest, so you can't add yourself as a steward."
@@ -1872,6 +1936,20 @@ async function getCollaboratorRole(nestId, userEmail) {
1872
1936
  );
1873
1937
  return row?.permission ?? null;
1874
1938
  }
1939
+ async function resolveGrantRoles(nestId, userEmail, userId) {
1940
+ const [grantRaw, stewardRoles, teamRoles] = await Promise.all([
1941
+ getCollaboratorRole(nestId, userEmail),
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
+ }
1875
1953
  async function resolveUserRoles(nestId, userEmail, opts) {
1876
1954
  const roles = /* @__PURE__ */ new Set();
1877
1955
  if (isSuperAdmin(userEmail)) roles.add("admin");
@@ -2113,8 +2191,6 @@ export {
2113
2191
  listNestTeams,
2114
2192
  shareTeamWithNest,
2115
2193
  unshareTeamFromNest,
2116
- resolveTeamRolesForUser,
2117
- collabPermToRole,
2118
2194
  isServerAdminUserId,
2119
2195
  resolveNestPermission,
2120
2196
  permissionLevel,
@@ -2132,6 +2208,7 @@ export {
2132
2208
  importNest,
2133
2209
  listNests,
2134
2210
  listSharedNests,
2211
+ listVisibleNests,
2135
2212
  listPublicNests,
2136
2213
  getNest,
2137
2214
  deleteNest,
@@ -2155,6 +2232,7 @@ export {
2155
2232
  getStewardRolesForUser,
2156
2233
  getStewardsForUser,
2157
2234
  getCollaboratorRole,
2235
+ resolveGrantRoles,
2158
2236
  resolveUserRoles,
2159
2237
  canManageStewards,
2160
2238
  canCreateInNest,