@promptowl/contextnest-community 1.0.1 → 1.2.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 +7 -4
- package/README.md +97 -7
- package/dist/chunk-5MT4ZBVF.js +1413 -0
- package/dist/{chunk-5VHKEIAW.js → chunk-E7E3JMQR.js} +18 -36
- package/dist/{chunk-KQCWNHDM.js → chunk-G62P54ET.js} +39 -0
- package/dist/{chunk-JMZ75ZCD.js → chunk-LO54V4AU.js} +1 -1
- package/dist/{chunk-7K2LLJXK.js → chunk-XRK6SQSC.js} +1 -1
- package/dist/index.js +1256 -1157
- package/dist/{keys-YV33AJK3.js → keys-73STFJJB.js} +1 -1
- package/dist/{review-service-4WS3XL6K.js → review-service-GYX3AW6E.js} +4 -4
- package/dist/{stewardship-service-C5D2O7ZE.js → stewardship-service-VOD5HY3I.js} +20 -4
- package/dist/{version-service-TFEYNPH7.js → version-service-OCZUV2QP.js} +2 -2
- package/dist/web3/assets/index-72vKyivD.js +756 -0
- package/dist/web3/assets/index-JmSevkg_.css +1 -0
- package/dist/web3/index.html +2 -2
- package/package.json +148 -134
- package/dist/chunk-K22GWPT4.js +0 -498
- package/dist/web3/assets/index-DkLevP7k.js +0 -624
- package/dist/web3/assets/index-DpoBdKrd.css +0 -1
|
@@ -0,0 +1,1413 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ANON_USER_ID,
|
|
3
|
+
config,
|
|
4
|
+
getDb
|
|
5
|
+
} from "./chunk-G62P54ET.js";
|
|
6
|
+
|
|
7
|
+
// src/governance/stewardship-service.ts
|
|
8
|
+
import { v4 as uuid2 } from "uuid";
|
|
9
|
+
|
|
10
|
+
// src/governance/access-service.ts
|
|
11
|
+
import { readFileSync, existsSync } from "fs";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
var accessConfig = null;
|
|
14
|
+
function loadAccessConfig() {
|
|
15
|
+
const candidates = [
|
|
16
|
+
join(config.DATA_ROOT, "access.yaml"),
|
|
17
|
+
join(config.DATA_ROOT, "access.yml")
|
|
18
|
+
];
|
|
19
|
+
for (const path of candidates) {
|
|
20
|
+
if (existsSync(path)) {
|
|
21
|
+
const content = readFileSync(path, "utf-8");
|
|
22
|
+
accessConfig = parseAccessYaml(content);
|
|
23
|
+
return accessConfig;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
accessConfig = null;
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
function getAccessConfig() {
|
|
30
|
+
return accessConfig;
|
|
31
|
+
}
|
|
32
|
+
function isSuperAdmin(email) {
|
|
33
|
+
if (!accessConfig?.super_admins) return false;
|
|
34
|
+
return accessConfig.super_admins.map((e) => e.toLowerCase()).includes(email.toLowerCase());
|
|
35
|
+
}
|
|
36
|
+
function parseAccessYaml(content) {
|
|
37
|
+
const result = {};
|
|
38
|
+
const lines = content.split("\n");
|
|
39
|
+
let currentSection = null;
|
|
40
|
+
let currentGroup = null;
|
|
41
|
+
let inMembers = false;
|
|
42
|
+
for (const rawLine of lines) {
|
|
43
|
+
const line = rawLine.trimEnd();
|
|
44
|
+
if (!line || line.startsWith("#")) continue;
|
|
45
|
+
if (!line.startsWith(" ") && !line.startsWith(" ")) {
|
|
46
|
+
const match = line.match(/^(\w+):\s*(.*)?$/);
|
|
47
|
+
if (!match) continue;
|
|
48
|
+
const key = match[1];
|
|
49
|
+
const value = match[2]?.trim();
|
|
50
|
+
if (key === "mode") {
|
|
51
|
+
result.mode = value;
|
|
52
|
+
currentSection = null;
|
|
53
|
+
} else if (key === "allowed_users") {
|
|
54
|
+
currentSection = "allowed_users";
|
|
55
|
+
result.allowed_users = [];
|
|
56
|
+
} else if (key === "groups") {
|
|
57
|
+
currentSection = "groups";
|
|
58
|
+
result.groups = {};
|
|
59
|
+
} else if (key === "super_admins") {
|
|
60
|
+
currentSection = "super_admins";
|
|
61
|
+
result.super_admins = [];
|
|
62
|
+
}
|
|
63
|
+
currentGroup = null;
|
|
64
|
+
inMembers = false;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const listMatch = line.match(/^\s+-\s+["']?([^"'\n]+?)["']?\s*$/);
|
|
68
|
+
if (currentSection === "allowed_users" && listMatch) {
|
|
69
|
+
result.allowed_users.push(listMatch[1].trim());
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (currentSection === "super_admins" && listMatch) {
|
|
73
|
+
result.super_admins.push(listMatch[1].trim());
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (currentSection === "groups") {
|
|
77
|
+
const groupMatch = line.match(/^ (\w+):$/);
|
|
78
|
+
if (groupMatch) {
|
|
79
|
+
currentGroup = groupMatch[1];
|
|
80
|
+
result.groups[currentGroup] = { members: [], default_permission: "read" };
|
|
81
|
+
inMembers = false;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (currentGroup) {
|
|
85
|
+
const propMatch = line.match(/^\s{4}(\w+):\s*(.*)?$/);
|
|
86
|
+
if (propMatch) {
|
|
87
|
+
const prop = propMatch[1];
|
|
88
|
+
const val = propMatch[2]?.trim();
|
|
89
|
+
if (prop === "default_permission" && val) {
|
|
90
|
+
result.groups[currentGroup].default_permission = val;
|
|
91
|
+
}
|
|
92
|
+
if (prop === "members") {
|
|
93
|
+
inMembers = true;
|
|
94
|
+
}
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (inMembers && listMatch) {
|
|
98
|
+
result.groups[currentGroup].members.push(listMatch[1].trim());
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// src/shared/errors.ts
|
|
107
|
+
var AppError = class extends Error {
|
|
108
|
+
constructor(statusCode, message) {
|
|
109
|
+
super(message);
|
|
110
|
+
this.statusCode = statusCode;
|
|
111
|
+
this.name = "AppError";
|
|
112
|
+
}
|
|
113
|
+
statusCode;
|
|
114
|
+
};
|
|
115
|
+
var NotFoundError = class extends AppError {
|
|
116
|
+
constructor(message = "Not found") {
|
|
117
|
+
super(404, message);
|
|
118
|
+
this.name = "NotFoundError";
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
var ForbiddenError = class extends AppError {
|
|
122
|
+
constructor(message = "Forbidden") {
|
|
123
|
+
super(403, message);
|
|
124
|
+
this.name = "ForbiddenError";
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
var ValidationError = class extends AppError {
|
|
128
|
+
constructor(message) {
|
|
129
|
+
super(400, message);
|
|
130
|
+
this.name = "ValidationError";
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
var ConflictError = class extends AppError {
|
|
134
|
+
constructor(message) {
|
|
135
|
+
super(409, message);
|
|
136
|
+
this.name = "ConflictError";
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
var LockedError = class extends AppError {
|
|
140
|
+
constructor(message = "Locked") {
|
|
141
|
+
super(423, message);
|
|
142
|
+
this.name = "LockedError";
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// src/nodes/engine.ts
|
|
147
|
+
import {
|
|
148
|
+
NestStorage as NestStorage3,
|
|
149
|
+
GraphQueryEngine,
|
|
150
|
+
VersionManager
|
|
151
|
+
} from "@promptowl/contextnest-engine";
|
|
152
|
+
|
|
153
|
+
// src/nests/service.ts
|
|
154
|
+
import { join as join3 } from "path";
|
|
155
|
+
import { rmSync as rmSync2, mkdirSync as mkdirSync2 } from "fs";
|
|
156
|
+
import { v4 as uuid } from "uuid";
|
|
157
|
+
import { NestStorage } from "@promptowl/contextnest-engine";
|
|
158
|
+
|
|
159
|
+
// src/fs/vault-import.ts
|
|
160
|
+
import { mkdirSync, writeFileSync, rmSync } from "fs";
|
|
161
|
+
import { join as join2, dirname, isAbsolute } from "path";
|
|
162
|
+
function isSkippedSegment(segment) {
|
|
163
|
+
return segment.startsWith(".") || segment === "node_modules";
|
|
164
|
+
}
|
|
165
|
+
function safeSegments(rel) {
|
|
166
|
+
if (!rel || isAbsolute(rel)) return null;
|
|
167
|
+
const segs = rel.split(/[\\/]/).filter(Boolean);
|
|
168
|
+
if (!segs.length) return null;
|
|
169
|
+
for (const s of segs) {
|
|
170
|
+
if (s === "..") return null;
|
|
171
|
+
if (s !== ".versions" && isSkippedSegment(s)) return null;
|
|
172
|
+
}
|
|
173
|
+
return segs;
|
|
174
|
+
}
|
|
175
|
+
function writeImportedFiles(dest, files) {
|
|
176
|
+
let written = 0;
|
|
177
|
+
for (const f of files) {
|
|
178
|
+
const segs = safeSegments(f?.path || "");
|
|
179
|
+
if (!segs) continue;
|
|
180
|
+
const full = join2(dest, ...segs);
|
|
181
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
182
|
+
writeFileSync(full, f.content ?? "", "utf-8");
|
|
183
|
+
written++;
|
|
184
|
+
}
|
|
185
|
+
return written;
|
|
186
|
+
}
|
|
187
|
+
function discardImportDir(dest) {
|
|
188
|
+
try {
|
|
189
|
+
rmSync(dest, { recursive: true, force: true });
|
|
190
|
+
} catch {
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// src/telemetry/tracker.ts
|
|
195
|
+
function trackEvent(event, data) {
|
|
196
|
+
if (!config.TELEMETRY_ENABLED) return;
|
|
197
|
+
try {
|
|
198
|
+
const db = getDb();
|
|
199
|
+
db.prepare(
|
|
200
|
+
"INSERT INTO telemetry_events (event, data_json) VALUES (?, ?)"
|
|
201
|
+
).run(event, data ? JSON.stringify(data) : null);
|
|
202
|
+
} catch {
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
async function flushTelemetry() {
|
|
206
|
+
if (!config.TELEMETRY_ENABLED || !config.PROMPTOWL_KEY) return;
|
|
207
|
+
const db = getDb();
|
|
208
|
+
const userCount = db.prepare("SELECT COUNT(*) as c FROM users").get()?.c || 0;
|
|
209
|
+
const nestCount = db.prepare("SELECT COUNT(*) as c FROM nests").get()?.c || 0;
|
|
210
|
+
const events = db.prepare(
|
|
211
|
+
"SELECT id, event, data_json, created_at FROM telemetry_events WHERE sent = 0 ORDER BY id LIMIT 100"
|
|
212
|
+
).all();
|
|
213
|
+
if (events.length === 0 && userCount === 0) return;
|
|
214
|
+
const payload = {
|
|
215
|
+
server_key: config.PROMPTOWL_KEY,
|
|
216
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
217
|
+
stats: { users: userCount, nests: nestCount },
|
|
218
|
+
events: events.map((e) => ({
|
|
219
|
+
event: e.event,
|
|
220
|
+
data: e.data_json ? JSON.parse(e.data_json) : null,
|
|
221
|
+
at: e.created_at
|
|
222
|
+
}))
|
|
223
|
+
};
|
|
224
|
+
try {
|
|
225
|
+
const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
|
|
226
|
+
const res = await fetch(`${promptowlUrl}/api/telemetry/ingest`, {
|
|
227
|
+
method: "POST",
|
|
228
|
+
headers: { "Content-Type": "application/json" },
|
|
229
|
+
body: JSON.stringify(payload)
|
|
230
|
+
});
|
|
231
|
+
if (res.ok && events.length > 0) {
|
|
232
|
+
const ids = events.map((e) => e.id);
|
|
233
|
+
db.prepare(
|
|
234
|
+
`UPDATE telemetry_events SET sent = 1 WHERE id IN (${ids.map(() => "?").join(",")})`
|
|
235
|
+
).run(...ids);
|
|
236
|
+
}
|
|
237
|
+
} catch {
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
var telemetryTimer = null;
|
|
241
|
+
function startTelemetryLoop() {
|
|
242
|
+
if (!config.TELEMETRY_ENABLED) return;
|
|
243
|
+
setTimeout(() => flushTelemetry(), 3e4);
|
|
244
|
+
telemetryTimer = setInterval(
|
|
245
|
+
() => flushTelemetry(),
|
|
246
|
+
config.TELEMETRY_INTERVAL_MS
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// src/auth/license.ts
|
|
251
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
252
|
+
var currentLicense = null;
|
|
253
|
+
function getCurrentLicense() {
|
|
254
|
+
return currentLicense;
|
|
255
|
+
}
|
|
256
|
+
function isLicenseAdminEmail(email) {
|
|
257
|
+
if (!email) return false;
|
|
258
|
+
const lic = currentLicense;
|
|
259
|
+
if (!lic?.valid || !lic.ownerEmail) return false;
|
|
260
|
+
return lic.ownerEmail.toLowerCase() === email.toLowerCase();
|
|
261
|
+
}
|
|
262
|
+
function isLicenseAdminUserId(userId) {
|
|
263
|
+
try {
|
|
264
|
+
const row = getDb().prepare("SELECT email FROM users WHERE id = ?").get(userId);
|
|
265
|
+
return isLicenseAdminEmail(row?.email);
|
|
266
|
+
} catch {
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
function upsertEnvVar(filePath, varName, value) {
|
|
271
|
+
const prefix = `${varName}=`;
|
|
272
|
+
let lines = [];
|
|
273
|
+
if (existsSync2(filePath)) {
|
|
274
|
+
lines = readFileSync2(filePath, "utf8").split(/\r?\n/);
|
|
275
|
+
}
|
|
276
|
+
const filtered = lines.filter((line) => !line.trimStart().startsWith(prefix));
|
|
277
|
+
if (value !== null) {
|
|
278
|
+
filtered.push(`${prefix}${value}`);
|
|
279
|
+
}
|
|
280
|
+
while (filtered.length && filtered[filtered.length - 1] === "") {
|
|
281
|
+
filtered.pop();
|
|
282
|
+
}
|
|
283
|
+
writeFileSync2(filePath, filtered.join("\n") + "\n", "utf8");
|
|
284
|
+
}
|
|
285
|
+
async function installLicenseKey(key) {
|
|
286
|
+
const trimmed = key.trim();
|
|
287
|
+
if (!trimmed.startsWith("pk_")) {
|
|
288
|
+
throw new Error("Invalid license key format. Must start with pk_.");
|
|
289
|
+
}
|
|
290
|
+
const previousKey = process.env.PROMPTOWL_KEY || "";
|
|
291
|
+
process.env.PROMPTOWL_KEY = trimmed;
|
|
292
|
+
const info = await validateLicense({ forceFresh: true });
|
|
293
|
+
if (!info.valid) {
|
|
294
|
+
process.env.PROMPTOWL_KEY = previousKey;
|
|
295
|
+
if (previousKey) {
|
|
296
|
+
await validateLicense({ forceFresh: true });
|
|
297
|
+
}
|
|
298
|
+
return info;
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
upsertEnvVar(config.ENV_FILE_PATH, "PROMPTOWL_KEY", trimmed);
|
|
302
|
+
} catch (err) {
|
|
303
|
+
console.warn("[license] failed to write .env:", err);
|
|
304
|
+
}
|
|
305
|
+
return info;
|
|
306
|
+
}
|
|
307
|
+
var safetyPollHandle = null;
|
|
308
|
+
var SAFETY_POLL_INTERVAL_MS = 60 * 1e3;
|
|
309
|
+
function startLicenseSafetyPoll() {
|
|
310
|
+
if (safetyPollHandle) return;
|
|
311
|
+
safetyPollHandle = setInterval(async () => {
|
|
312
|
+
if (!config.PROMPTOWL_KEY) return;
|
|
313
|
+
try {
|
|
314
|
+
const wasValid = !!currentLicense?.valid;
|
|
315
|
+
await validateLicense({ forceFresh: true });
|
|
316
|
+
const isValid = !!currentLicense?.valid;
|
|
317
|
+
if (wasValid && !isValid) {
|
|
318
|
+
console.log("[license] safety poll detected revocation");
|
|
319
|
+
handleLicenseRevoked();
|
|
320
|
+
}
|
|
321
|
+
} catch (err) {
|
|
322
|
+
console.warn(
|
|
323
|
+
`[license] safety poll error: ${err.message}`
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
}, SAFETY_POLL_INTERVAL_MS);
|
|
327
|
+
if (typeof safetyPollHandle.unref === "function") {
|
|
328
|
+
safetyPollHandle.unref();
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
function handleLicenseRevoked() {
|
|
332
|
+
try {
|
|
333
|
+
const db = getDb();
|
|
334
|
+
const result = db.prepare("DELETE FROM sessions").run();
|
|
335
|
+
console.warn(
|
|
336
|
+
`[license] revoked \u2014 wiped ${result.changes} active session(s).`
|
|
337
|
+
);
|
|
338
|
+
} catch (err) {
|
|
339
|
+
console.warn("[license] failed to wipe sessions:", err);
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
upsertEnvVar(config.ENV_FILE_PATH, "PROMPTOWL_KEY", null);
|
|
343
|
+
console.warn(
|
|
344
|
+
`[license] revoked \u2014 removed PROMPTOWL_KEY from ${config.ENV_FILE_PATH}`
|
|
345
|
+
);
|
|
346
|
+
} catch (err) {
|
|
347
|
+
console.warn("[license] failed to strip key from .env:", err);
|
|
348
|
+
}
|
|
349
|
+
process.env.PROMPTOWL_KEY = "";
|
|
350
|
+
currentLicense = {
|
|
351
|
+
valid: false,
|
|
352
|
+
tier: "none",
|
|
353
|
+
org: null,
|
|
354
|
+
limits: null,
|
|
355
|
+
suspended: false,
|
|
356
|
+
suspendedReason: null,
|
|
357
|
+
ownerEmail: null
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
361
|
+
var suspensionFirstSeen = null;
|
|
362
|
+
var suspensionConfirmed = false;
|
|
363
|
+
var suspensionReason = null;
|
|
364
|
+
var SUSPENSION_CONFIRM_WINDOW_MS = 60 * 60 * 1e3;
|
|
365
|
+
function isSuspended() {
|
|
366
|
+
return suspensionConfirmed;
|
|
367
|
+
}
|
|
368
|
+
function getSuspensionReason() {
|
|
369
|
+
return suspensionReason;
|
|
370
|
+
}
|
|
371
|
+
async function validateLicense(opts = {}) {
|
|
372
|
+
const info = await _validateLicenseImpl(!!opts.forceFresh);
|
|
373
|
+
currentLicense = info;
|
|
374
|
+
return info;
|
|
375
|
+
}
|
|
376
|
+
async function _validateLicenseImpl(forceFresh) {
|
|
377
|
+
const key = config.PROMPTOWL_KEY;
|
|
378
|
+
if (!key) {
|
|
379
|
+
return {
|
|
380
|
+
valid: false,
|
|
381
|
+
tier: "none",
|
|
382
|
+
org: null,
|
|
383
|
+
limits: null,
|
|
384
|
+
suspended: false,
|
|
385
|
+
suspendedReason: null,
|
|
386
|
+
ownerEmail: null
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
const db = getDb();
|
|
390
|
+
const cached = db.prepare("SELECT * FROM license_cache WHERE key = ?").get(key);
|
|
391
|
+
if (cached && !forceFresh) {
|
|
392
|
+
const age = Date.now() - (/* @__PURE__ */ new Date(cached.validated_at + "Z")).getTime();
|
|
393
|
+
if (age < CACHE_TTL_MS) {
|
|
394
|
+
return {
|
|
395
|
+
valid: true,
|
|
396
|
+
tier: cached.tier,
|
|
397
|
+
org: cached.org,
|
|
398
|
+
limits: cached.limits_json ? JSON.parse(cached.limits_json) : null,
|
|
399
|
+
suspended: suspensionConfirmed,
|
|
400
|
+
suspendedReason: suspensionReason,
|
|
401
|
+
ownerEmail: cached.owner_email || null
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
try {
|
|
406
|
+
const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
|
|
407
|
+
const res = await fetch(`${promptowlUrl}/api/license/validate`, {
|
|
408
|
+
method: "POST",
|
|
409
|
+
headers: { "Content-Type": "application/json" },
|
|
410
|
+
body: JSON.stringify({ key })
|
|
411
|
+
});
|
|
412
|
+
if (!res.ok) {
|
|
413
|
+
if (cached) {
|
|
414
|
+
console.warn(
|
|
415
|
+
" PromptOwl unreachable, using cached license (grace period)"
|
|
416
|
+
);
|
|
417
|
+
return {
|
|
418
|
+
valid: true,
|
|
419
|
+
tier: cached.tier,
|
|
420
|
+
org: cached.org,
|
|
421
|
+
limits: cached.limits_json ? JSON.parse(cached.limits_json) : null,
|
|
422
|
+
suspended: suspensionConfirmed,
|
|
423
|
+
suspendedReason: suspensionReason,
|
|
424
|
+
ownerEmail: cached.owner_email || null
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
valid: false,
|
|
429
|
+
tier: "none",
|
|
430
|
+
org: null,
|
|
431
|
+
limits: null,
|
|
432
|
+
suspended: false,
|
|
433
|
+
suspendedReason: null,
|
|
434
|
+
ownerEmail: null
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
const data = await res.json();
|
|
438
|
+
if (data.suspended === true) {
|
|
439
|
+
if (!suspensionFirstSeen) {
|
|
440
|
+
suspensionFirstSeen = Date.now();
|
|
441
|
+
suspensionReason = data.suspended_reason || "Suspended by PromptOwl";
|
|
442
|
+
console.warn(
|
|
443
|
+
`
|
|
444
|
+
WARNING: PromptOwl has flagged this server for suspension.`
|
|
445
|
+
);
|
|
446
|
+
console.warn(
|
|
447
|
+
` Reason: ${suspensionReason}`
|
|
448
|
+
);
|
|
449
|
+
console.warn(
|
|
450
|
+
` This will be confirmed in ~1 hour. If this is an error,`
|
|
451
|
+
);
|
|
452
|
+
console.warn(
|
|
453
|
+
` contact support@promptowl.ai to reverse it.
|
|
454
|
+
`
|
|
455
|
+
);
|
|
456
|
+
} else if (Date.now() - suspensionFirstSeen >= SUSPENSION_CONFIRM_WINDOW_MS) {
|
|
457
|
+
suspensionConfirmed = true;
|
|
458
|
+
suspensionReason = data.suspended_reason || "Suspended by PromptOwl";
|
|
459
|
+
console.error(
|
|
460
|
+
`
|
|
461
|
+
SERVER SUSPENDED: ${suspensionReason}`
|
|
462
|
+
);
|
|
463
|
+
console.error(
|
|
464
|
+
` Write operations are disabled. Reads still work.`
|
|
465
|
+
);
|
|
466
|
+
console.error(
|
|
467
|
+
` Contact support@promptowl.ai to resolve.
|
|
468
|
+
`
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
} else {
|
|
472
|
+
if (suspensionFirstSeen) {
|
|
473
|
+
console.log(" Suspension flag cleared by PromptOwl.");
|
|
474
|
+
}
|
|
475
|
+
suspensionFirstSeen = null;
|
|
476
|
+
suspensionConfirmed = false;
|
|
477
|
+
suspensionReason = null;
|
|
478
|
+
}
|
|
479
|
+
if (!data.valid && !data.suspended) {
|
|
480
|
+
db.prepare("DELETE FROM license_cache WHERE key = ?").run(key);
|
|
481
|
+
return {
|
|
482
|
+
valid: false,
|
|
483
|
+
tier: "none",
|
|
484
|
+
org: null,
|
|
485
|
+
limits: null,
|
|
486
|
+
suspended: false,
|
|
487
|
+
suspendedReason: null,
|
|
488
|
+
ownerEmail: data.owner_email || null
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
if (data.valid) {
|
|
492
|
+
const limitsJson = data.limits ? JSON.stringify(data.limits) : null;
|
|
493
|
+
db.prepare(
|
|
494
|
+
`INSERT OR REPLACE INTO license_cache (key, tier, org, limits_json, owner_email, validated_at)
|
|
495
|
+
VALUES (?, ?, ?, ?, ?, datetime('now'))`
|
|
496
|
+
).run(
|
|
497
|
+
key,
|
|
498
|
+
data.tier || "community",
|
|
499
|
+
data.org || null,
|
|
500
|
+
limitsJson,
|
|
501
|
+
data.owner_email || null
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
return {
|
|
505
|
+
valid: data.valid !== false,
|
|
506
|
+
tier: data.tier || "community",
|
|
507
|
+
org: data.org || null,
|
|
508
|
+
limits: data.limits || null,
|
|
509
|
+
suspended: suspensionConfirmed,
|
|
510
|
+
suspendedReason: suspensionReason,
|
|
511
|
+
ownerEmail: data.owner_email || null
|
|
512
|
+
};
|
|
513
|
+
} catch (err) {
|
|
514
|
+
if (cached) {
|
|
515
|
+
console.warn(
|
|
516
|
+
` PromptOwl validation failed (${err.message}), using cached license`
|
|
517
|
+
);
|
|
518
|
+
return {
|
|
519
|
+
valid: true,
|
|
520
|
+
tier: cached.tier,
|
|
521
|
+
org: cached.org,
|
|
522
|
+
limits: cached.limits_json ? JSON.parse(cached.limits_json) : null,
|
|
523
|
+
suspended: suspensionConfirmed,
|
|
524
|
+
suspendedReason: suspensionReason,
|
|
525
|
+
ownerEmail: cached.owner_email || null
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
return {
|
|
529
|
+
valid: false,
|
|
530
|
+
tier: "none",
|
|
531
|
+
org: null,
|
|
532
|
+
limits: null,
|
|
533
|
+
suspended: false,
|
|
534
|
+
suspendedReason: null,
|
|
535
|
+
ownerEmail: null
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// src/nests/service.ts
|
|
541
|
+
function resolveNestPath(nestId) {
|
|
542
|
+
return join3(config.DATA_ROOT, "nests", nestId);
|
|
543
|
+
}
|
|
544
|
+
function isImportedNest(nestId) {
|
|
545
|
+
const row = getDb().prepare("SELECT is_imported FROM nests WHERE id = ?").get(nestId);
|
|
546
|
+
return !!row?.is_imported;
|
|
547
|
+
}
|
|
548
|
+
function toSlug(name) {
|
|
549
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
550
|
+
}
|
|
551
|
+
function isStewardshipEnabled(nestId) {
|
|
552
|
+
const db = getDb();
|
|
553
|
+
const row = db.prepare("SELECT stewardship_enabled FROM nests WHERE id = ?").get(nestId);
|
|
554
|
+
return !!row?.stewardship_enabled;
|
|
555
|
+
}
|
|
556
|
+
function setStewardshipEnabled(nestId, enabled) {
|
|
557
|
+
const db = getDb();
|
|
558
|
+
db.prepare("UPDATE nests SET stewardship_enabled = ? WHERE id = ?").run(
|
|
559
|
+
enabled ? 1 : 0,
|
|
560
|
+
nestId
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
function nestAllowsSelfApprove(nestId) {
|
|
564
|
+
const db = getDb();
|
|
565
|
+
const row = db.prepare("SELECT allow_self_approve FROM nests WHERE id = ?").get(nestId);
|
|
566
|
+
return !!row?.allow_self_approve;
|
|
567
|
+
}
|
|
568
|
+
function setAllowSelfApprove(nestId, allow) {
|
|
569
|
+
const db = getDb();
|
|
570
|
+
db.prepare("UPDATE nests SET allow_self_approve = ? WHERE id = ?").run(
|
|
571
|
+
allow ? 1 : 0,
|
|
572
|
+
nestId
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
function disableStewardshipAndWipeGovernance(nestId) {
|
|
576
|
+
const db = getDb();
|
|
577
|
+
const wipe = db.transaction((id) => {
|
|
578
|
+
const stewards = db.prepare("DELETE FROM stewards WHERE nest_id = ?").run(id).changes;
|
|
579
|
+
const pendingReviews = db.prepare(
|
|
580
|
+
"DELETE FROM review_requests WHERE nest_id = ? AND status = 'pending'"
|
|
581
|
+
).run(id).changes;
|
|
582
|
+
db.prepare("UPDATE nests SET stewardship_enabled = 0 WHERE id = ?").run(id);
|
|
583
|
+
return { stewards, pendingReviews };
|
|
584
|
+
});
|
|
585
|
+
return wipe(nestId);
|
|
586
|
+
}
|
|
587
|
+
async function createNest(userId, name, description) {
|
|
588
|
+
const id = uuid();
|
|
589
|
+
const slug = toSlug(name);
|
|
590
|
+
const db = getDb();
|
|
591
|
+
const visibility = userId === ANON_USER_ID ? "public" : "private";
|
|
592
|
+
db.prepare(
|
|
593
|
+
"INSERT INTO nests (id, user_id, name, slug, description, visibility) VALUES (?, ?, ?, ?, ?, ?)"
|
|
594
|
+
).run(id, userId, name, slug, description || null, visibility);
|
|
595
|
+
const path = resolveNestPath(id);
|
|
596
|
+
mkdirSync2(path, { recursive: true });
|
|
597
|
+
const storage = new NestStorage(path);
|
|
598
|
+
await storage.init(name);
|
|
599
|
+
trackEvent("nest.create", { nestId: id, userId });
|
|
600
|
+
return db.prepare("SELECT * FROM nests WHERE id = ?").get(id);
|
|
601
|
+
}
|
|
602
|
+
async function importNest(userId, name, files) {
|
|
603
|
+
const nm = (name || "").trim();
|
|
604
|
+
if (!nm) {
|
|
605
|
+
throw new ValidationError("name is required");
|
|
606
|
+
}
|
|
607
|
+
if (!Array.isArray(files)) {
|
|
608
|
+
throw new ValidationError("files are required");
|
|
609
|
+
}
|
|
610
|
+
const db = getDb();
|
|
611
|
+
const id = uuid();
|
|
612
|
+
const slug = toSlug(nm);
|
|
613
|
+
const visibility = userId === ANON_USER_ID ? "public" : "private";
|
|
614
|
+
const dest = resolveNestPath(id);
|
|
615
|
+
mkdirSync2(dest, { recursive: true });
|
|
616
|
+
try {
|
|
617
|
+
writeImportedFiles(dest, files);
|
|
618
|
+
} catch (err) {
|
|
619
|
+
console.error("[nests] import write failed", dest, err);
|
|
620
|
+
discardImportDir(dest);
|
|
621
|
+
throw new ValidationError("Failed to import folder");
|
|
622
|
+
}
|
|
623
|
+
db.prepare(
|
|
624
|
+
"INSERT INTO nests (id, user_id, name, slug, description, visibility, is_imported) VALUES (?, ?, ?, ?, ?, ?, 1)"
|
|
625
|
+
).run(id, userId, nm, slug, null, visibility);
|
|
626
|
+
trackEvent("nest.import", { nestId: id, userId });
|
|
627
|
+
return db.prepare("SELECT * FROM nests WHERE id = ?").get(id);
|
|
628
|
+
}
|
|
629
|
+
function listNests(userId) {
|
|
630
|
+
const db = getDb();
|
|
631
|
+
if (userId === ANON_USER_ID) {
|
|
632
|
+
return db.prepare("SELECT * FROM nests WHERE user_id = ? ORDER BY created_at DESC").all(ANON_USER_ID);
|
|
633
|
+
}
|
|
634
|
+
const includeAnon = config.AUTH_MODE === "open" || isLicenseAdminUserId(userId);
|
|
635
|
+
if (!includeAnon) {
|
|
636
|
+
return db.prepare("SELECT * FROM nests WHERE user_id = ? ORDER BY created_at DESC").all(userId);
|
|
637
|
+
}
|
|
638
|
+
return db.prepare(
|
|
639
|
+
"SELECT * FROM nests WHERE user_id = ? OR user_id = ? ORDER BY created_at DESC"
|
|
640
|
+
).all(userId, ANON_USER_ID);
|
|
641
|
+
}
|
|
642
|
+
function listSharedNests(userId) {
|
|
643
|
+
const db = getDb();
|
|
644
|
+
return db.prepare(
|
|
645
|
+
`SELECT DISTINCT n.* FROM nests n
|
|
646
|
+
LEFT JOIN nest_collaborators nc
|
|
647
|
+
ON nc.nest_id = n.id AND nc.user_id = ?
|
|
648
|
+
LEFT JOIN users u ON u.id = ?
|
|
649
|
+
LEFT JOIN stewards s
|
|
650
|
+
ON s.nest_id = n.id AND s.is_active = 1
|
|
651
|
+
AND LOWER(s.user_email) = LOWER(u.email)
|
|
652
|
+
WHERE n.user_id != ?
|
|
653
|
+
AND (nc.user_id IS NOT NULL OR s.id IS NOT NULL)
|
|
654
|
+
ORDER BY n.created_at DESC`
|
|
655
|
+
).all(userId, userId, userId);
|
|
656
|
+
}
|
|
657
|
+
function listPublicNests(userId) {
|
|
658
|
+
const db = getDb();
|
|
659
|
+
return db.prepare(
|
|
660
|
+
`SELECT n.* FROM nests n
|
|
661
|
+
WHERE n.visibility = 'public'
|
|
662
|
+
AND n.user_id != ?
|
|
663
|
+
AND NOT EXISTS (
|
|
664
|
+
SELECT 1 FROM nest_collaborators nc
|
|
665
|
+
WHERE nc.nest_id = n.id AND nc.user_id = ?
|
|
666
|
+
)
|
|
667
|
+
ORDER BY n.created_at DESC`
|
|
668
|
+
).all(userId, userId);
|
|
669
|
+
}
|
|
670
|
+
function getNest(nestId) {
|
|
671
|
+
const db = getDb();
|
|
672
|
+
return db.prepare("SELECT * FROM nests WHERE id = ?").get(nestId) || null;
|
|
673
|
+
}
|
|
674
|
+
async function deleteNest(nestId) {
|
|
675
|
+
const db = getDb();
|
|
676
|
+
const wipe = db.transaction((id) => {
|
|
677
|
+
db.prepare("DELETE FROM approved_versions WHERE nest_id = ?").run(id);
|
|
678
|
+
db.prepare("DELETE FROM node_versions WHERE nest_id = ?").run(id);
|
|
679
|
+
db.prepare("DELETE FROM review_requests WHERE nest_id = ?").run(id);
|
|
680
|
+
db.prepare("DELETE FROM stewards WHERE nest_id = ?").run(id);
|
|
681
|
+
db.prepare("DELETE FROM nest_collaborators WHERE nest_id = ?").run(id);
|
|
682
|
+
try {
|
|
683
|
+
db.prepare("DELETE FROM node_tag_index WHERE nest_id = ?").run(id);
|
|
684
|
+
} catch {
|
|
685
|
+
}
|
|
686
|
+
db.prepare("DELETE FROM api_keys WHERE nest_id = ?").run(id);
|
|
687
|
+
db.prepare("DELETE FROM nests WHERE id = ?").run(id);
|
|
688
|
+
});
|
|
689
|
+
wipe(nestId);
|
|
690
|
+
const path = resolveNestPath(nestId);
|
|
691
|
+
try {
|
|
692
|
+
rmSync2(path, { recursive: true, force: true });
|
|
693
|
+
} catch (err) {
|
|
694
|
+
console.warn(`[nests] failed to remove nest directory ${path}:`, err);
|
|
695
|
+
}
|
|
696
|
+
trackEvent("nest.delete", { nestId });
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// src/nodes/flat-storage.ts
|
|
700
|
+
import { readdirSync } from "fs";
|
|
701
|
+
import { join as join4, relative, sep } from "path";
|
|
702
|
+
import { NestStorage as NestStorage2 } from "@promptowl/contextnest-engine";
|
|
703
|
+
var META_FILES = /* @__PURE__ */ new Set([
|
|
704
|
+
"INDEX.md",
|
|
705
|
+
"CONTEXT.md",
|
|
706
|
+
"CLAUDE.md",
|
|
707
|
+
"GEMINI.md",
|
|
708
|
+
"AGENTS.md",
|
|
709
|
+
"QWEN.md"
|
|
710
|
+
]);
|
|
711
|
+
var FlatNestStorage = class extends NestStorage2 {
|
|
712
|
+
async discoverDocuments() {
|
|
713
|
+
const root = this.root;
|
|
714
|
+
let entries;
|
|
715
|
+
try {
|
|
716
|
+
entries = readdirSync(root, { recursive: true, withFileTypes: true });
|
|
717
|
+
} catch (err) {
|
|
718
|
+
console.error("[FlatNestStorage] cannot read folder", root, err);
|
|
719
|
+
return [];
|
|
720
|
+
}
|
|
721
|
+
const ids = [];
|
|
722
|
+
for (const e of entries) {
|
|
723
|
+
if (!e.isFile() || !e.name.endsWith(".md")) continue;
|
|
724
|
+
if (META_FILES.has(e.name)) continue;
|
|
725
|
+
const dir = e.parentPath ?? e.path ?? root;
|
|
726
|
+
const rel = relative(root, join4(dir, e.name)).split(sep).join("/");
|
|
727
|
+
if (rel.split("/").some(isSkippedSegment)) {
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
ids.push(rel.replace(/\.md$/, ""));
|
|
731
|
+
}
|
|
732
|
+
const nodes = [];
|
|
733
|
+
for (const id of ids.sort()) {
|
|
734
|
+
try {
|
|
735
|
+
nodes.push(await this.readDocument(id));
|
|
736
|
+
} catch (err) {
|
|
737
|
+
console.error("[FlatNestStorage] skipped unreadable doc", id, err);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
return nodes;
|
|
741
|
+
}
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
// src/nodes/engine.ts
|
|
745
|
+
var NestEngineCache = class {
|
|
746
|
+
cache = /* @__PURE__ */ new Map();
|
|
747
|
+
get(nestId) {
|
|
748
|
+
let engine = this.cache.get(nestId);
|
|
749
|
+
if (!engine) {
|
|
750
|
+
const nestPath = resolveNestPath(nestId);
|
|
751
|
+
const storage = isImportedNest(nestId) ? new FlatNestStorage(nestPath) : new NestStorage3(nestPath);
|
|
752
|
+
const query = new GraphQueryEngine(storage);
|
|
753
|
+
const versions = new VersionManager(storage);
|
|
754
|
+
engine = { storage, query, versions };
|
|
755
|
+
this.cache.set(nestId, engine);
|
|
756
|
+
}
|
|
757
|
+
return engine;
|
|
758
|
+
}
|
|
759
|
+
evict(nestId) {
|
|
760
|
+
this.cache.delete(nestId);
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
var engineCache = new NestEngineCache();
|
|
764
|
+
|
|
765
|
+
// src/governance/title-resolver.ts
|
|
766
|
+
async function buildTitleMap(nestId) {
|
|
767
|
+
try {
|
|
768
|
+
const { storage } = engineCache.get(nestId);
|
|
769
|
+
const docs = await storage.discoverDocuments();
|
|
770
|
+
return new Map(docs.map((d) => [d.id, d.frontmatter.title]));
|
|
771
|
+
} catch {
|
|
772
|
+
return /* @__PURE__ */ new Map();
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// src/shared/access.ts
|
|
777
|
+
var PERMISSION_LEVELS = {
|
|
778
|
+
none: 0,
|
|
779
|
+
read: 1,
|
|
780
|
+
write: 2,
|
|
781
|
+
admin: 3,
|
|
782
|
+
owner: 4
|
|
783
|
+
};
|
|
784
|
+
function resolveNestPermission(nestId, userId) {
|
|
785
|
+
const db = getDb();
|
|
786
|
+
const nest = db.prepare("SELECT user_id, visibility FROM nests WHERE id = ?").get(nestId);
|
|
787
|
+
if (!nest) return "none";
|
|
788
|
+
if (nest.user_id === userId) return "owner";
|
|
789
|
+
if (nest.user_id === ANON_USER_ID && isLicenseAdminUserId(userId)) {
|
|
790
|
+
return "owner";
|
|
791
|
+
}
|
|
792
|
+
const directGrant = db.prepare(
|
|
793
|
+
"SELECT permission FROM nest_collaborators WHERE nest_id = ? AND user_id = ?"
|
|
794
|
+
).get(nestId, userId);
|
|
795
|
+
if (directGrant) return directGrant.permission;
|
|
796
|
+
const stewardGrant = db.prepare(
|
|
797
|
+
`SELECT 1 FROM stewards s
|
|
798
|
+
JOIN users u ON u.id = ?
|
|
799
|
+
WHERE s.nest_id = ? AND s.is_active = 1
|
|
800
|
+
AND LOWER(s.user_email) = LOWER(u.email)
|
|
801
|
+
LIMIT 1`
|
|
802
|
+
).get(userId, nestId);
|
|
803
|
+
if (stewardGrant) return "read";
|
|
804
|
+
if (nest.visibility === "public") return "read";
|
|
805
|
+
return "none";
|
|
806
|
+
}
|
|
807
|
+
function permissionLevel(p) {
|
|
808
|
+
return PERMISSION_LEVELS[p] ?? 0;
|
|
809
|
+
}
|
|
810
|
+
function isPublicReader(nestId, userId) {
|
|
811
|
+
const db = getDb();
|
|
812
|
+
const nest = db.prepare("SELECT user_id, visibility FROM nests WHERE id = ?").get(nestId);
|
|
813
|
+
if (!nest) return false;
|
|
814
|
+
if (nest.visibility !== "public") return false;
|
|
815
|
+
if (nest.user_id === userId) return false;
|
|
816
|
+
const directGrant = db.prepare(
|
|
817
|
+
"SELECT 1 FROM nest_collaborators WHERE nest_id = ? AND user_id = ?"
|
|
818
|
+
).get(nestId, userId);
|
|
819
|
+
if (directGrant) return false;
|
|
820
|
+
const stewardGrant = db.prepare(
|
|
821
|
+
`SELECT 1 FROM stewards s
|
|
822
|
+
JOIN users u ON u.id = ?
|
|
823
|
+
WHERE s.nest_id = ? AND s.is_active = 1
|
|
824
|
+
AND LOWER(s.user_email) = LOWER(u.email)
|
|
825
|
+
LIMIT 1`
|
|
826
|
+
).get(userId, nestId);
|
|
827
|
+
if (stewardGrant) return false;
|
|
828
|
+
return true;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// src/governance/roles.ts
|
|
832
|
+
function collabPermToRole(permission) {
|
|
833
|
+
switch (permission) {
|
|
834
|
+
case "owner":
|
|
835
|
+
return "owner";
|
|
836
|
+
case "admin":
|
|
837
|
+
return "admin";
|
|
838
|
+
case "write":
|
|
839
|
+
return "editor";
|
|
840
|
+
case "read":
|
|
841
|
+
return "viewer";
|
|
842
|
+
default:
|
|
843
|
+
return null;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
var includesAny = (roles, wanted) => roles.some((r) => wanted.includes(r));
|
|
847
|
+
var canViewWith = (roles) => roles.length > 0;
|
|
848
|
+
var canEditWith = (roles) => includesAny(roles, ["owner", "admin", "editor"]);
|
|
849
|
+
var canManageWith = (roles) => includesAny(roles, ["owner", "admin"]);
|
|
850
|
+
var PRECEDENCE = [
|
|
851
|
+
"owner",
|
|
852
|
+
"admin",
|
|
853
|
+
"editor",
|
|
854
|
+
"reviewer",
|
|
855
|
+
"viewer"
|
|
856
|
+
];
|
|
857
|
+
function primaryRole(roles) {
|
|
858
|
+
for (const r of PRECEDENCE) if (roles.includes(r)) return r;
|
|
859
|
+
return null;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// src/governance/stewardship-service.ts
|
|
863
|
+
function assignSteward(data) {
|
|
864
|
+
const db = getDb();
|
|
865
|
+
const id = uuid2();
|
|
866
|
+
db.prepare(
|
|
867
|
+
`INSERT INTO stewards
|
|
868
|
+
(id, nest_id, scope, node_pattern, tag_name, user_email, user_id, role, assigned_by, is_active)
|
|
869
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
870
|
+
).run(
|
|
871
|
+
id,
|
|
872
|
+
data.nestId,
|
|
873
|
+
data.scope,
|
|
874
|
+
data.nodePattern || null,
|
|
875
|
+
data.tagName || null,
|
|
876
|
+
data.userEmail,
|
|
877
|
+
data.userId || null,
|
|
878
|
+
data.role,
|
|
879
|
+
data.assignedBy,
|
|
880
|
+
data.isActive ? 1 : 0
|
|
881
|
+
);
|
|
882
|
+
return { ...data, id };
|
|
883
|
+
}
|
|
884
|
+
function removeSteward(id) {
|
|
885
|
+
const db = getDb();
|
|
886
|
+
db.prepare("DELETE FROM stewards WHERE id = ?").run(id);
|
|
887
|
+
}
|
|
888
|
+
var VALID_STEWARD_ROLES = ["editor", "reviewer", "viewer"];
|
|
889
|
+
function updateSteward(id, update) {
|
|
890
|
+
const db = getDb();
|
|
891
|
+
const current = db.prepare("SELECT * FROM stewards WHERE id = ? AND is_active = 1").get(id);
|
|
892
|
+
if (!current) {
|
|
893
|
+
throw new ValidationError("Steward not found");
|
|
894
|
+
}
|
|
895
|
+
const role = update.role ?? current.role;
|
|
896
|
+
if (!VALID_STEWARD_ROLES.includes(role)) {
|
|
897
|
+
throw new ValidationError(
|
|
898
|
+
`Invalid role "${role}" \u2014 must be editor, reviewer, or viewer.`
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
let scope = current.scope;
|
|
902
|
+
let nodePattern = current.node_pattern ?? null;
|
|
903
|
+
let tagName = current.tag_name ?? null;
|
|
904
|
+
if (update.scope) {
|
|
905
|
+
scope = update.scope;
|
|
906
|
+
nodePattern = null;
|
|
907
|
+
tagName = null;
|
|
908
|
+
switch (scope) {
|
|
909
|
+
case "document":
|
|
910
|
+
if (!update.documentId)
|
|
911
|
+
throw new ValidationError("documentId required for document scope");
|
|
912
|
+
nodePattern = update.documentId;
|
|
913
|
+
break;
|
|
914
|
+
case "tag":
|
|
915
|
+
if (!update.tagName)
|
|
916
|
+
throw new ValidationError("tagName required for tag scope");
|
|
917
|
+
tagName = update.tagName.trim().replace(/^#+/, "").toLowerCase();
|
|
918
|
+
break;
|
|
919
|
+
case "nest":
|
|
920
|
+
break;
|
|
921
|
+
}
|
|
922
|
+
const dup = db.prepare(
|
|
923
|
+
`SELECT role FROM stewards
|
|
924
|
+
WHERE nest_id = ? AND is_active = 1 AND id != ? AND scope = ?
|
|
925
|
+
AND user_email = ?
|
|
926
|
+
AND COALESCE(node_pattern, '') = COALESCE(?, '')
|
|
927
|
+
AND COALESCE(tag_name, '') = COALESCE(?, '')`
|
|
928
|
+
).get(current.nest_id, id, scope, current.user_email, nodePattern, tagName);
|
|
929
|
+
if (dup) {
|
|
930
|
+
const scopeLabel = scope === "document" ? `document "${nodePattern}"` : scope === "tag" ? `tag "#${tagName}"` : "this nest";
|
|
931
|
+
throw new ConflictError(
|
|
932
|
+
`"${current.user_email}" is already a steward of ${scopeLabel} with the "${dup.role}" role.`
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
db.prepare(
|
|
937
|
+
"UPDATE stewards SET role = ?, scope = ?, node_pattern = ?, tag_name = ? WHERE id = ?"
|
|
938
|
+
).run(role, scope, nodePattern, tagName, id);
|
|
939
|
+
const row = db.prepare("SELECT * FROM stewards WHERE id = ?").get(id);
|
|
940
|
+
return rowToSteward(row);
|
|
941
|
+
}
|
|
942
|
+
function updateStewardRole(id, role) {
|
|
943
|
+
return updateSteward(id, { role });
|
|
944
|
+
}
|
|
945
|
+
function getSteward(id) {
|
|
946
|
+
const db = getDb();
|
|
947
|
+
const row = db.prepare("SELECT * FROM stewards WHERE id = ?").get(id);
|
|
948
|
+
return row ? rowToSteward(row) : null;
|
|
949
|
+
}
|
|
950
|
+
async function getStewardsForNest(nestId) {
|
|
951
|
+
const db = getDb();
|
|
952
|
+
const rows = db.prepare("SELECT * FROM stewards WHERE nest_id = ? AND is_active = 1").all(nestId);
|
|
953
|
+
return enrichWithTitles(nestId, rows.map(rowToSteward));
|
|
954
|
+
}
|
|
955
|
+
function getStewardsForScope(params) {
|
|
956
|
+
const db = getDb();
|
|
957
|
+
let sql = "SELECT * FROM stewards WHERE nest_id = ? AND is_active = 1";
|
|
958
|
+
const args = [params.nestId];
|
|
959
|
+
if (params.scope) {
|
|
960
|
+
sql += " AND scope = ?";
|
|
961
|
+
args.push(params.scope);
|
|
962
|
+
}
|
|
963
|
+
if (params.scopeTarget) {
|
|
964
|
+
sql += " AND (node_pattern = ? OR tag_name = ?)";
|
|
965
|
+
args.push(params.scopeTarget, params.scopeTarget);
|
|
966
|
+
}
|
|
967
|
+
return db.prepare(sql).all(...args).map(rowToSteward);
|
|
968
|
+
}
|
|
969
|
+
async function listStewards(params) {
|
|
970
|
+
const db = getDb();
|
|
971
|
+
let sql = "SELECT * FROM stewards WHERE nest_id = ? AND is_active = 1";
|
|
972
|
+
const args = [params.nestId];
|
|
973
|
+
if (params.scope) {
|
|
974
|
+
sql += " AND scope = ?";
|
|
975
|
+
args.push(params.scope);
|
|
976
|
+
}
|
|
977
|
+
if (params.search) {
|
|
978
|
+
sql += " AND (user_email LIKE ? OR tag_name LIKE ? OR node_pattern LIKE ?)";
|
|
979
|
+
const like = `%${params.search.toLowerCase()}%`;
|
|
980
|
+
args.push(like, like, like);
|
|
981
|
+
}
|
|
982
|
+
sql += " ORDER BY scope, COALESCE(node_pattern, tag_name, ''), user_email";
|
|
983
|
+
const stewards = db.prepare(sql).all(...args).map(rowToSteward);
|
|
984
|
+
return enrichWithTitles(params.nestId, stewards);
|
|
985
|
+
}
|
|
986
|
+
async function enrichWithTitles(nestId, stewards) {
|
|
987
|
+
if (!stewards.some((s) => s.scope === "document")) return stewards;
|
|
988
|
+
const titleByNodeId = await buildTitleMap(nestId);
|
|
989
|
+
for (const s of stewards) {
|
|
990
|
+
if (s.scope === "document" && s.nodePattern) {
|
|
991
|
+
s.nodeTitle = titleByNodeId.get(s.nodePattern);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
return stewards;
|
|
995
|
+
}
|
|
996
|
+
async function createStewardRecord(params) {
|
|
997
|
+
if (params.users.length === 0) {
|
|
998
|
+
throw new Error("At least one user is required");
|
|
999
|
+
}
|
|
1000
|
+
let nodePattern;
|
|
1001
|
+
let tagName;
|
|
1002
|
+
switch (params.scope) {
|
|
1003
|
+
case "document":
|
|
1004
|
+
if (!params.documentId) throw new Error("documentId required for document scope");
|
|
1005
|
+
nodePattern = params.documentId;
|
|
1006
|
+
break;
|
|
1007
|
+
case "tag":
|
|
1008
|
+
if (!params.tagName) throw new Error("tagName required for tag scope");
|
|
1009
|
+
tagName = params.tagName.trim().replace(/^#+/, "").toLowerCase();
|
|
1010
|
+
break;
|
|
1011
|
+
case "nest":
|
|
1012
|
+
break;
|
|
1013
|
+
}
|
|
1014
|
+
const db = getDb();
|
|
1015
|
+
const results = [];
|
|
1016
|
+
const actor = (params.assignedBy || "").trim().toLowerCase();
|
|
1017
|
+
const ownerEmail = (getNestOwnerEmail(params.nestId) || "").toLowerCase();
|
|
1018
|
+
for (const user of params.users) {
|
|
1019
|
+
const email = user.email.trim().toLowerCase();
|
|
1020
|
+
if (!email) continue;
|
|
1021
|
+
if (email === actor) {
|
|
1022
|
+
throw new ValidationError(
|
|
1023
|
+
"You already manage this nest, so you can't add yourself as a steward."
|
|
1024
|
+
);
|
|
1025
|
+
}
|
|
1026
|
+
if (ownerEmail && email === ownerEmail) {
|
|
1027
|
+
throw new ValidationError(
|
|
1028
|
+
"The nest owner already has full access and doesn't need a steward role."
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
const existing = db.prepare(
|
|
1032
|
+
`SELECT * FROM stewards
|
|
1033
|
+
WHERE nest_id = ? AND is_active = 1 AND scope = ? AND user_email = ?
|
|
1034
|
+
AND COALESCE(node_pattern, '') = COALESCE(?, '')
|
|
1035
|
+
AND COALESCE(tag_name, '') = COALESCE(?, '')`
|
|
1036
|
+
).get(
|
|
1037
|
+
params.nestId,
|
|
1038
|
+
params.scope,
|
|
1039
|
+
email,
|
|
1040
|
+
nodePattern ?? null,
|
|
1041
|
+
tagName ?? null
|
|
1042
|
+
);
|
|
1043
|
+
if (existing) {
|
|
1044
|
+
const scopeLabel = params.scope === "document" ? `document "${nodePattern}"` : params.scope === "tag" ? `tag "#${tagName}"` : "this nest";
|
|
1045
|
+
throw new ConflictError(
|
|
1046
|
+
`"${email}" is already a steward of ${scopeLabel} with the "${existing.role}" role. Remove the existing assignment first to change the role.`
|
|
1047
|
+
);
|
|
1048
|
+
}
|
|
1049
|
+
const userRow = db.prepare("SELECT id FROM users WHERE email = ?").get(email);
|
|
1050
|
+
const created = assignSteward({
|
|
1051
|
+
nestId: params.nestId,
|
|
1052
|
+
scope: params.scope,
|
|
1053
|
+
nodePattern,
|
|
1054
|
+
tagName,
|
|
1055
|
+
userEmail: email,
|
|
1056
|
+
userId: userRow?.id,
|
|
1057
|
+
role: user.role ?? "reviewer",
|
|
1058
|
+
assignedBy: params.assignedBy,
|
|
1059
|
+
assignedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1060
|
+
isActive: true
|
|
1061
|
+
});
|
|
1062
|
+
results.push(created);
|
|
1063
|
+
}
|
|
1064
|
+
db.prepare(
|
|
1065
|
+
"UPDATE nests SET stewardship_enabled = 1 WHERE id = ? AND stewardship_enabled = 0"
|
|
1066
|
+
).run(params.nestId);
|
|
1067
|
+
return results;
|
|
1068
|
+
}
|
|
1069
|
+
function resolveStewardsForNode(nestId, nodeId) {
|
|
1070
|
+
return resolve(nestId, nodeId).stewards;
|
|
1071
|
+
}
|
|
1072
|
+
function resolveStewardsWithFallback(nestId, nodeId) {
|
|
1073
|
+
return resolve(nestId, nodeId);
|
|
1074
|
+
}
|
|
1075
|
+
function resolve(nestId, nodeId) {
|
|
1076
|
+
const db = getDb();
|
|
1077
|
+
const rows = db.prepare(
|
|
1078
|
+
`
|
|
1079
|
+
SELECT s.*, 1 AS priority, ('document: ' || s.node_pattern) AS match_source
|
|
1080
|
+
FROM stewards s
|
|
1081
|
+
WHERE s.nest_id = ? AND s.is_active = 1 AND s.scope = 'document'
|
|
1082
|
+
AND s.node_pattern = ?
|
|
1083
|
+
UNION ALL
|
|
1084
|
+
SELECT s.*, 2 AS priority, ('tag: ' || s.tag_name) AS match_source
|
|
1085
|
+
FROM stewards s
|
|
1086
|
+
JOIN node_tag_index nt
|
|
1087
|
+
ON nt.nest_id = s.nest_id
|
|
1088
|
+
AND nt.tag_name = s.tag_name
|
|
1089
|
+
WHERE s.nest_id = ? AND s.is_active = 1 AND s.scope = 'tag'
|
|
1090
|
+
AND nt.node_id = ?
|
|
1091
|
+
UNION ALL
|
|
1092
|
+
SELECT s.*, 3 AS priority, 'nest-level steward' AS match_source
|
|
1093
|
+
FROM stewards s
|
|
1094
|
+
WHERE s.nest_id = ? AND s.is_active = 1 AND s.scope = 'nest'
|
|
1095
|
+
ORDER BY priority ASC, user_email ASC
|
|
1096
|
+
`
|
|
1097
|
+
).all(
|
|
1098
|
+
nestId,
|
|
1099
|
+
nodeId,
|
|
1100
|
+
// document branch
|
|
1101
|
+
nestId,
|
|
1102
|
+
nodeId,
|
|
1103
|
+
// tag branch
|
|
1104
|
+
nestId
|
|
1105
|
+
// nest branch
|
|
1106
|
+
);
|
|
1107
|
+
const resolved = rows.map((row) => ({
|
|
1108
|
+
steward: rowToSteward(row),
|
|
1109
|
+
priority: row.priority,
|
|
1110
|
+
source: row.match_source
|
|
1111
|
+
}));
|
|
1112
|
+
if (resolved.length > 0) {
|
|
1113
|
+
return { stewards: resolved, fallbackToOwner: false };
|
|
1114
|
+
}
|
|
1115
|
+
const owner = db.prepare(
|
|
1116
|
+
`SELECT u.email FROM nests n
|
|
1117
|
+
JOIN users u ON u.id = n.user_id
|
|
1118
|
+
WHERE n.id = ?`
|
|
1119
|
+
).get(nestId);
|
|
1120
|
+
return {
|
|
1121
|
+
stewards: [],
|
|
1122
|
+
fallbackToOwner: true,
|
|
1123
|
+
ownerEmail: owner?.email
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
function userIdForEmail(email) {
|
|
1127
|
+
const db = getDb();
|
|
1128
|
+
const row = db.prepare("SELECT id FROM users WHERE LOWER(email) = LOWER(?)").get(email);
|
|
1129
|
+
return row?.id;
|
|
1130
|
+
}
|
|
1131
|
+
function getStewardRolesForUser(nestId, userEmail) {
|
|
1132
|
+
const db = getDb();
|
|
1133
|
+
const rows = db.prepare(
|
|
1134
|
+
"SELECT DISTINCT role FROM stewards WHERE nest_id = ? AND is_active = 1 AND LOWER(user_email) = LOWER(?)"
|
|
1135
|
+
).all(nestId, userEmail);
|
|
1136
|
+
return rows.map((r) => r.role);
|
|
1137
|
+
}
|
|
1138
|
+
function getStewardsForUser(nestId, userEmail) {
|
|
1139
|
+
const db = getDb();
|
|
1140
|
+
const rows = db.prepare(
|
|
1141
|
+
"SELECT * FROM stewards WHERE nest_id = ? AND is_active = 1 AND LOWER(user_email) = LOWER(?)"
|
|
1142
|
+
).all(nestId, userEmail);
|
|
1143
|
+
return rows.map(rowToSteward);
|
|
1144
|
+
}
|
|
1145
|
+
function getCollaboratorRole(nestId, userEmail) {
|
|
1146
|
+
const userId = userIdForEmail(userEmail);
|
|
1147
|
+
if (!userId) return null;
|
|
1148
|
+
const db = getDb();
|
|
1149
|
+
const row = db.prepare(
|
|
1150
|
+
"SELECT permission FROM nest_collaborators WHERE nest_id = ? AND user_id = ?"
|
|
1151
|
+
).get(nestId, userId);
|
|
1152
|
+
return row?.permission ?? null;
|
|
1153
|
+
}
|
|
1154
|
+
function resolveUserRoles(nestId, userEmail, opts) {
|
|
1155
|
+
const roles = /* @__PURE__ */ new Set();
|
|
1156
|
+
if (isSuperAdmin2(userEmail)) roles.add("admin");
|
|
1157
|
+
const owner = getNestOwnerEmail(nestId);
|
|
1158
|
+
if (owner && owner.toLowerCase() === userEmail.toLowerCase()) {
|
|
1159
|
+
roles.add("owner");
|
|
1160
|
+
}
|
|
1161
|
+
const userId = userIdForEmail(userEmail);
|
|
1162
|
+
if (userId) {
|
|
1163
|
+
const collabRole = collabPermToRole(resolveNestPermission(nestId, userId));
|
|
1164
|
+
if (collabRole) roles.add(collabRole);
|
|
1165
|
+
}
|
|
1166
|
+
const stewardRoles = opts?.nodeId ? resolveStewardsForNode(nestId, opts.nodeId).filter(
|
|
1167
|
+
(r) => r.steward.userEmail.toLowerCase() === userEmail.toLowerCase()
|
|
1168
|
+
).map((r) => r.steward.role) : getStewardRolesForUser(nestId, userEmail);
|
|
1169
|
+
for (const role of stewardRoles) roles.add(role);
|
|
1170
|
+
return [...roles];
|
|
1171
|
+
}
|
|
1172
|
+
function isSuperAdmin2(userEmail) {
|
|
1173
|
+
const cfg = getAccessConfig();
|
|
1174
|
+
return !!cfg?.super_admins?.includes(userEmail);
|
|
1175
|
+
}
|
|
1176
|
+
function canManageStewards(userEmail) {
|
|
1177
|
+
if (config.AUTH_MODE === "open") return true;
|
|
1178
|
+
return isLicenseAdminEmail(userEmail) || isSuperAdmin2(userEmail);
|
|
1179
|
+
}
|
|
1180
|
+
function canCreateInNest(nestId, userEmail) {
|
|
1181
|
+
if (config.AUTH_MODE === "open" || isSuperAdmin2(userEmail)) return true;
|
|
1182
|
+
const userId = userIdForEmail(userEmail);
|
|
1183
|
+
if (userId) {
|
|
1184
|
+
const perm = resolveNestPermission(nestId, userId);
|
|
1185
|
+
if (perm === "owner" || perm === "admin" || perm === "write") return true;
|
|
1186
|
+
}
|
|
1187
|
+
return getStewardsForUser(nestId, userEmail).some(
|
|
1188
|
+
(s) => s.role === "editor" && s.scope === "nest"
|
|
1189
|
+
);
|
|
1190
|
+
}
|
|
1191
|
+
function getNestOwnerEmail(nestId) {
|
|
1192
|
+
const db = getDb();
|
|
1193
|
+
const row = db.prepare(
|
|
1194
|
+
`SELECT u.email FROM nests n
|
|
1195
|
+
JOIN users u ON u.id = n.user_id
|
|
1196
|
+
WHERE n.id = ?`
|
|
1197
|
+
).get(nestId);
|
|
1198
|
+
return row?.email ?? null;
|
|
1199
|
+
}
|
|
1200
|
+
function canUserEdit(nestId, nodeId, userEmail) {
|
|
1201
|
+
const roles = resolveUserRoles(nestId, userEmail, { nodeId });
|
|
1202
|
+
if (roles.includes("owner")) {
|
|
1203
|
+
return { allowed: true, reason: "nest owner", role: "owner" };
|
|
1204
|
+
}
|
|
1205
|
+
if (isSuperAdmin2(userEmail)) {
|
|
1206
|
+
return { allowed: true, reason: "super admin", role: "super_admin" };
|
|
1207
|
+
}
|
|
1208
|
+
if (canEditWith(roles)) {
|
|
1209
|
+
return {
|
|
1210
|
+
allowed: true,
|
|
1211
|
+
reason: "editor access (collaborator or steward)",
|
|
1212
|
+
role: roles.includes("admin") ? "admin" : "editor"
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
return { allowed: false, reason: "no editor role on this node", role: null };
|
|
1216
|
+
}
|
|
1217
|
+
function getCurrentVersionAuthor(nestId, nodeId) {
|
|
1218
|
+
const db = getDb();
|
|
1219
|
+
const row = db.prepare(
|
|
1220
|
+
`SELECT author FROM node_versions
|
|
1221
|
+
WHERE nest_id = ? AND node_id = ?
|
|
1222
|
+
ORDER BY version DESC LIMIT 1`
|
|
1223
|
+
).get(nestId, nodeId);
|
|
1224
|
+
return row?.author ?? null;
|
|
1225
|
+
}
|
|
1226
|
+
function getPendingReviewRequester(nestId, nodeId) {
|
|
1227
|
+
const db = getDb();
|
|
1228
|
+
const row = db.prepare(
|
|
1229
|
+
`SELECT requested_by FROM review_requests
|
|
1230
|
+
WHERE nest_id = ? AND node_id = ? AND status = 'pending'
|
|
1231
|
+
ORDER BY requested_at DESC LIMIT 1`
|
|
1232
|
+
).get(nestId, nodeId);
|
|
1233
|
+
return row?.requested_by ?? null;
|
|
1234
|
+
}
|
|
1235
|
+
function canUserApprove(nestId, nodeId, userEmail) {
|
|
1236
|
+
const roles = resolveUserRoles(nestId, userEmail, { nodeId });
|
|
1237
|
+
const isOwner = roles.includes("owner");
|
|
1238
|
+
const isSuper = isSuperAdmin2(userEmail);
|
|
1239
|
+
const allowSelf = nestAllowsSelfApprove(nestId);
|
|
1240
|
+
const hasStewardApprove = roles.includes("admin") || roles.includes("reviewer");
|
|
1241
|
+
const actor = getPendingReviewRequester(nestId, nodeId) ?? getCurrentVersionAuthor(nestId, nodeId);
|
|
1242
|
+
const isOwnSubmission = !!actor && actor.toLowerCase() === userEmail.toLowerCase();
|
|
1243
|
+
if (hasStewardApprove) {
|
|
1244
|
+
if (isOwnSubmission && !((isOwner || isSuper) && allowSelf)) {
|
|
1245
|
+
return {
|
|
1246
|
+
allowed: false,
|
|
1247
|
+
reason: "You submitted this version for review, so you can't approve it yourself. Ask another reviewer to approve it (separation of duties).",
|
|
1248
|
+
role: roles.includes("admin") ? "admin" : "reviewer"
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
return {
|
|
1252
|
+
allowed: true,
|
|
1253
|
+
reason: "reviewer access (collaborator or steward)",
|
|
1254
|
+
role: roles.includes("admin") ? "admin" : "reviewer"
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
if (isOwner || isSuper) {
|
|
1258
|
+
if (allowSelf) {
|
|
1259
|
+
return {
|
|
1260
|
+
allowed: true,
|
|
1261
|
+
reason: isOwner ? "nest owner (self-approve enabled)" : "super admin",
|
|
1262
|
+
role: isOwner ? "owner" : "super_admin"
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
return {
|
|
1266
|
+
allowed: false,
|
|
1267
|
+
reason: "Approvals go to assigned reviewers while self-approve is off. Enable self-approve in nest settings to approve directly (e.g. while seeding), or add a reviewer steward.",
|
|
1268
|
+
role: isOwner ? "owner" : "super_admin"
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
const held = primaryRole(roles);
|
|
1272
|
+
return {
|
|
1273
|
+
allowed: false,
|
|
1274
|
+
reason: held ? `You're a "${held}" on this document \u2014 only reviewers (or admins) can approve. Ask the nest owner to grant you the reviewer steward role.` : "You're not a steward on this document, so you can't approve it. Ask the nest owner to add you as a reviewer.",
|
|
1275
|
+
role: held
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
function canUserAccess(nestId, nodeId, userEmail) {
|
|
1279
|
+
const roles = resolveUserRoles(nestId, userEmail, { nodeId });
|
|
1280
|
+
if (roles.includes("owner")) {
|
|
1281
|
+
return { allowed: true, reason: "nest owner", role: "owner" };
|
|
1282
|
+
}
|
|
1283
|
+
if (isSuperAdmin2(userEmail)) {
|
|
1284
|
+
return { allowed: true, reason: "super admin", role: "super_admin" };
|
|
1285
|
+
}
|
|
1286
|
+
if (canViewWith(roles)) {
|
|
1287
|
+
return {
|
|
1288
|
+
allowed: true,
|
|
1289
|
+
reason: "collaborator or steward access",
|
|
1290
|
+
role: primaryRole(roles)
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
return { allowed: false, reason: "no collaborator or steward access", role: null };
|
|
1294
|
+
}
|
|
1295
|
+
function syncFromConfig(nestId, config2) {
|
|
1296
|
+
const db = getDb();
|
|
1297
|
+
let count = 0;
|
|
1298
|
+
db.prepare(
|
|
1299
|
+
"UPDATE stewards SET is_active = 0 WHERE nest_id = ?"
|
|
1300
|
+
).run(nestId);
|
|
1301
|
+
db.prepare(
|
|
1302
|
+
"UPDATE nests SET stewardship_enabled = 1 WHERE id = ?"
|
|
1303
|
+
).run(nestId);
|
|
1304
|
+
const addEntries = (scope, entries, target) => {
|
|
1305
|
+
for (const entry of entries) {
|
|
1306
|
+
const user = db.prepare("SELECT id FROM users WHERE email = ?").get(entry.email);
|
|
1307
|
+
const rawRole = entry.role || "reviewer";
|
|
1308
|
+
const role = rawRole === "admin" ? "reviewer" : rawRole;
|
|
1309
|
+
assignSteward({
|
|
1310
|
+
nestId,
|
|
1311
|
+
scope,
|
|
1312
|
+
nodePattern: target?.nodePattern,
|
|
1313
|
+
tagName: target?.tagName ? target.tagName.trim().replace(/^#+/, "").toLowerCase() : void 0,
|
|
1314
|
+
userEmail: entry.email.toLowerCase(),
|
|
1315
|
+
userId: user?.id,
|
|
1316
|
+
role,
|
|
1317
|
+
assignedBy: "config",
|
|
1318
|
+
assignedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1319
|
+
isActive: true
|
|
1320
|
+
});
|
|
1321
|
+
count++;
|
|
1322
|
+
}
|
|
1323
|
+
};
|
|
1324
|
+
if (config2.nest) {
|
|
1325
|
+
addEntries("nest", config2.nest);
|
|
1326
|
+
}
|
|
1327
|
+
if (config2.tags) {
|
|
1328
|
+
for (const [tagName, entries] of Object.entries(config2.tags)) {
|
|
1329
|
+
addEntries("tag", entries, { tagName });
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
if (config2.documents) {
|
|
1333
|
+
for (const [docPattern, entries] of Object.entries(config2.documents)) {
|
|
1334
|
+
addEntries("document", entries, { nodePattern: docPattern });
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
return count;
|
|
1338
|
+
}
|
|
1339
|
+
function rowToSteward(row) {
|
|
1340
|
+
return {
|
|
1341
|
+
id: row.id,
|
|
1342
|
+
nestId: row.nest_id,
|
|
1343
|
+
scope: row.scope,
|
|
1344
|
+
nodePattern: row.node_pattern || void 0,
|
|
1345
|
+
tagName: row.tag_name || void 0,
|
|
1346
|
+
userEmail: row.user_email,
|
|
1347
|
+
userId: row.user_id || void 0,
|
|
1348
|
+
role: row.role,
|
|
1349
|
+
assignedBy: row.assigned_by,
|
|
1350
|
+
assignedAt: row.assigned_at,
|
|
1351
|
+
isActive: !!row.is_active
|
|
1352
|
+
};
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
export {
|
|
1356
|
+
AppError,
|
|
1357
|
+
NotFoundError,
|
|
1358
|
+
ForbiddenError,
|
|
1359
|
+
ValidationError,
|
|
1360
|
+
ConflictError,
|
|
1361
|
+
LockedError,
|
|
1362
|
+
trackEvent,
|
|
1363
|
+
startTelemetryLoop,
|
|
1364
|
+
getCurrentLicense,
|
|
1365
|
+
isLicenseAdminEmail,
|
|
1366
|
+
isLicenseAdminUserId,
|
|
1367
|
+
installLicenseKey,
|
|
1368
|
+
startLicenseSafetyPoll,
|
|
1369
|
+
isSuspended,
|
|
1370
|
+
getSuspensionReason,
|
|
1371
|
+
validateLicense,
|
|
1372
|
+
resolveNestPermission,
|
|
1373
|
+
permissionLevel,
|
|
1374
|
+
isPublicReader,
|
|
1375
|
+
isStewardshipEnabled,
|
|
1376
|
+
setStewardshipEnabled,
|
|
1377
|
+
nestAllowsSelfApprove,
|
|
1378
|
+
setAllowSelfApprove,
|
|
1379
|
+
disableStewardshipAndWipeGovernance,
|
|
1380
|
+
createNest,
|
|
1381
|
+
importNest,
|
|
1382
|
+
listNests,
|
|
1383
|
+
listSharedNests,
|
|
1384
|
+
listPublicNests,
|
|
1385
|
+
getNest,
|
|
1386
|
+
deleteNest,
|
|
1387
|
+
engineCache,
|
|
1388
|
+
loadAccessConfig,
|
|
1389
|
+
isSuperAdmin,
|
|
1390
|
+
buildTitleMap,
|
|
1391
|
+
canManageWith,
|
|
1392
|
+
assignSteward,
|
|
1393
|
+
removeSteward,
|
|
1394
|
+
updateSteward,
|
|
1395
|
+
updateStewardRole,
|
|
1396
|
+
getSteward,
|
|
1397
|
+
getStewardsForNest,
|
|
1398
|
+
getStewardsForScope,
|
|
1399
|
+
listStewards,
|
|
1400
|
+
createStewardRecord,
|
|
1401
|
+
resolveStewardsForNode,
|
|
1402
|
+
resolveStewardsWithFallback,
|
|
1403
|
+
getStewardRolesForUser,
|
|
1404
|
+
getStewardsForUser,
|
|
1405
|
+
getCollaboratorRole,
|
|
1406
|
+
resolveUserRoles,
|
|
1407
|
+
canManageStewards,
|
|
1408
|
+
canCreateInNest,
|
|
1409
|
+
canUserEdit,
|
|
1410
|
+
canUserApprove,
|
|
1411
|
+
canUserAccess,
|
|
1412
|
+
syncFromConfig
|
|
1413
|
+
};
|