@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.
- package/CONFIGURATION.md +2 -1
- package/README.md +1 -1
- package/dist/{chunk-3M7677XW.js → chunk-5QQ7WKAI.js} +14 -1
- package/dist/{chunk-JHIU6RZU.js → chunk-EG77SQHO.js} +34 -1
- package/dist/{chunk-XB7T3THE.js → chunk-PAIP5KHB.js} +17 -8
- package/dist/{chunk-F7EKYNXG.js → chunk-Q5NRCDD4.js} +884 -806
- package/dist/{chunk-ZMSU7BCC.js → chunk-ZQS5W45U.js} +1 -1
- package/dist/{client-YUOEVUFU.js → client-AAMF22YJ.js} +1 -1
- package/dist/{grants-service-PS5RMMEA.js → grants-service-FZD6QM6V.js} +2 -2
- package/dist/index.js +134 -60
- package/dist/{review-service-DPN26C47.js → review-service-Q3NDK5TX.js} +5 -5
- package/dist/{stewardship-service-7BHI2YXB.js → stewardship-service-B6L7UACX.js} +4 -2
- package/dist/{version-service-YQIIQ7FN.js → version-service-L52F4WVZ.js} +6 -4
- package/dist/web3/assets/{index-BiGiarBx.css → index-DcmDj50q.css} +1 -1
- package/dist/web3/assets/index-EGPZ6MAy.js +1023 -0
- package/dist/web3/index.html +2 -2
- package/package.json +1 -1
- package/dist/web3/assets/index-CUVRdYvH.js +0 -1026
|
@@ -13,8 +13,9 @@ import {
|
|
|
13
13
|
} from "./chunk-XQ46F76G.js";
|
|
14
14
|
import {
|
|
15
15
|
config,
|
|
16
|
-
getDb
|
|
17
|
-
|
|
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
|
|
142
|
-
import { v4 as
|
|
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 {
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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/
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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
|
|
639
|
-
return
|
|
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
|
|
666
|
+
async function enrichTeam(team) {
|
|
642
667
|
const db = getDb();
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
|
|
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
|
-
|
|
652
|
-
n++;
|
|
653
|
-
candidate = `${baseName} (${n})`;
|
|
675
|
+
for (const r of rows) invited.set(r.id, r.is_invited);
|
|
654
676
|
}
|
|
655
|
-
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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
|
|
697
|
+
async function loadTeamRow(teamId) {
|
|
666
698
|
const db = getDb();
|
|
667
|
-
await db.
|
|
668
|
-
|
|
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
|
-
|
|
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
|
|
676
|
-
[
|
|
740
|
+
"SELECT * FROM teams WHERE owner_id = ? AND source = ? AND external_id = ?",
|
|
741
|
+
[params.ownerId, params.source, params.externalId]
|
|
677
742
|
);
|
|
678
|
-
return
|
|
743
|
+
return row ? enrichTeam(rowToTeam(row)) : null;
|
|
679
744
|
}
|
|
680
|
-
async function
|
|
745
|
+
async function listTeamsForUser(userId) {
|
|
681
746
|
const db = getDb();
|
|
682
|
-
await db.
|
|
683
|
-
|
|
684
|
-
|
|
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
|
|
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
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
)
|
|
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
|
|
701
|
-
const
|
|
702
|
-
|
|
703
|
-
|
|
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
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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
|
-
|
|
725
|
-
|
|
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
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
-
|
|
732
|
-
|
|
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
|
-
|
|
840
|
+
const { hashPassword } = await import("./keys-73STFJJB.js");
|
|
841
|
+
const userId = uuid();
|
|
735
842
|
await db.run(
|
|
736
|
-
|
|
737
|
-
|
|
843
|
+
"INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
|
|
844
|
+
[userId, requested, null, await hashPassword(uuid())]
|
|
738
845
|
);
|
|
739
|
-
return
|
|
846
|
+
return { userId, email: requested };
|
|
740
847
|
}
|
|
741
|
-
async function
|
|
742
|
-
const
|
|
743
|
-
const
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
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
|
-
|
|
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
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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 (
|
|
763
|
-
throw new
|
|
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
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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
|
|
922
|
+
async function listNestTeams(nestId) {
|
|
786
923
|
const db = getDb();
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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
|
-
|
|
801
|
-
|
|
802
|
-
|
|
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
|
|
967
|
+
async function unshareTeamFromNest(params) {
|
|
806
968
|
const db = getDb();
|
|
807
|
-
|
|
808
|
-
"SELECT
|
|
809
|
-
|
|
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
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
if (
|
|
816
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
822
|
-
|
|
823
|
-
const
|
|
824
|
-
|
|
825
|
-
|
|
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
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
|
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
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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
|
-
|
|
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/
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
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
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
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
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
const
|
|
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/
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
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
|
|
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
|
|
1138
|
+
async function isPublicReader(nestId, userId) {
|
|
1057
1139
|
const db = getDb();
|
|
1058
|
-
const
|
|
1059
|
-
|
|
1060
|
-
if (
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
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
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
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
|
|
1192
|
+
async function isStewardshipEnabled(nestId) {
|
|
1088
1193
|
const db = getDb();
|
|
1089
|
-
const row = await db.get(
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
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
|
|
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
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
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
|
|
1207
|
+
async function nestAllowsSelfApprove(nestId) {
|
|
1128
1208
|
const db = getDb();
|
|
1129
1209
|
const row = await db.get(
|
|
1130
|
-
"SELECT
|
|
1131
|
-
[
|
|
1210
|
+
"SELECT allow_self_approve FROM nests WHERE id = ?",
|
|
1211
|
+
[nestId]
|
|
1132
1212
|
);
|
|
1133
|
-
return row
|
|
1213
|
+
return !!row?.allow_self_approve;
|
|
1134
1214
|
}
|
|
1135
|
-
async function
|
|
1215
|
+
async function setAllowSelfApprove(nestId, allow) {
|
|
1136
1216
|
const db = getDb();
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
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
|
|
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
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
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
|
|
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.
|
|
1174
|
-
|
|
1175
|
-
|
|
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
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
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
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
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
|
|
1276
|
+
async function createNest(userId, name, description) {
|
|
1277
|
+
const id = uuid2();
|
|
1205
1278
|
const db = getDb();
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
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
|
|
1214
|
-
|
|
1215
|
-
|
|
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
|
|
1218
|
-
|
|
1219
|
-
|
|
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
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
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
|
|
1231
|
-
[userId,
|
|
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
|
-
|
|
1333
|
+
trackEvent("nest.import", { nestId: id, userId });
|
|
1334
|
+
return await db.get("SELECT * FROM nests WHERE id = ?", [id]);
|
|
1234
1335
|
}
|
|
1235
|
-
async function
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
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
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
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
|
|
1254
|
-
|
|
1255
|
-
const
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
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
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
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
|
|
1272
|
-
const
|
|
1273
|
-
|
|
1274
|
-
|
|
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
|
-
|
|
1277
|
-
const
|
|
1278
|
-
|
|
1279
|
-
|
|
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
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
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
|
|
1428
|
+
async function listVisibleNests(userId) {
|
|
1286
1429
|
const db = getDb();
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
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
|
|
1456
|
+
async function listPublicNests(userId) {
|
|
1310
1457
|
const db = getDb();
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
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
|
|
1470
|
+
async function getNest(nestId) {
|
|
1318
1471
|
const db = getDb();
|
|
1319
|
-
return db.
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
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
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
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
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
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
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
)
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1425
|
-
|
|
1560
|
+
};
|
|
1561
|
+
var engineCache = new NestEngineCache();
|
|
1426
1562
|
|
|
1427
|
-
// src/governance/
|
|
1428
|
-
function
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
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
|
-
|
|
1455
|
-
|
|
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
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
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
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
}
|
|
1485
|
-
|
|
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
|
|
1523
|
-
|
|
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
|
|
1526
|
-
const
|
|
1527
|
-
const
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
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,
|