@promptowl/contextnest-community 1.0.1 → 1.1.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 +6 -4
- package/README.md +80 -7
- package/dist/{chunk-JMZ75ZCD.js → chunk-7UTMBL6Z.js} +1 -1
- package/dist/{chunk-5VHKEIAW.js → chunk-S2EWN2VA.js} +18 -36
- package/dist/{chunk-KQCWNHDM.js → chunk-TDAX3JOT.js} +25 -0
- package/dist/chunk-WCOUCBDJ.js +1406 -0
- package/dist/{chunk-7K2LLJXK.js → chunk-XRK6SQSC.js} +1 -1
- package/dist/index.js +1022 -1230
- package/dist/{keys-YV33AJK3.js → keys-73STFJJB.js} +1 -1
- package/dist/{review-service-4WS3XL6K.js → review-service-3OJIPYNV.js} +4 -4
- package/dist/{stewardship-service-C5D2O7ZE.js → stewardship-service-3XGX7QIN.js} +20 -4
- package/dist/{version-service-TFEYNPH7.js → version-service-UODXLAOJ.js} +2 -2
- package/dist/web3/assets/index-BLxRS7jD.js +673 -0
- package/dist/web3/assets/index-DszK6Vkc.css +1 -0
- package/dist/web3/index.html +2 -2
- package/package.json +136 -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
package/dist/index.js
CHANGED
|
@@ -6,48 +6,85 @@ import {
|
|
|
6
6
|
hashPassword,
|
|
7
7
|
parseBearerToken,
|
|
8
8
|
verifyPassword
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-XRK6SQSC.js";
|
|
10
10
|
import {
|
|
11
11
|
approve,
|
|
12
12
|
cancelReview,
|
|
13
|
-
engineCache,
|
|
14
13
|
getPendingReview,
|
|
15
14
|
getReviewHistory,
|
|
16
15
|
getReviewQueue,
|
|
17
16
|
reject,
|
|
18
17
|
safePublishDocument,
|
|
19
18
|
submitForReview
|
|
20
|
-
} from "./chunk-
|
|
19
|
+
} from "./chunk-S2EWN2VA.js";
|
|
20
|
+
import {
|
|
21
|
+
checkConflict,
|
|
22
|
+
createVersion,
|
|
23
|
+
getApprovedVersion,
|
|
24
|
+
getCurrentVersion,
|
|
25
|
+
getDisplayStatus,
|
|
26
|
+
getVersions,
|
|
27
|
+
setApprovedVersion
|
|
28
|
+
} from "./chunk-7UTMBL6Z.js";
|
|
21
29
|
import {
|
|
22
|
-
|
|
30
|
+
AppError,
|
|
31
|
+
ConflictError,
|
|
32
|
+
ForbiddenError,
|
|
33
|
+
NotFoundError,
|
|
34
|
+
ValidationError,
|
|
35
|
+
canCreateInNest,
|
|
36
|
+
canManageStewards,
|
|
37
|
+
canManageWith,
|
|
23
38
|
canUserAccess,
|
|
24
39
|
canUserApprove,
|
|
25
40
|
canUserEdit,
|
|
41
|
+
createNest,
|
|
26
42
|
createStewardRecord,
|
|
43
|
+
deleteNest,
|
|
44
|
+
disableStewardshipAndWipeGovernance,
|
|
45
|
+
engineCache,
|
|
46
|
+
getCollaboratorRole,
|
|
47
|
+
getCurrentLicense,
|
|
48
|
+
getNest,
|
|
49
|
+
getStewardRolesForUser,
|
|
27
50
|
getStewardsForNest,
|
|
51
|
+
getStewardsForUser,
|
|
52
|
+
getSuspensionReason,
|
|
53
|
+
importNest,
|
|
54
|
+
installLicenseKey,
|
|
55
|
+
isLicenseAdminEmail,
|
|
56
|
+
isLicenseAdminUserId,
|
|
57
|
+
isPublicReader,
|
|
58
|
+
isStewardshipEnabled,
|
|
28
59
|
isSuperAdmin,
|
|
60
|
+
isSuspended,
|
|
61
|
+
listNests,
|
|
62
|
+
listPublicNests,
|
|
63
|
+
listSharedNests,
|
|
29
64
|
listStewards,
|
|
30
65
|
loadAccessConfig,
|
|
66
|
+
nestAllowsSelfApprove,
|
|
67
|
+
permissionLevel,
|
|
31
68
|
removeSteward,
|
|
69
|
+
resolveNestPermission,
|
|
32
70
|
resolveStewardsForNode,
|
|
33
71
|
resolveStewardsWithFallback,
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
} from "./chunk-JMZ75ZCD.js";
|
|
72
|
+
resolveUserRoles,
|
|
73
|
+
setAllowSelfApprove,
|
|
74
|
+
setStewardshipEnabled,
|
|
75
|
+
startLicenseSafetyPoll,
|
|
76
|
+
startTelemetryLoop,
|
|
77
|
+
syncFromConfig,
|
|
78
|
+
trackEvent,
|
|
79
|
+
updateSteward,
|
|
80
|
+
validateLicense
|
|
81
|
+
} from "./chunk-WCOUCBDJ.js";
|
|
45
82
|
import {
|
|
46
83
|
ANON_EMAIL,
|
|
47
84
|
ANON_USER_ID,
|
|
48
85
|
config,
|
|
49
86
|
getDb
|
|
50
|
-
} from "./chunk-
|
|
87
|
+
} from "./chunk-TDAX3JOT.js";
|
|
51
88
|
|
|
52
89
|
// src/index.ts
|
|
53
90
|
import { serve } from "@hono/node-server";
|
|
@@ -172,463 +209,9 @@ var authMiddleware = createMiddleware(async (c, next) => {
|
|
|
172
209
|
return c.json({ error: "Missing or invalid credentials" }, 401);
|
|
173
210
|
});
|
|
174
211
|
|
|
175
|
-
// src/shared/errors.ts
|
|
176
|
-
var AppError = class extends Error {
|
|
177
|
-
constructor(statusCode, message) {
|
|
178
|
-
super(message);
|
|
179
|
-
this.statusCode = statusCode;
|
|
180
|
-
this.name = "AppError";
|
|
181
|
-
}
|
|
182
|
-
statusCode;
|
|
183
|
-
};
|
|
184
|
-
var NotFoundError = class extends AppError {
|
|
185
|
-
constructor(message = "Not found") {
|
|
186
|
-
super(404, message);
|
|
187
|
-
this.name = "NotFoundError";
|
|
188
|
-
}
|
|
189
|
-
};
|
|
190
|
-
var ForbiddenError = class extends AppError {
|
|
191
|
-
constructor(message = "Forbidden") {
|
|
192
|
-
super(403, message);
|
|
193
|
-
this.name = "ForbiddenError";
|
|
194
|
-
}
|
|
195
|
-
};
|
|
196
|
-
var ValidationError = class extends AppError {
|
|
197
|
-
constructor(message) {
|
|
198
|
-
super(400, message);
|
|
199
|
-
this.name = "ValidationError";
|
|
200
|
-
}
|
|
201
|
-
};
|
|
202
|
-
var ConflictError = class extends AppError {
|
|
203
|
-
constructor(message) {
|
|
204
|
-
super(409, message);
|
|
205
|
-
this.name = "ConflictError";
|
|
206
|
-
}
|
|
207
|
-
};
|
|
208
|
-
|
|
209
|
-
// src/telemetry/tracker.ts
|
|
210
|
-
function trackEvent(event, data) {
|
|
211
|
-
if (!config.TELEMETRY_ENABLED) return;
|
|
212
|
-
try {
|
|
213
|
-
const db = getDb();
|
|
214
|
-
db.prepare(
|
|
215
|
-
"INSERT INTO telemetry_events (event, data_json) VALUES (?, ?)"
|
|
216
|
-
).run(event, data ? JSON.stringify(data) : null);
|
|
217
|
-
} catch {
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
async function flushTelemetry() {
|
|
221
|
-
if (!config.TELEMETRY_ENABLED || !config.PROMPTOWL_KEY) return;
|
|
222
|
-
const db = getDb();
|
|
223
|
-
const userCount = db.prepare("SELECT COUNT(*) as c FROM users").get()?.c || 0;
|
|
224
|
-
const nestCount = db.prepare("SELECT COUNT(*) as c FROM nests").get()?.c || 0;
|
|
225
|
-
const events = db.prepare(
|
|
226
|
-
"SELECT id, event, data_json, created_at FROM telemetry_events WHERE sent = 0 ORDER BY id LIMIT 100"
|
|
227
|
-
).all();
|
|
228
|
-
if (events.length === 0 && userCount === 0) return;
|
|
229
|
-
const payload = {
|
|
230
|
-
server_key: config.PROMPTOWL_KEY,
|
|
231
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
232
|
-
stats: { users: userCount, nests: nestCount },
|
|
233
|
-
events: events.map((e) => ({
|
|
234
|
-
event: e.event,
|
|
235
|
-
data: e.data_json ? JSON.parse(e.data_json) : null,
|
|
236
|
-
at: e.created_at
|
|
237
|
-
}))
|
|
238
|
-
};
|
|
239
|
-
try {
|
|
240
|
-
const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
|
|
241
|
-
const res = await fetch(`${promptowlUrl}/api/telemetry/ingest`, {
|
|
242
|
-
method: "POST",
|
|
243
|
-
headers: { "Content-Type": "application/json" },
|
|
244
|
-
body: JSON.stringify(payload)
|
|
245
|
-
});
|
|
246
|
-
if (res.ok && events.length > 0) {
|
|
247
|
-
const ids = events.map((e) => e.id);
|
|
248
|
-
db.prepare(
|
|
249
|
-
`UPDATE telemetry_events SET sent = 1 WHERE id IN (${ids.map(() => "?").join(",")})`
|
|
250
|
-
).run(...ids);
|
|
251
|
-
}
|
|
252
|
-
} catch {
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
var telemetryTimer = null;
|
|
256
|
-
function startTelemetryLoop() {
|
|
257
|
-
if (!config.TELEMETRY_ENABLED) return;
|
|
258
|
-
setTimeout(() => flushTelemetry(), 3e4);
|
|
259
|
-
telemetryTimer = setInterval(
|
|
260
|
-
() => flushTelemetry(),
|
|
261
|
-
config.TELEMETRY_INTERVAL_MS
|
|
262
|
-
);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// src/auth/license.ts
|
|
266
|
-
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
267
|
-
var currentLicense = null;
|
|
268
|
-
function getCurrentLicense() {
|
|
269
|
-
return currentLicense;
|
|
270
|
-
}
|
|
271
|
-
function isLicenseAdminEmail(email) {
|
|
272
|
-
if (!email) return false;
|
|
273
|
-
const lic = currentLicense;
|
|
274
|
-
if (!lic?.valid || !lic.ownerEmail) return false;
|
|
275
|
-
return lic.ownerEmail.toLowerCase() === email.toLowerCase();
|
|
276
|
-
}
|
|
277
|
-
function isLicenseAdminUserId(userId) {
|
|
278
|
-
try {
|
|
279
|
-
const row = getDb().prepare("SELECT email FROM users WHERE id = ?").get(userId);
|
|
280
|
-
return isLicenseAdminEmail(row?.email);
|
|
281
|
-
} catch {
|
|
282
|
-
return false;
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
function upsertEnvVar(filePath, varName, value) {
|
|
286
|
-
const prefix = `${varName}=`;
|
|
287
|
-
let lines = [];
|
|
288
|
-
if (existsSync(filePath)) {
|
|
289
|
-
lines = readFileSync(filePath, "utf8").split(/\r?\n/);
|
|
290
|
-
}
|
|
291
|
-
const filtered = lines.filter((line) => !line.trimStart().startsWith(prefix));
|
|
292
|
-
if (value !== null) {
|
|
293
|
-
filtered.push(`${prefix}${value}`);
|
|
294
|
-
}
|
|
295
|
-
while (filtered.length && filtered[filtered.length - 1] === "") {
|
|
296
|
-
filtered.pop();
|
|
297
|
-
}
|
|
298
|
-
writeFileSync(filePath, filtered.join("\n") + "\n", "utf8");
|
|
299
|
-
}
|
|
300
|
-
async function installLicenseKey(key) {
|
|
301
|
-
const trimmed = key.trim();
|
|
302
|
-
if (!trimmed.startsWith("pk_")) {
|
|
303
|
-
throw new Error("Invalid license key format. Must start with pk_.");
|
|
304
|
-
}
|
|
305
|
-
const previousKey = process.env.PROMPTOWL_KEY || "";
|
|
306
|
-
process.env.PROMPTOWL_KEY = trimmed;
|
|
307
|
-
const info = await validateLicense({ forceFresh: true });
|
|
308
|
-
if (!info.valid) {
|
|
309
|
-
process.env.PROMPTOWL_KEY = previousKey;
|
|
310
|
-
if (previousKey) {
|
|
311
|
-
await validateLicense({ forceFresh: true });
|
|
312
|
-
}
|
|
313
|
-
return info;
|
|
314
|
-
}
|
|
315
|
-
try {
|
|
316
|
-
upsertEnvVar(config.ENV_FILE_PATH, "PROMPTOWL_KEY", trimmed);
|
|
317
|
-
} catch (err) {
|
|
318
|
-
console.warn("[license] failed to write .env:", err);
|
|
319
|
-
}
|
|
320
|
-
startLicenseWatcher();
|
|
321
|
-
return info;
|
|
322
|
-
}
|
|
323
|
-
var watcherActive = false;
|
|
324
|
-
var watcherAbort = null;
|
|
325
|
-
var WATCHER_BACKOFF_MIN_MS = 2 * 1e3;
|
|
326
|
-
var WATCHER_BACKOFF_MAX_MS = 60 * 1e3;
|
|
327
|
-
function startLicenseWatcher() {
|
|
328
|
-
if (watcherActive) {
|
|
329
|
-
watcherAbort?.abort();
|
|
330
|
-
return;
|
|
331
|
-
}
|
|
332
|
-
watcherActive = true;
|
|
333
|
-
void runLicenseWatcher();
|
|
334
|
-
}
|
|
335
|
-
async function runLicenseWatcher() {
|
|
336
|
-
let backoff = WATCHER_BACKOFF_MIN_MS;
|
|
337
|
-
while (watcherActive) {
|
|
338
|
-
const key = config.PROMPTOWL_KEY;
|
|
339
|
-
if (!key) {
|
|
340
|
-
await sleep(WATCHER_BACKOFF_MAX_MS);
|
|
341
|
-
continue;
|
|
342
|
-
}
|
|
343
|
-
try {
|
|
344
|
-
watcherAbort = new AbortController();
|
|
345
|
-
const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
|
|
346
|
-
const fetchTimeout = AbortSignal.timeout(30 * 1e3);
|
|
347
|
-
const signal = typeof AbortSignal.any === "function" ? AbortSignal.any([watcherAbort.signal, fetchTimeout]) : watcherAbort.signal;
|
|
348
|
-
const res = await fetch(`${promptowlUrl}/api/license/listen`, {
|
|
349
|
-
method: "POST",
|
|
350
|
-
headers: { "Content-Type": "application/json" },
|
|
351
|
-
body: JSON.stringify({
|
|
352
|
-
key,
|
|
353
|
-
since_updated_at: currentLicense ? (/* @__PURE__ */ new Date()).toISOString() : void 0
|
|
354
|
-
}),
|
|
355
|
-
signal
|
|
356
|
-
});
|
|
357
|
-
if (!res.ok) {
|
|
358
|
-
if (res.status === 504 || res.status === 408 || res.status === 502) {
|
|
359
|
-
backoff = WATCHER_BACKOFF_MIN_MS;
|
|
360
|
-
continue;
|
|
361
|
-
}
|
|
362
|
-
throw new Error(`listen returned ${res.status}`);
|
|
363
|
-
}
|
|
364
|
-
const data = await res.json();
|
|
365
|
-
backoff = WATCHER_BACKOFF_MIN_MS;
|
|
366
|
-
if (data.event && data.event !== "no_change") {
|
|
367
|
-
console.log(
|
|
368
|
-
`[license] event from PromptOwl: ${data.event} \u2014 revalidating`
|
|
369
|
-
);
|
|
370
|
-
const wasValid = !!currentLicense?.valid;
|
|
371
|
-
await validateLicense({ forceFresh: true });
|
|
372
|
-
const isValid = !!currentLicense?.valid;
|
|
373
|
-
if (wasValid && !isValid) {
|
|
374
|
-
handleLicenseRevoked();
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
} catch (err) {
|
|
378
|
-
if (err.name === "AbortError") {
|
|
379
|
-
continue;
|
|
380
|
-
}
|
|
381
|
-
console.warn(
|
|
382
|
-
`[license] watcher error: ${err.message}; backing off ${backoff}ms`
|
|
383
|
-
);
|
|
384
|
-
await sleep(backoff);
|
|
385
|
-
backoff = Math.min(backoff * 2, WATCHER_BACKOFF_MAX_MS);
|
|
386
|
-
} finally {
|
|
387
|
-
watcherAbort = null;
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
function sleep(ms) {
|
|
392
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
393
|
-
}
|
|
394
|
-
var safetyPollHandle = null;
|
|
395
|
-
var SAFETY_POLL_INTERVAL_MS = 60 * 1e3;
|
|
396
|
-
function startLicenseSafetyPoll() {
|
|
397
|
-
if (safetyPollHandle) return;
|
|
398
|
-
safetyPollHandle = setInterval(async () => {
|
|
399
|
-
if (!config.PROMPTOWL_KEY) return;
|
|
400
|
-
try {
|
|
401
|
-
const wasValid = !!currentLicense?.valid;
|
|
402
|
-
await validateLicense({ forceFresh: true });
|
|
403
|
-
const isValid = !!currentLicense?.valid;
|
|
404
|
-
if (wasValid && !isValid) {
|
|
405
|
-
console.log("[license] safety poll detected revocation");
|
|
406
|
-
handleLicenseRevoked();
|
|
407
|
-
}
|
|
408
|
-
} catch (err) {
|
|
409
|
-
console.warn(
|
|
410
|
-
`[license] safety poll error: ${err.message}`
|
|
411
|
-
);
|
|
412
|
-
}
|
|
413
|
-
}, SAFETY_POLL_INTERVAL_MS);
|
|
414
|
-
if (typeof safetyPollHandle.unref === "function") {
|
|
415
|
-
safetyPollHandle.unref();
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
function handleLicenseRevoked() {
|
|
419
|
-
try {
|
|
420
|
-
const db = getDb();
|
|
421
|
-
const result = db.prepare("DELETE FROM sessions").run();
|
|
422
|
-
console.warn(
|
|
423
|
-
`[license] revoked \u2014 wiped ${result.changes} active session(s).`
|
|
424
|
-
);
|
|
425
|
-
} catch (err) {
|
|
426
|
-
console.warn("[license] failed to wipe sessions:", err);
|
|
427
|
-
}
|
|
428
|
-
try {
|
|
429
|
-
upsertEnvVar(config.ENV_FILE_PATH, "PROMPTOWL_KEY", null);
|
|
430
|
-
console.warn(
|
|
431
|
-
`[license] revoked \u2014 removed PROMPTOWL_KEY from ${config.ENV_FILE_PATH}`
|
|
432
|
-
);
|
|
433
|
-
} catch (err) {
|
|
434
|
-
console.warn("[license] failed to strip key from .env:", err);
|
|
435
|
-
}
|
|
436
|
-
process.env.PROMPTOWL_KEY = "";
|
|
437
|
-
currentLicense = {
|
|
438
|
-
valid: false,
|
|
439
|
-
tier: "none",
|
|
440
|
-
org: null,
|
|
441
|
-
limits: null,
|
|
442
|
-
suspended: false,
|
|
443
|
-
suspendedReason: null,
|
|
444
|
-
ownerEmail: null
|
|
445
|
-
};
|
|
446
|
-
}
|
|
447
|
-
var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
448
|
-
var suspensionFirstSeen = null;
|
|
449
|
-
var suspensionConfirmed = false;
|
|
450
|
-
var suspensionReason = null;
|
|
451
|
-
var SUSPENSION_CONFIRM_WINDOW_MS = 60 * 60 * 1e3;
|
|
452
|
-
function isSuspended() {
|
|
453
|
-
return suspensionConfirmed;
|
|
454
|
-
}
|
|
455
|
-
function getSuspensionReason() {
|
|
456
|
-
return suspensionReason;
|
|
457
|
-
}
|
|
458
|
-
async function validateLicense(opts = {}) {
|
|
459
|
-
const info = await _validateLicenseImpl(!!opts.forceFresh);
|
|
460
|
-
currentLicense = info;
|
|
461
|
-
return info;
|
|
462
|
-
}
|
|
463
|
-
async function _validateLicenseImpl(forceFresh) {
|
|
464
|
-
const key = config.PROMPTOWL_KEY;
|
|
465
|
-
if (!key) {
|
|
466
|
-
return {
|
|
467
|
-
valid: false,
|
|
468
|
-
tier: "none",
|
|
469
|
-
org: null,
|
|
470
|
-
limits: null,
|
|
471
|
-
suspended: false,
|
|
472
|
-
suspendedReason: null,
|
|
473
|
-
ownerEmail: null
|
|
474
|
-
};
|
|
475
|
-
}
|
|
476
|
-
const db = getDb();
|
|
477
|
-
const cached = db.prepare("SELECT * FROM license_cache WHERE key = ?").get(key);
|
|
478
|
-
if (cached && !forceFresh) {
|
|
479
|
-
const age = Date.now() - (/* @__PURE__ */ new Date(cached.validated_at + "Z")).getTime();
|
|
480
|
-
if (age < CACHE_TTL_MS) {
|
|
481
|
-
return {
|
|
482
|
-
valid: true,
|
|
483
|
-
tier: cached.tier,
|
|
484
|
-
org: cached.org,
|
|
485
|
-
limits: cached.limits_json ? JSON.parse(cached.limits_json) : null,
|
|
486
|
-
suspended: suspensionConfirmed,
|
|
487
|
-
suspendedReason: suspensionReason,
|
|
488
|
-
ownerEmail: cached.owner_email || null
|
|
489
|
-
};
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
try {
|
|
493
|
-
const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
|
|
494
|
-
const res = await fetch(`${promptowlUrl}/api/license/validate`, {
|
|
495
|
-
method: "POST",
|
|
496
|
-
headers: { "Content-Type": "application/json" },
|
|
497
|
-
body: JSON.stringify({ key })
|
|
498
|
-
});
|
|
499
|
-
if (!res.ok) {
|
|
500
|
-
if (cached) {
|
|
501
|
-
console.warn(
|
|
502
|
-
" PromptOwl unreachable, using cached license (grace period)"
|
|
503
|
-
);
|
|
504
|
-
return {
|
|
505
|
-
valid: true,
|
|
506
|
-
tier: cached.tier,
|
|
507
|
-
org: cached.org,
|
|
508
|
-
limits: cached.limits_json ? JSON.parse(cached.limits_json) : null,
|
|
509
|
-
suspended: suspensionConfirmed,
|
|
510
|
-
suspendedReason: suspensionReason,
|
|
511
|
-
ownerEmail: cached.owner_email || null
|
|
512
|
-
};
|
|
513
|
-
}
|
|
514
|
-
return {
|
|
515
|
-
valid: false,
|
|
516
|
-
tier: "none",
|
|
517
|
-
org: null,
|
|
518
|
-
limits: null,
|
|
519
|
-
suspended: false,
|
|
520
|
-
suspendedReason: null,
|
|
521
|
-
ownerEmail: null
|
|
522
|
-
};
|
|
523
|
-
}
|
|
524
|
-
const data = await res.json();
|
|
525
|
-
if (data.suspended === true) {
|
|
526
|
-
if (!suspensionFirstSeen) {
|
|
527
|
-
suspensionFirstSeen = Date.now();
|
|
528
|
-
suspensionReason = data.suspended_reason || "Suspended by PromptOwl";
|
|
529
|
-
console.warn(
|
|
530
|
-
`
|
|
531
|
-
WARNING: PromptOwl has flagged this server for suspension.`
|
|
532
|
-
);
|
|
533
|
-
console.warn(
|
|
534
|
-
` Reason: ${suspensionReason}`
|
|
535
|
-
);
|
|
536
|
-
console.warn(
|
|
537
|
-
` This will be confirmed in ~1 hour. If this is an error,`
|
|
538
|
-
);
|
|
539
|
-
console.warn(
|
|
540
|
-
` contact support@promptowl.ai to reverse it.
|
|
541
|
-
`
|
|
542
|
-
);
|
|
543
|
-
} else if (Date.now() - suspensionFirstSeen >= SUSPENSION_CONFIRM_WINDOW_MS) {
|
|
544
|
-
suspensionConfirmed = true;
|
|
545
|
-
suspensionReason = data.suspended_reason || "Suspended by PromptOwl";
|
|
546
|
-
console.error(
|
|
547
|
-
`
|
|
548
|
-
SERVER SUSPENDED: ${suspensionReason}`
|
|
549
|
-
);
|
|
550
|
-
console.error(
|
|
551
|
-
` Write operations are disabled. Reads still work.`
|
|
552
|
-
);
|
|
553
|
-
console.error(
|
|
554
|
-
` Contact support@promptowl.ai to resolve.
|
|
555
|
-
`
|
|
556
|
-
);
|
|
557
|
-
}
|
|
558
|
-
} else {
|
|
559
|
-
if (suspensionFirstSeen) {
|
|
560
|
-
console.log(" Suspension flag cleared by PromptOwl.");
|
|
561
|
-
}
|
|
562
|
-
suspensionFirstSeen = null;
|
|
563
|
-
suspensionConfirmed = false;
|
|
564
|
-
suspensionReason = null;
|
|
565
|
-
}
|
|
566
|
-
if (!data.valid && !data.suspended) {
|
|
567
|
-
db.prepare("DELETE FROM license_cache WHERE key = ?").run(key);
|
|
568
|
-
return {
|
|
569
|
-
valid: false,
|
|
570
|
-
tier: "none",
|
|
571
|
-
org: null,
|
|
572
|
-
limits: null,
|
|
573
|
-
suspended: false,
|
|
574
|
-
suspendedReason: null,
|
|
575
|
-
ownerEmail: data.owner_email || null
|
|
576
|
-
};
|
|
577
|
-
}
|
|
578
|
-
if (data.valid) {
|
|
579
|
-
const limitsJson = data.limits ? JSON.stringify(data.limits) : null;
|
|
580
|
-
db.prepare(
|
|
581
|
-
`INSERT OR REPLACE INTO license_cache (key, tier, org, limits_json, owner_email, validated_at)
|
|
582
|
-
VALUES (?, ?, ?, ?, ?, datetime('now'))`
|
|
583
|
-
).run(
|
|
584
|
-
key,
|
|
585
|
-
data.tier || "community",
|
|
586
|
-
data.org || null,
|
|
587
|
-
limitsJson,
|
|
588
|
-
data.owner_email || null
|
|
589
|
-
);
|
|
590
|
-
}
|
|
591
|
-
return {
|
|
592
|
-
valid: data.valid !== false,
|
|
593
|
-
tier: data.tier || "community",
|
|
594
|
-
org: data.org || null,
|
|
595
|
-
limits: data.limits || null,
|
|
596
|
-
suspended: suspensionConfirmed,
|
|
597
|
-
suspendedReason: suspensionReason,
|
|
598
|
-
ownerEmail: data.owner_email || null
|
|
599
|
-
};
|
|
600
|
-
} catch (err) {
|
|
601
|
-
if (cached) {
|
|
602
|
-
console.warn(
|
|
603
|
-
` PromptOwl validation failed (${err.message}), using cached license`
|
|
604
|
-
);
|
|
605
|
-
return {
|
|
606
|
-
valid: true,
|
|
607
|
-
tier: cached.tier,
|
|
608
|
-
org: cached.org,
|
|
609
|
-
limits: cached.limits_json ? JSON.parse(cached.limits_json) : null,
|
|
610
|
-
suspended: suspensionConfirmed,
|
|
611
|
-
suspendedReason: suspensionReason,
|
|
612
|
-
ownerEmail: cached.owner_email || null
|
|
613
|
-
};
|
|
614
|
-
}
|
|
615
|
-
return {
|
|
616
|
-
valid: false,
|
|
617
|
-
tier: "none",
|
|
618
|
-
org: null,
|
|
619
|
-
limits: null,
|
|
620
|
-
suspended: false,
|
|
621
|
-
suspendedReason: null,
|
|
622
|
-
ownerEmail: null
|
|
623
|
-
};
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
|
|
627
212
|
// src/shared/rate-limit.ts
|
|
628
213
|
var buckets = /* @__PURE__ */ new Map();
|
|
629
|
-
function
|
|
630
|
-
const now = Date.now();
|
|
631
|
-
const cutoff = now - cfg.windowMs;
|
|
214
|
+
function liveBucket(key, cutoff) {
|
|
632
215
|
let bucket = buckets.get(key);
|
|
633
216
|
if (!bucket) {
|
|
634
217
|
bucket = { hits: [] };
|
|
@@ -637,14 +220,29 @@ function tryConsume(key, cfg) {
|
|
|
637
220
|
while (bucket.hits.length && bucket.hits[0] < cutoff) {
|
|
638
221
|
bucket.hits.shift();
|
|
639
222
|
}
|
|
223
|
+
return bucket;
|
|
224
|
+
}
|
|
225
|
+
function tryConsume(key, cfg) {
|
|
226
|
+
const now = Date.now();
|
|
227
|
+
const bucket = liveBucket(key, now - cfg.windowMs);
|
|
640
228
|
if (bucket.hits.length >= cfg.max) {
|
|
641
229
|
return false;
|
|
642
230
|
}
|
|
643
231
|
bucket.hits.push(now);
|
|
644
232
|
return true;
|
|
645
233
|
}
|
|
234
|
+
function isLimited(key, cfg) {
|
|
235
|
+
return liveBucket(key, Date.now() - cfg.windowMs).hits.length >= cfg.max;
|
|
236
|
+
}
|
|
237
|
+
function recordFailure(key, cfg) {
|
|
238
|
+
liveBucket(key, Date.now() - cfg.windowMs).hits.push(Date.now());
|
|
239
|
+
}
|
|
240
|
+
function clear(key) {
|
|
241
|
+
buckets.delete(key);
|
|
242
|
+
}
|
|
646
243
|
|
|
647
244
|
// src/auth/routes.ts
|
|
245
|
+
import { getConnInfo } from "@hono/node-server/conninfo";
|
|
648
246
|
var LOGIN_LIMIT = { max: 5, windowMs: 15 * 6e4 };
|
|
649
247
|
var REGISTER_LIMIT = { max: 3, windowMs: 60 * 6e4 };
|
|
650
248
|
var DEVICE_LIMIT = { max: 10, windowMs: 15 * 6e4 };
|
|
@@ -653,6 +251,11 @@ function clientIp(c) {
|
|
|
653
251
|
if (xff) return xff.split(",")[0].trim();
|
|
654
252
|
const realIp = c.req.header("x-real-ip");
|
|
655
253
|
if (realIp) return realIp.trim();
|
|
254
|
+
try {
|
|
255
|
+
const addr = getConnInfo(c).remote.address;
|
|
256
|
+
if (addr) return addr;
|
|
257
|
+
} catch {
|
|
258
|
+
}
|
|
656
259
|
return "unknown";
|
|
657
260
|
}
|
|
658
261
|
function resolveCallerUserId(c) {
|
|
@@ -731,7 +334,10 @@ authRoutes.post("/login", async (c) => {
|
|
|
731
334
|
}
|
|
732
335
|
const ip = clientIp(c);
|
|
733
336
|
const emailLower = body.email.toLowerCase();
|
|
734
|
-
|
|
337
|
+
const hasIp = ip !== "unknown";
|
|
338
|
+
const ipKey = `login:ip:${ip}`;
|
|
339
|
+
const emailKey = `login:email:${emailLower}`;
|
|
340
|
+
if (hasIp && isLimited(ipKey, LOGIN_LIMIT) || isLimited(emailKey, LOGIN_LIMIT)) {
|
|
735
341
|
return c.json({ error: "Too many login attempts, try again later" }, 429);
|
|
736
342
|
}
|
|
737
343
|
const db = getDb();
|
|
@@ -740,8 +346,14 @@ authRoutes.post("/login", async (c) => {
|
|
|
740
346
|
).get(body.email);
|
|
741
347
|
const check = user ? await verifyPassword(body.password, user.password_hash) : { ok: false, needsRehash: false };
|
|
742
348
|
if (!user || !check.ok) {
|
|
349
|
+
if (hasIp) recordFailure(ipKey, LOGIN_LIMIT);
|
|
350
|
+
recordFailure(emailKey, LOGIN_LIMIT);
|
|
351
|
+
console.warn(`[auth] login FAIL \u2014 counter++ ip=${ip} email=${emailLower}`);
|
|
743
352
|
return c.json({ error: "Invalid credentials" }, 401);
|
|
744
353
|
}
|
|
354
|
+
console.log(`[auth] login OK \u2014 counter reset ip=${ip} email=${emailLower}`);
|
|
355
|
+
if (hasIp) clear(ipKey);
|
|
356
|
+
clear(emailKey);
|
|
745
357
|
if (check.needsRehash) {
|
|
746
358
|
try {
|
|
747
359
|
const newHash = await hashPassword(body.password);
|
|
@@ -1079,8 +691,13 @@ authRoutes.get("/teammates", async (c) => {
|
|
|
1079
691
|
WHERE s.is_active = 1
|
|
1080
692
|
AND NOT EXISTS (
|
|
1081
693
|
SELECT 1 FROM users u
|
|
1082
|
-
JOIN api_keys k ON k.user_id = u.id
|
|
1083
694
|
WHERE lower(u.email) = lower(s.user_email)
|
|
695
|
+
AND (
|
|
696
|
+
u.is_invited = 0
|
|
697
|
+
OR EXISTS (
|
|
698
|
+
SELECT 1 FROM api_keys k WHERE k.user_id = u.id
|
|
699
|
+
)
|
|
700
|
+
)
|
|
1084
701
|
)
|
|
1085
702
|
ORDER BY s.user_email`
|
|
1086
703
|
).all();
|
|
@@ -1094,392 +711,116 @@ authRoutes.get("/teammates", async (c) => {
|
|
|
1094
711
|
// src/nests/routes.ts
|
|
1095
712
|
import { Hono as Hono2 } from "hono";
|
|
1096
713
|
|
|
1097
|
-
// src/
|
|
1098
|
-
|
|
1099
|
-
none: 0,
|
|
1100
|
-
read: 1,
|
|
1101
|
-
write: 2,
|
|
1102
|
-
admin: 3,
|
|
1103
|
-
owner: 4
|
|
1104
|
-
};
|
|
1105
|
-
function resolveNestPermission(nestId, userId) {
|
|
1106
|
-
const db = getDb();
|
|
1107
|
-
const nest = db.prepare("SELECT user_id, visibility FROM nests WHERE id = ?").get(nestId);
|
|
1108
|
-
if (!nest) return "none";
|
|
1109
|
-
if (nest.user_id === userId) return "owner";
|
|
1110
|
-
if (nest.user_id === ANON_USER_ID && isLicenseAdminUserId(userId)) {
|
|
1111
|
-
return "owner";
|
|
1112
|
-
}
|
|
1113
|
-
const directGrant = db.prepare(
|
|
1114
|
-
"SELECT permission FROM nest_collaborators WHERE nest_id = ? AND user_id = ?"
|
|
1115
|
-
).get(nestId, userId);
|
|
1116
|
-
if (directGrant) return directGrant.permission;
|
|
1117
|
-
if (nest.visibility === "public") return "read";
|
|
1118
|
-
return "none";
|
|
1119
|
-
}
|
|
1120
|
-
function permissionLevel(p) {
|
|
1121
|
-
return PERMISSION_LEVELS[p] ?? 0;
|
|
1122
|
-
}
|
|
714
|
+
// src/nodes/service.ts
|
|
715
|
+
import { serializeDocument, parseDocument as parseDocument2 } from "@promptowl/contextnest-engine";
|
|
1123
716
|
|
|
1124
|
-
// src/
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
import { v4 as uuid2 } from "uuid";
|
|
1128
|
-
import { NestStorage } from "@promptowl/contextnest-engine";
|
|
1129
|
-
function nestPath(nestId) {
|
|
1130
|
-
return join(config.DATA_ROOT, "nests", nestId);
|
|
1131
|
-
}
|
|
1132
|
-
function toSlug(name) {
|
|
1133
|
-
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1134
|
-
}
|
|
1135
|
-
function isStewardshipEnabled(nestId) {
|
|
1136
|
-
const db = getDb();
|
|
1137
|
-
const row = db.prepare("SELECT stewardship_enabled FROM nests WHERE id = ?").get(nestId);
|
|
1138
|
-
return !!row?.stewardship_enabled;
|
|
717
|
+
// src/governance/tag-index-service.ts
|
|
718
|
+
function normalizeTag(raw) {
|
|
719
|
+
return raw.trim().replace(/^#+/, "").toLowerCase();
|
|
1139
720
|
}
|
|
1140
|
-
function
|
|
721
|
+
function syncNodeTags(nestId, nodeId, tags) {
|
|
1141
722
|
const db = getDb();
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
723
|
+
const normalized = Array.from(
|
|
724
|
+
new Set(
|
|
725
|
+
tags.filter((t) => typeof t === "string").map(normalizeTag).filter(Boolean)
|
|
726
|
+
)
|
|
1145
727
|
);
|
|
728
|
+
db.transaction(() => {
|
|
729
|
+
db.prepare(
|
|
730
|
+
"DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?"
|
|
731
|
+
).run(nestId, nodeId);
|
|
732
|
+
const insert = db.prepare(
|
|
733
|
+
"INSERT OR IGNORE INTO node_tag_index (nest_id, node_id, tag_name) VALUES (?, ?, ?)"
|
|
734
|
+
);
|
|
735
|
+
for (const tag of normalized) {
|
|
736
|
+
insert.run(nestId, nodeId, tag);
|
|
737
|
+
}
|
|
738
|
+
})();
|
|
1146
739
|
}
|
|
1147
|
-
|
|
1148
|
-
const id = uuid2();
|
|
1149
|
-
const slug = toSlug(name);
|
|
740
|
+
function removeNodeFromTagIndex(nestId, nodeId) {
|
|
1150
741
|
const db = getDb();
|
|
1151
|
-
const visibility = userId === ANON_USER_ID ? "public" : "private";
|
|
1152
742
|
db.prepare(
|
|
1153
|
-
"
|
|
1154
|
-
).run(
|
|
1155
|
-
const path = nestPath(id);
|
|
1156
|
-
mkdirSync(path, { recursive: true });
|
|
1157
|
-
const storage = new NestStorage(path);
|
|
1158
|
-
await storage.init(name);
|
|
1159
|
-
trackEvent("nest.create", { nestId: id, userId });
|
|
1160
|
-
return db.prepare("SELECT * FROM nests WHERE id = ?").get(id);
|
|
743
|
+
"DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?"
|
|
744
|
+
).run(nestId, nodeId);
|
|
1161
745
|
}
|
|
1162
|
-
|
|
746
|
+
|
|
747
|
+
// src/governance/access-guard.ts
|
|
748
|
+
function resolveCallerEmail(userId) {
|
|
749
|
+
if (!userId) return "admin@localhost";
|
|
1163
750
|
const db = getDb();
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
if (
|
|
1169
|
-
return
|
|
751
|
+
const row = db.prepare("SELECT email FROM users WHERE id = ?").get(userId);
|
|
752
|
+
return row?.email || "admin@localhost";
|
|
753
|
+
}
|
|
754
|
+
function canReadNode(nestId, nodeId, userId, userEmail) {
|
|
755
|
+
if (isPublicReader(nestId, userId)) {
|
|
756
|
+
return getApprovedVersion(nestId, nodeId) !== null;
|
|
1170
757
|
}
|
|
1171
|
-
return
|
|
1172
|
-
|
|
1173
|
-
).all(userId, ANON_USER_ID);
|
|
758
|
+
if (!isStewardshipEnabled(nestId)) return true;
|
|
759
|
+
return canUserAccess(nestId, nodeId, userEmail).allowed;
|
|
1174
760
|
}
|
|
1175
|
-
function
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
ORDER BY n.created_at DESC`
|
|
1182
|
-
).all(userId, userId);
|
|
761
|
+
function filterAccessible(nestId, userId, userEmail, nodes) {
|
|
762
|
+
if (isPublicReader(nestId, userId)) {
|
|
763
|
+
return nodes.filter((n) => getApprovedVersion(nestId, n.id) !== null);
|
|
764
|
+
}
|
|
765
|
+
if (!isStewardshipEnabled(nestId)) return nodes;
|
|
766
|
+
return nodes.filter((n) => canUserAccess(nestId, n.id, userEmail).allowed);
|
|
1183
767
|
}
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
768
|
+
|
|
769
|
+
// src/governance/external-edit-service.ts
|
|
770
|
+
import { readFile } from "fs/promises";
|
|
771
|
+
import { join } from "path";
|
|
772
|
+
import {
|
|
773
|
+
detectDrift,
|
|
774
|
+
stageSuggestion,
|
|
775
|
+
approveSuggestion,
|
|
776
|
+
rejectSuggestion,
|
|
777
|
+
listSuggestions,
|
|
778
|
+
readSuggestion,
|
|
779
|
+
parseDocument,
|
|
780
|
+
VersionManager,
|
|
781
|
+
computeContentHash,
|
|
782
|
+
getChecksumContent
|
|
783
|
+
} from "@promptowl/contextnest-engine";
|
|
784
|
+
var communityRbac = {
|
|
785
|
+
isCzar: () => false,
|
|
786
|
+
canIngest: () => true,
|
|
787
|
+
isDocOwner: () => true
|
|
788
|
+
};
|
|
789
|
+
function docPath(nestId, documentId) {
|
|
790
|
+
return join(config.DATA_ROOT, "nests", nestId, `${documentId}.md`);
|
|
1187
791
|
}
|
|
1188
|
-
async function
|
|
1189
|
-
const db = getDb();
|
|
1190
|
-
const wipe = db.transaction((id) => {
|
|
1191
|
-
db.prepare("DELETE FROM approved_versions WHERE nest_id = ?").run(id);
|
|
1192
|
-
db.prepare("DELETE FROM node_versions WHERE nest_id = ?").run(id);
|
|
1193
|
-
db.prepare("DELETE FROM review_requests WHERE nest_id = ?").run(id);
|
|
1194
|
-
db.prepare("DELETE FROM stewards WHERE nest_id = ?").run(id);
|
|
1195
|
-
db.prepare("DELETE FROM nest_collaborators WHERE nest_id = ?").run(id);
|
|
1196
|
-
try {
|
|
1197
|
-
db.prepare("DELETE FROM node_tag_index WHERE nest_id = ?").run(id);
|
|
1198
|
-
} catch {
|
|
1199
|
-
}
|
|
1200
|
-
db.prepare("DELETE FROM api_keys WHERE nest_id = ?").run(id);
|
|
1201
|
-
db.prepare("DELETE FROM nests WHERE id = ?").run(id);
|
|
1202
|
-
});
|
|
1203
|
-
wipe(nestId);
|
|
1204
|
-
const path = nestPath(nestId);
|
|
792
|
+
async function readRaw(nestId, documentId) {
|
|
1205
793
|
try {
|
|
1206
|
-
|
|
1207
|
-
} catch
|
|
1208
|
-
|
|
794
|
+
return await readFile(docPath(nestId, documentId), "utf-8");
|
|
795
|
+
} catch {
|
|
796
|
+
return null;
|
|
1209
797
|
}
|
|
1210
|
-
trackEvent("nest.delete", { nestId });
|
|
1211
798
|
}
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
const
|
|
1218
|
-
|
|
799
|
+
async function loadChainHead(storage, documentId) {
|
|
800
|
+
const history = await storage.readHistory(documentId);
|
|
801
|
+
if (!history || history.versions.length === 0) return null;
|
|
802
|
+
const latest = history.versions[history.versions.length - 1];
|
|
803
|
+
try {
|
|
804
|
+
const content = await new VersionManager(storage).reconstructVersion(
|
|
805
|
+
documentId,
|
|
806
|
+
latest.version
|
|
807
|
+
);
|
|
808
|
+
return { version: latest.version, content };
|
|
809
|
+
} catch {
|
|
810
|
+
return null;
|
|
1219
811
|
}
|
|
1220
|
-
return resolveNestPermission(nestId, userId);
|
|
1221
|
-
}
|
|
1222
|
-
var nestRoutes = new Hono2();
|
|
1223
|
-
nestRoutes.get("/", async (c) => {
|
|
1224
|
-
const userId = c.get("userId");
|
|
1225
|
-
const owned = listNests(userId);
|
|
1226
|
-
const shared = listSharedNests(userId);
|
|
1227
|
-
const db = getDb();
|
|
1228
|
-
const ownerEmailStmt = db.prepare(
|
|
1229
|
-
"SELECT email FROM users WHERE id = ?"
|
|
1230
|
-
);
|
|
1231
|
-
const annotate = (n) => {
|
|
1232
|
-
const permission = effectivePermission(n.id, userId);
|
|
1233
|
-
const is_owner = permission === "owner";
|
|
1234
|
-
let owner_email = null;
|
|
1235
|
-
if (!is_owner && n.user_id !== ANON_USER_ID) {
|
|
1236
|
-
const row = ownerEmailStmt.get(n.user_id);
|
|
1237
|
-
owner_email = row?.email ?? null;
|
|
1238
|
-
}
|
|
1239
|
-
return { ...n, permission, is_owner, owner_email };
|
|
1240
|
-
};
|
|
1241
|
-
return c.json({
|
|
1242
|
-
nests: [...owned.map(annotate), ...shared.map(annotate)]
|
|
1243
|
-
});
|
|
1244
|
-
});
|
|
1245
|
-
nestRoutes.post("/", async (c) => {
|
|
1246
|
-
const body = await c.req.json();
|
|
1247
|
-
if (!body.name) {
|
|
1248
|
-
throw new ValidationError("name is required");
|
|
1249
|
-
}
|
|
1250
|
-
const nest = await createNest(c.get("userId"), body.name, body.description);
|
|
1251
|
-
return c.json({ nest }, 201);
|
|
1252
|
-
});
|
|
1253
|
-
nestRoutes.get("/:nestId", async (c) => {
|
|
1254
|
-
const nestId = c.req.param("nestId");
|
|
1255
|
-
const permission = effectivePermission(nestId, c.get("userId"));
|
|
1256
|
-
if (permission === "none") {
|
|
1257
|
-
throw new NotFoundError("Nest not found");
|
|
1258
|
-
}
|
|
1259
|
-
const nest = getNest(nestId);
|
|
1260
|
-
return c.json({ nest, permission });
|
|
1261
|
-
});
|
|
1262
|
-
nestRoutes.delete("/:nestId", async (c) => {
|
|
1263
|
-
const nestId = c.req.param("nestId");
|
|
1264
|
-
const userId = c.get("userId");
|
|
1265
|
-
const nest = getNest(nestId);
|
|
1266
|
-
if (!nest) {
|
|
1267
|
-
throw new NotFoundError("Nest not found");
|
|
1268
|
-
}
|
|
1269
|
-
const permission = effectivePermission(nestId, userId);
|
|
1270
|
-
const isAnonOwned = nest.user_id === ANON_USER_ID;
|
|
1271
|
-
const adminCaretaker = config.AUTH_MODE !== "open" && isAnonOwned && isLicenseAdminUserId(userId);
|
|
1272
|
-
if (permission !== "owner" && !adminCaretaker) {
|
|
1273
|
-
throw new ForbiddenError(
|
|
1274
|
-
"You don't have permission to delete this nest. Only the nest owner can delete it."
|
|
1275
|
-
);
|
|
1276
|
-
}
|
|
1277
|
-
await deleteNest(nestId);
|
|
1278
|
-
return c.json({ deleted: true });
|
|
1279
|
-
});
|
|
1280
|
-
nestRoutes.get("/:nestId/settings", async (c) => {
|
|
1281
|
-
const nestId = c.req.param("nestId");
|
|
1282
|
-
const permission = effectivePermission(nestId, c.get("userId"));
|
|
1283
|
-
if (permission === "none") {
|
|
1284
|
-
throw new NotFoundError("Nest not found");
|
|
1285
|
-
}
|
|
1286
|
-
return c.json({
|
|
1287
|
-
stewardship_enabled: isStewardshipEnabled(nestId)
|
|
1288
|
-
});
|
|
1289
|
-
});
|
|
1290
|
-
nestRoutes.patch("/:nestId/settings", async (c) => {
|
|
1291
|
-
const nestId = c.req.param("nestId");
|
|
1292
|
-
const userId = c.get("userId");
|
|
1293
|
-
const isServerAdmin = isLicenseAdminUserId(userId);
|
|
1294
|
-
const permission = effectivePermission(nestId, userId);
|
|
1295
|
-
if (!isServerAdmin && permission !== "owner") {
|
|
1296
|
-
return c.json(
|
|
1297
|
-
{
|
|
1298
|
-
error: "Only the nest owner or the server license-admin can update nest settings."
|
|
1299
|
-
},
|
|
1300
|
-
403
|
|
1301
|
-
);
|
|
1302
|
-
}
|
|
1303
|
-
const body = await c.req.json();
|
|
1304
|
-
if (typeof body.stewardship_enabled === "boolean") {
|
|
1305
|
-
setStewardshipEnabled(nestId, body.stewardship_enabled);
|
|
1306
|
-
}
|
|
1307
|
-
return c.json({
|
|
1308
|
-
stewardship_enabled: isStewardshipEnabled(nestId)
|
|
1309
|
-
});
|
|
1310
|
-
});
|
|
1311
|
-
|
|
1312
|
-
// src/nests/sharing-routes.ts
|
|
1313
|
-
import { Hono as Hono3 } from "hono";
|
|
1314
|
-
import { v4 as uuid3 } from "uuid";
|
|
1315
|
-
var sharingRoutes = new Hono3();
|
|
1316
|
-
sharingRoutes.get("/collaborators", async (c) => {
|
|
1317
|
-
const db = getDb();
|
|
1318
|
-
const collabs = db.prepare(
|
|
1319
|
-
`SELECT nc.*, u.email FROM nest_collaborators nc
|
|
1320
|
-
LEFT JOIN users u ON nc.user_id = u.id
|
|
1321
|
-
WHERE nc.nest_id = ?
|
|
1322
|
-
ORDER BY nc.granted_at`
|
|
1323
|
-
).all(c.req.param("nestId"));
|
|
1324
|
-
return c.json({ collaborators: collabs });
|
|
1325
|
-
});
|
|
1326
|
-
sharingRoutes.post("/collaborators", async (c) => {
|
|
1327
|
-
const body = await c.req.json();
|
|
1328
|
-
if (!body.permission || !["read", "write", "admin"].includes(body.permission)) {
|
|
1329
|
-
throw new ValidationError("permission must be read, write, or admin");
|
|
1330
|
-
}
|
|
1331
|
-
const db = getDb();
|
|
1332
|
-
let userId = body.user_id;
|
|
1333
|
-
if (!userId && body.email) {
|
|
1334
|
-
let user = db.prepare("SELECT id FROM users WHERE email = ?").get(body.email);
|
|
1335
|
-
if (!user) {
|
|
1336
|
-
const { hashPassword: hashPassword2 } = await import("./keys-YV33AJK3.js");
|
|
1337
|
-
userId = uuid3();
|
|
1338
|
-
db.prepare(
|
|
1339
|
-
"INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)"
|
|
1340
|
-
).run(userId, body.email, null, await hashPassword2(uuid3()));
|
|
1341
|
-
} else {
|
|
1342
|
-
userId = user.id;
|
|
1343
|
-
}
|
|
1344
|
-
}
|
|
1345
|
-
if (!userId) {
|
|
1346
|
-
throw new ValidationError("user_id or email is required");
|
|
1347
|
-
}
|
|
1348
|
-
const collabId = uuid3();
|
|
1349
|
-
db.prepare(
|
|
1350
|
-
"INSERT INTO nest_collaborators (id, nest_id, user_id, permission, granted_by) VALUES (?, ?, ?, ?, ?)"
|
|
1351
|
-
).run(
|
|
1352
|
-
collabId,
|
|
1353
|
-
c.req.param("nestId"),
|
|
1354
|
-
userId,
|
|
1355
|
-
body.permission,
|
|
1356
|
-
c.get("userId")
|
|
1357
|
-
);
|
|
1358
|
-
const collab = db.prepare("SELECT * FROM nest_collaborators WHERE id = ?").get(collabId);
|
|
1359
|
-
return c.json({ collaborator: collab }, 201);
|
|
1360
|
-
});
|
|
1361
|
-
sharingRoutes.patch("/collaborators/:collabId", async (c) => {
|
|
1362
|
-
const body = await c.req.json();
|
|
1363
|
-
if (!body.permission || !["read", "write", "admin"].includes(body.permission)) {
|
|
1364
|
-
throw new ValidationError("permission must be read, write, or admin");
|
|
1365
|
-
}
|
|
1366
|
-
const db = getDb();
|
|
1367
|
-
db.prepare("UPDATE nest_collaborators SET permission = ? WHERE id = ?").run(
|
|
1368
|
-
body.permission,
|
|
1369
|
-
c.req.param("collabId")
|
|
1370
|
-
);
|
|
1371
|
-
return c.json({ updated: true });
|
|
1372
|
-
});
|
|
1373
|
-
sharingRoutes.delete("/collaborators/:collabId", async (c) => {
|
|
1374
|
-
const db = getDb();
|
|
1375
|
-
db.prepare("DELETE FROM nest_collaborators WHERE id = ?").run(
|
|
1376
|
-
c.req.param("collabId")
|
|
1377
|
-
);
|
|
1378
|
-
return c.json({ removed: true });
|
|
1379
|
-
});
|
|
1380
|
-
sharingRoutes.patch("/visibility", async (c) => {
|
|
1381
|
-
const body = await c.req.json();
|
|
1382
|
-
if (!body.visibility || !["private", "public"].includes(body.visibility)) {
|
|
1383
|
-
throw new ValidationError("visibility must be private or public");
|
|
1384
|
-
}
|
|
1385
|
-
const db = getDb();
|
|
1386
|
-
db.prepare("UPDATE nests SET visibility = ? WHERE id = ?").run(
|
|
1387
|
-
body.visibility,
|
|
1388
|
-
c.req.param("nestId")
|
|
1389
|
-
);
|
|
1390
|
-
return c.json({ visibility: body.visibility });
|
|
1391
|
-
});
|
|
1392
|
-
|
|
1393
|
-
// src/governance/access-guard.ts
|
|
1394
|
-
function resolveCallerEmail(userId) {
|
|
1395
|
-
if (!userId) return "admin@localhost";
|
|
1396
|
-
const db = getDb();
|
|
1397
|
-
const row = db.prepare("SELECT email FROM users WHERE id = ?").get(userId);
|
|
1398
|
-
return row?.email || "admin@localhost";
|
|
1399
|
-
}
|
|
1400
|
-
function canReadNode(nestId, nodeId, userEmail) {
|
|
1401
|
-
if (!isStewardshipEnabled(nestId)) return true;
|
|
1402
|
-
return canUserAccess(nestId, nodeId, userEmail).allowed;
|
|
1403
|
-
}
|
|
1404
|
-
function filterAccessible(nestId, userEmail, nodes) {
|
|
1405
|
-
if (!isStewardshipEnabled(nestId)) return nodes;
|
|
1406
|
-
return nodes.filter((n) => canUserAccess(nestId, n.id, userEmail).allowed);
|
|
1407
|
-
}
|
|
1408
|
-
|
|
1409
|
-
// src/nodes/routes.ts
|
|
1410
|
-
import { Hono as Hono4 } from "hono";
|
|
1411
|
-
import { serializeDocument } from "@promptowl/contextnest-engine";
|
|
1412
|
-
|
|
1413
|
-
// src/governance/tag-index-service.ts
|
|
1414
|
-
function normalizeTag(raw) {
|
|
1415
|
-
return raw.trim().replace(/^#+/, "").toLowerCase();
|
|
1416
|
-
}
|
|
1417
|
-
function syncNodeTags(nestId, nodeId, tags) {
|
|
1418
|
-
const db = getDb();
|
|
1419
|
-
const normalized = Array.from(
|
|
1420
|
-
new Set(
|
|
1421
|
-
tags.filter((t) => typeof t === "string").map(normalizeTag).filter(Boolean)
|
|
1422
|
-
)
|
|
1423
|
-
);
|
|
1424
|
-
db.transaction(() => {
|
|
1425
|
-
db.prepare(
|
|
1426
|
-
"DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?"
|
|
1427
|
-
).run(nestId, nodeId);
|
|
1428
|
-
const insert = db.prepare(
|
|
1429
|
-
"INSERT OR IGNORE INTO node_tag_index (nest_id, node_id, tag_name) VALUES (?, ?, ?)"
|
|
1430
|
-
);
|
|
1431
|
-
for (const tag of normalized) {
|
|
1432
|
-
insert.run(nestId, nodeId, tag);
|
|
1433
|
-
}
|
|
1434
|
-
})();
|
|
1435
|
-
}
|
|
1436
|
-
function removeNodeFromTagIndex(nestId, nodeId) {
|
|
1437
|
-
const db = getDb();
|
|
1438
|
-
db.prepare(
|
|
1439
|
-
"DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?"
|
|
1440
|
-
).run(nestId, nodeId);
|
|
1441
|
-
}
|
|
1442
|
-
|
|
1443
|
-
// src/governance/external-edit-service.ts
|
|
1444
|
-
import { readFile } from "fs/promises";
|
|
1445
|
-
import { join as join2 } from "path";
|
|
1446
|
-
import {
|
|
1447
|
-
detectDrift,
|
|
1448
|
-
stageSuggestion,
|
|
1449
|
-
approveSuggestion,
|
|
1450
|
-
rejectSuggestion,
|
|
1451
|
-
listSuggestions,
|
|
1452
|
-
readSuggestion,
|
|
1453
|
-
VersionManager
|
|
1454
|
-
} from "@promptowl/contextnest-engine";
|
|
1455
|
-
var communityRbac = {
|
|
1456
|
-
isCzar: () => false,
|
|
1457
|
-
canIngest: () => true,
|
|
1458
|
-
isDocOwner: () => true
|
|
1459
|
-
};
|
|
1460
|
-
function docPath(nestId, documentId) {
|
|
1461
|
-
return join2(config.DATA_ROOT, "nests", nestId, `${documentId}.md`);
|
|
1462
812
|
}
|
|
1463
|
-
async function
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
}
|
|
813
|
+
async function loadLatestApprovedNode(nestId, documentId) {
|
|
814
|
+
const { storage } = engineCache.get(nestId);
|
|
815
|
+
const head = await loadChainHead(storage, documentId);
|
|
816
|
+
if (!head) return null;
|
|
817
|
+
return parseDocument(docPath(nestId, documentId), head.content, documentId);
|
|
1469
818
|
}
|
|
1470
|
-
async function
|
|
1471
|
-
const
|
|
1472
|
-
if (!
|
|
1473
|
-
const
|
|
1474
|
-
|
|
1475
|
-
const content = await new VersionManager(storage).reconstructVersion(
|
|
1476
|
-
documentId,
|
|
1477
|
-
latest.version
|
|
1478
|
-
);
|
|
1479
|
-
return { version: latest.version, content };
|
|
1480
|
-
} catch {
|
|
1481
|
-
return null;
|
|
1482
|
-
}
|
|
819
|
+
async function bodyMatchesLatestVersion(storage, documentId, liveBodyHash) {
|
|
820
|
+
const head = await loadChainHead(storage, documentId);
|
|
821
|
+
if (!head) return false;
|
|
822
|
+
const headBodyHash = computeContentHash(getChecksumContent(head.content));
|
|
823
|
+
return headBodyHash === liveBodyHash;
|
|
1483
824
|
}
|
|
1484
825
|
async function scanDocumentForDrift(nestId, documentId, actor = "system:scanner") {
|
|
1485
826
|
const res = await scanDocumentForDriftInternal(nestId, documentId, actor);
|
|
@@ -1493,6 +834,9 @@ async function scanDocumentForDriftInternal(nestId, documentId, actor) {
|
|
|
1493
834
|
if (raw == null) return null;
|
|
1494
835
|
const drift = detectDrift(raw, node.frontmatter.checksum);
|
|
1495
836
|
if (!drift.drifted) return null;
|
|
837
|
+
if (await bodyMatchesLatestVersion(storage, documentId, drift.actualHash)) {
|
|
838
|
+
return null;
|
|
839
|
+
}
|
|
1496
840
|
const approved = await loadChainHead(storage, documentId);
|
|
1497
841
|
if (!approved) return null;
|
|
1498
842
|
const existing = await listSuggestions(storage, documentId);
|
|
@@ -1521,20 +865,35 @@ async function scanNestForDrift(nestId, actor = "system:scanner") {
|
|
|
1521
865
|
async function getPendingChange(nestId, documentId) {
|
|
1522
866
|
const { storage } = engineCache.get(nestId);
|
|
1523
867
|
const list = await listSuggestions(storage, documentId);
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
868
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
869
|
+
const meta = list[i];
|
|
870
|
+
if (await bodyMatchesLatestVersion(storage, documentId, meta.proposed_hash)) {
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
return {
|
|
874
|
+
suggestion_id: meta.suggestion_id,
|
|
875
|
+
detected_at: meta.detected_at,
|
|
876
|
+
source: meta.source,
|
|
877
|
+
proposed_hash: meta.proposed_hash
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
return null;
|
|
1532
881
|
}
|
|
1533
882
|
async function listNestExternalEdits(nestId) {
|
|
1534
883
|
const { storage } = engineCache.get(nestId);
|
|
1535
884
|
const docs = await storage.discoverDocuments();
|
|
1536
885
|
const lists = await Promise.all(
|
|
1537
|
-
docs.map((doc) =>
|
|
886
|
+
docs.map(async (doc) => {
|
|
887
|
+
const metas = await listSuggestions(storage, doc.id);
|
|
888
|
+
const fresh = [];
|
|
889
|
+
for (const meta of metas) {
|
|
890
|
+
if (await bodyMatchesLatestVersion(storage, doc.id, meta.proposed_hash)) {
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
893
|
+
fresh.push(meta);
|
|
894
|
+
}
|
|
895
|
+
return fresh;
|
|
896
|
+
})
|
|
1538
897
|
);
|
|
1539
898
|
const entries = lists.flat().map((meta) => ({
|
|
1540
899
|
suggestion_id: meta.suggestion_id,
|
|
@@ -1590,7 +949,7 @@ async function approveExternalEdit(input) {
|
|
|
1590
949
|
const node = await storage.readDocument(input.documentId);
|
|
1591
950
|
const versionNum = result.versionEntry.version;
|
|
1592
951
|
const tags = node.frontmatter.tags || [];
|
|
1593
|
-
const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-
|
|
952
|
+
const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-UODXLAOJ.js");
|
|
1594
953
|
createVersion2({
|
|
1595
954
|
nestId: input.nestId,
|
|
1596
955
|
nodeId: input.documentId,
|
|
@@ -1656,48 +1015,79 @@ function startDriftScanner(intervalMs = 3e4) {
|
|
|
1656
1015
|
scannerTimer.unref?.();
|
|
1657
1016
|
}
|
|
1658
1017
|
|
|
1659
|
-
// src/nodes/
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
title: node.frontmatter.title,
|
|
1665
|
-
type: node.frontmatter.type || "document",
|
|
1666
|
-
tags: node.frontmatter.tags || [],
|
|
1667
|
-
// Widen to string so callers can layer review-workflow states like
|
|
1668
|
-
// "pending_review" / "rejected" on top of the on-disk frontmatter
|
|
1669
|
-
// status. The engine's Status enum only knows draft/approved.
|
|
1670
|
-
status: node.frontmatter.status || "draft",
|
|
1671
|
-
version: node.frontmatter.version || 1,
|
|
1672
|
-
author: node.frontmatter.author,
|
|
1673
|
-
description: node.frontmatter.description,
|
|
1674
|
-
created_at: node.frontmatter.created_at,
|
|
1675
|
-
updated_at: node.frontmatter.updated_at,
|
|
1676
|
-
content: node.body,
|
|
1677
|
-
pendingChange: node.pendingChange ?? void 0
|
|
1678
|
-
};
|
|
1018
|
+
// src/nodes/service.ts
|
|
1019
|
+
function userIdFromEmail(email) {
|
|
1020
|
+
const db = getDb();
|
|
1021
|
+
const row = db.prepare("SELECT id FROM users WHERE LOWER(email) = LOWER(?)").get(email);
|
|
1022
|
+
return row?.id ?? ANON_USER_ID;
|
|
1679
1023
|
}
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1024
|
+
var normalizeTag2 = (t) => t.startsWith("#") ? t : `#${t}`;
|
|
1025
|
+
var stripUndefined = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== void 0));
|
|
1026
|
+
function bodyOnly(nodeId, raw) {
|
|
1027
|
+
try {
|
|
1028
|
+
return parseDocument2(`${nodeId}.md`, raw, nodeId).body ?? "";
|
|
1029
|
+
} catch {
|
|
1030
|
+
return raw;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
function toNodeResponse(node) {
|
|
1034
|
+
const fm = node.frontmatter;
|
|
1035
|
+
const title = fm.title === void 0 || fm.title === null ? "" : String(fm.title);
|
|
1036
|
+
const tags = Array.isArray(fm.tags) ? fm.tags.map((t) => String(t)) : [];
|
|
1037
|
+
return {
|
|
1038
|
+
id: node.id,
|
|
1039
|
+
title,
|
|
1040
|
+
type: fm.type || "document",
|
|
1041
|
+
tags,
|
|
1042
|
+
status: fm.status || "draft",
|
|
1043
|
+
version: fm.version || 1,
|
|
1044
|
+
author: fm.author,
|
|
1045
|
+
description: fm.description,
|
|
1046
|
+
created_at: fm.created_at,
|
|
1047
|
+
updated_at: fm.updated_at,
|
|
1048
|
+
content: node.body || "",
|
|
1049
|
+
pendingChange: node.pendingChange ?? void 0
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
async function listNodesForCaller(nestId, userId, filters = {}) {
|
|
1053
|
+
const { storage, versions: versionManager } = engineCache.get(nestId);
|
|
1054
|
+
let documents = await storage.discoverDocuments();
|
|
1055
|
+
if (filters.type) {
|
|
1056
|
+
documents = documents.filter((n) => n.frontmatter.type === filters.type);
|
|
1057
|
+
}
|
|
1058
|
+
if (filters.tag) {
|
|
1059
|
+
const tag = normalizeTag2(filters.tag);
|
|
1060
|
+
documents = documents.filter(
|
|
1061
|
+
(n) => (n.frontmatter.tags || []).includes(tag)
|
|
1062
|
+
);
|
|
1063
|
+
}
|
|
1064
|
+
const userEmail = resolveCallerEmail(userId);
|
|
1065
|
+
const accessible = filterAccessible(nestId, userId, userEmail, documents);
|
|
1066
|
+
const publicReader = isPublicReader(nestId, userId);
|
|
1067
|
+
const enriched = await Promise.all(
|
|
1688
1068
|
accessible.map(async (doc) => {
|
|
1069
|
+
const r = toNodeResponse(doc);
|
|
1070
|
+
if (publicReader) {
|
|
1071
|
+
const approved = getApprovedVersion(nestId, doc.id);
|
|
1072
|
+
if (approved != null) {
|
|
1073
|
+
try {
|
|
1074
|
+
const raw = await versionManager.reconstructVersion(doc.id, approved);
|
|
1075
|
+
r.content = bodyOnly(doc.id, raw);
|
|
1076
|
+
} catch (err) {
|
|
1077
|
+
console.error("reconstructVersion failed (list)", doc.id, approved, err);
|
|
1078
|
+
r.content = "";
|
|
1079
|
+
}
|
|
1080
|
+
r.version = approved;
|
|
1081
|
+
r.status = "published";
|
|
1082
|
+
}
|
|
1083
|
+
return r;
|
|
1084
|
+
}
|
|
1085
|
+
let pending = null;
|
|
1689
1086
|
try {
|
|
1690
|
-
|
|
1087
|
+
pending = await getPendingChange(nestId, doc.id);
|
|
1691
1088
|
} catch {
|
|
1692
|
-
|
|
1089
|
+
pending = null;
|
|
1693
1090
|
}
|
|
1694
|
-
})
|
|
1695
|
-
);
|
|
1696
|
-
return c.json({
|
|
1697
|
-
count: accessible.length,
|
|
1698
|
-
nodes: accessible.map((doc) => {
|
|
1699
|
-
const r = toNodeResponse(doc);
|
|
1700
|
-
const pending = pendingByDoc.get(doc.id);
|
|
1701
1091
|
if (pending) {
|
|
1702
1092
|
r.pendingChange = pending;
|
|
1703
1093
|
r.status = "external_edit_pending";
|
|
@@ -1706,93 +1096,526 @@ nodeRoutes.get("/", async (c) => {
|
|
|
1706
1096
|
}
|
|
1707
1097
|
return r;
|
|
1708
1098
|
})
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
const nestId = c.req.param("nestId");
|
|
1099
|
+
);
|
|
1100
|
+
return filters.limit ? enriched.slice(0, filters.limit) : enriched;
|
|
1101
|
+
}
|
|
1102
|
+
async function listNodesForCallerByEmail(nestId, userEmail, filters = {}) {
|
|
1103
|
+
return listNodesForCaller(nestId, userIdFromEmail(userEmail), filters);
|
|
1104
|
+
}
|
|
1105
|
+
async function createNode(nestId, input, userEmail) {
|
|
1717
1106
|
const { storage, versions: versionManager } = engineCache.get(nestId);
|
|
1718
|
-
const slug =
|
|
1107
|
+
const slug = input.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1719
1108
|
const id = `nodes/${slug}`;
|
|
1720
1109
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1721
|
-
const tags =
|
|
1110
|
+
const tags = (input.tags || []).map(normalizeTag2);
|
|
1722
1111
|
const hasStewards = isStewardshipEnabled(nestId);
|
|
1723
|
-
const initialStatus = hasStewards ? "draft" : "
|
|
1112
|
+
const initialStatus = hasStewards ? "draft" : "published";
|
|
1724
1113
|
const initialVersion = hasStewards ? 1 : 0;
|
|
1725
1114
|
let node = {
|
|
1726
1115
|
id,
|
|
1727
1116
|
filePath: "",
|
|
1728
1117
|
frontmatter: {
|
|
1729
|
-
title:
|
|
1730
|
-
type:
|
|
1118
|
+
title: input.title,
|
|
1119
|
+
type: input.type || "document",
|
|
1731
1120
|
tags,
|
|
1732
|
-
status:
|
|
1121
|
+
status: input.status || initialStatus,
|
|
1733
1122
|
version: initialVersion,
|
|
1734
1123
|
created_at: now,
|
|
1735
1124
|
updated_at: now,
|
|
1736
|
-
metadata: {
|
|
1737
|
-
owners: ["*"],
|
|
1738
|
-
scope: body.scope || "team"
|
|
1739
|
-
}
|
|
1125
|
+
metadata: { owners: ["*"], scope: input.scope || "team" }
|
|
1740
1126
|
},
|
|
1741
|
-
body:
|
|
1127
|
+
body: input.content,
|
|
1742
1128
|
rawContent: ""
|
|
1743
1129
|
};
|
|
1744
|
-
|
|
1745
|
-
await storage.writeDocument(id, serialized);
|
|
1130
|
+
await storage.writeDocument(id, serializeDocument(node));
|
|
1746
1131
|
syncNodeTags(nestId, id, tags);
|
|
1132
|
+
let savedVersion = 1;
|
|
1133
|
+
if (hasStewards) {
|
|
1134
|
+
try {
|
|
1135
|
+
await versionManager.createVersion(node, userEmail);
|
|
1136
|
+
} catch (err) {
|
|
1137
|
+
console.error("VersionManager.createVersion failed (node create)", err);
|
|
1138
|
+
}
|
|
1139
|
+
createVersion({
|
|
1140
|
+
nestId,
|
|
1141
|
+
nodeId: id,
|
|
1142
|
+
version: 1,
|
|
1143
|
+
content: input.content,
|
|
1144
|
+
author: userEmail,
|
|
1145
|
+
status: "draft",
|
|
1146
|
+
tags
|
|
1147
|
+
});
|
|
1148
|
+
} else {
|
|
1149
|
+
try {
|
|
1150
|
+
const result = await safePublishDocument(storage, id, {
|
|
1151
|
+
editedBy: userEmail,
|
|
1152
|
+
note: "Auto-published on create (no stewards configured)"
|
|
1153
|
+
});
|
|
1154
|
+
savedVersion = result.node.frontmatter.version || 1;
|
|
1155
|
+
createVersion({
|
|
1156
|
+
nestId,
|
|
1157
|
+
nodeId: id,
|
|
1158
|
+
version: savedVersion,
|
|
1159
|
+
content: result.node.body || "",
|
|
1160
|
+
author: userEmail,
|
|
1161
|
+
status: "published",
|
|
1162
|
+
tags
|
|
1163
|
+
});
|
|
1164
|
+
setApprovedVersion(nestId, id, savedVersion, userEmail);
|
|
1165
|
+
node = result.node;
|
|
1166
|
+
} catch (err) {
|
|
1167
|
+
console.error("publishDocument failed (node create auto-publish)", err);
|
|
1168
|
+
createVersion({
|
|
1169
|
+
nestId,
|
|
1170
|
+
nodeId: id,
|
|
1171
|
+
version: 1,
|
|
1172
|
+
content: input.content,
|
|
1173
|
+
author: userEmail,
|
|
1174
|
+
status: "draft",
|
|
1175
|
+
tags
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
trackEvent("node.create", { nestId, nodeId: id });
|
|
1180
|
+
return { node, version: savedVersion };
|
|
1181
|
+
}
|
|
1182
|
+
async function registerImportedDocuments(nestId, userEmail) {
|
|
1183
|
+
const { storage } = engineCache.get(nestId);
|
|
1184
|
+
let docs;
|
|
1185
|
+
try {
|
|
1186
|
+
docs = await storage.discoverDocuments();
|
|
1187
|
+
} catch (err) {
|
|
1188
|
+
console.error("registerImportedDocuments: discovery failed", nestId, err);
|
|
1189
|
+
return 0;
|
|
1190
|
+
}
|
|
1191
|
+
let registered = 0;
|
|
1192
|
+
for (const doc of docs) {
|
|
1193
|
+
const nodeId = doc.id;
|
|
1194
|
+
if (getCurrentVersion(nestId, nodeId) > 0) continue;
|
|
1195
|
+
const rawTags = Array.isArray(doc.frontmatter?.tags) ? doc.frontmatter.tags : [];
|
|
1196
|
+
const tags = rawTags.map((t) => normalizeTag2(String(t)));
|
|
1197
|
+
const fmVersion = Number(doc.frontmatter?.version);
|
|
1198
|
+
let version = Number.isFinite(fmVersion) && fmVersion > 0 ? fmVersion : 1;
|
|
1199
|
+
let content = doc.body || "";
|
|
1200
|
+
try {
|
|
1201
|
+
const result = await safePublishDocument(storage, nodeId, {
|
|
1202
|
+
editedBy: userEmail,
|
|
1203
|
+
note: "Imported from existing folder"
|
|
1204
|
+
});
|
|
1205
|
+
version = result.node.frontmatter.version || version;
|
|
1206
|
+
content = result.node.body || content;
|
|
1207
|
+
} catch (err) {
|
|
1208
|
+
console.error("safePublishDocument failed (import register)", nodeId, err);
|
|
1209
|
+
}
|
|
1210
|
+
createVersion({
|
|
1211
|
+
nestId,
|
|
1212
|
+
nodeId,
|
|
1213
|
+
version,
|
|
1214
|
+
content,
|
|
1215
|
+
author: userEmail,
|
|
1216
|
+
status: "published",
|
|
1217
|
+
changeNote: "Imported from existing folder",
|
|
1218
|
+
tags
|
|
1219
|
+
});
|
|
1220
|
+
setApprovedVersion(nestId, nodeId, version, userEmail);
|
|
1221
|
+
syncNodeTags(nestId, nodeId, tags);
|
|
1222
|
+
registered++;
|
|
1223
|
+
}
|
|
1224
|
+
trackEvent("nest.import.documents", { nestId, registered });
|
|
1225
|
+
return registered;
|
|
1226
|
+
}
|
|
1227
|
+
async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
1228
|
+
const { storage, versions: versionManager } = engineCache.get(nestId);
|
|
1229
|
+
let node;
|
|
1230
|
+
try {
|
|
1231
|
+
node = await storage.readDocument(nodeId);
|
|
1232
|
+
} catch {
|
|
1233
|
+
throw new NotFoundError(`Node not found: ${nodeId}`);
|
|
1234
|
+
}
|
|
1235
|
+
if (patch.content !== void 0) {
|
|
1236
|
+
node = { ...node, body: patch.content };
|
|
1237
|
+
}
|
|
1238
|
+
if (patch.append) {
|
|
1239
|
+
node = { ...node, body: (node.body || "") + "\n\n" + patch.append };
|
|
1240
|
+
}
|
|
1241
|
+
if (patch.tags) {
|
|
1242
|
+
const newTags = patch.tags.map(normalizeTag2);
|
|
1243
|
+
const merged = [.../* @__PURE__ */ new Set([...node.frontmatter.tags || [], ...newTags])];
|
|
1244
|
+
node = { ...node, frontmatter: { ...node.frontmatter, tags: merged } };
|
|
1245
|
+
}
|
|
1246
|
+
if (patch.status) {
|
|
1247
|
+
node = { ...node, frontmatter: { ...node.frontmatter, status: patch.status } };
|
|
1248
|
+
}
|
|
1249
|
+
if (patch.title) {
|
|
1250
|
+
node = { ...node, frontmatter: { ...node.frontmatter, title: patch.title } };
|
|
1251
|
+
}
|
|
1252
|
+
const hasStewards = isStewardshipEnabled(nestId);
|
|
1253
|
+
const currentTags = node.frontmatter.tags || [];
|
|
1254
|
+
if (getPendingReview(nestId, nodeId)) {
|
|
1255
|
+
cancelReview({ nestId, nodeId, cancelledBy: userEmail });
|
|
1256
|
+
}
|
|
1257
|
+
let responseVersion;
|
|
1258
|
+
if (hasStewards) {
|
|
1259
|
+
const currentVersion = getCurrentVersion(nestId, nodeId);
|
|
1260
|
+
const newVersion = currentVersion + 1;
|
|
1261
|
+
node = {
|
|
1262
|
+
...node,
|
|
1263
|
+
frontmatter: {
|
|
1264
|
+
...node.frontmatter,
|
|
1265
|
+
version: newVersion,
|
|
1266
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1267
|
+
// Drop stale published-state checksum so the next verified read
|
|
1268
|
+
// doesn't flag this write as external drift.
|
|
1269
|
+
checksum: void 0
|
|
1270
|
+
}
|
|
1271
|
+
};
|
|
1272
|
+
node = { ...node, frontmatter: stripUndefined(node.frontmatter) };
|
|
1273
|
+
await storage.writeDocument(nodeId, serializeDocument(node));
|
|
1274
|
+
syncNodeTags(nestId, nodeId, currentTags);
|
|
1275
|
+
try {
|
|
1276
|
+
await versionManager.createVersion(node, userEmail, { note: patch.changeNote });
|
|
1277
|
+
} catch (err) {
|
|
1278
|
+
console.error("VersionManager.createVersion failed (node patch)", err);
|
|
1279
|
+
}
|
|
1280
|
+
createVersion({
|
|
1281
|
+
nestId,
|
|
1282
|
+
nodeId,
|
|
1283
|
+
version: newVersion,
|
|
1284
|
+
content: node.body || "",
|
|
1285
|
+
author: userEmail,
|
|
1286
|
+
status: "draft",
|
|
1287
|
+
tags: currentTags,
|
|
1288
|
+
changeNote: patch.changeNote
|
|
1289
|
+
});
|
|
1290
|
+
responseVersion = newVersion;
|
|
1291
|
+
} else {
|
|
1292
|
+
node = {
|
|
1293
|
+
...node,
|
|
1294
|
+
frontmatter: {
|
|
1295
|
+
...node.frontmatter,
|
|
1296
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1297
|
+
checksum: void 0
|
|
1298
|
+
}
|
|
1299
|
+
};
|
|
1300
|
+
node = { ...node, frontmatter: stripUndefined(node.frontmatter) };
|
|
1301
|
+
await storage.writeDocument(nodeId, serializeDocument(node));
|
|
1302
|
+
syncNodeTags(nestId, nodeId, currentTags);
|
|
1303
|
+
let publishedVersion = (node.frontmatter.version || 0) + 1;
|
|
1304
|
+
try {
|
|
1305
|
+
const result = await safePublishDocument(storage, nodeId, {
|
|
1306
|
+
editedBy: userEmail,
|
|
1307
|
+
note: patch.changeNote || "Auto-published on edit (no stewards)"
|
|
1308
|
+
});
|
|
1309
|
+
publishedVersion = result.node.frontmatter.version || publishedVersion;
|
|
1310
|
+
node = result.node;
|
|
1311
|
+
} catch (err) {
|
|
1312
|
+
console.error("publishDocument failed (node patch auto-publish)", err);
|
|
1313
|
+
}
|
|
1314
|
+
createVersion({
|
|
1315
|
+
nestId,
|
|
1316
|
+
nodeId,
|
|
1317
|
+
version: publishedVersion,
|
|
1318
|
+
content: node.body || "",
|
|
1319
|
+
author: userEmail,
|
|
1320
|
+
status: "published",
|
|
1321
|
+
tags: currentTags,
|
|
1322
|
+
changeNote: patch.changeNote
|
|
1323
|
+
});
|
|
1324
|
+
setApprovedVersion(nestId, nodeId, publishedVersion, userEmail);
|
|
1325
|
+
responseVersion = publishedVersion;
|
|
1326
|
+
}
|
|
1327
|
+
return { node, version: responseVersion };
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// src/nests/routes.ts
|
|
1331
|
+
function effectivePermission(nestId, userId) {
|
|
1332
|
+
if (config.AUTH_MODE === "open") {
|
|
1333
|
+
const db = getDb();
|
|
1334
|
+
const nest = db.prepare("SELECT user_id FROM nests WHERE id = ?").get(nestId);
|
|
1335
|
+
if (nest && nest.user_id === ANON_USER_ID) return "owner";
|
|
1336
|
+
}
|
|
1337
|
+
return resolveNestPermission(nestId, userId);
|
|
1338
|
+
}
|
|
1339
|
+
var nestRoutes = new Hono2();
|
|
1340
|
+
nestRoutes.get("/", async (c) => {
|
|
1341
|
+
const userId = c.get("userId");
|
|
1342
|
+
const owned = listNests(userId);
|
|
1343
|
+
const shared = listSharedNests(userId);
|
|
1344
|
+
const publicExtras = listPublicNests(userId);
|
|
1345
|
+
const db = getDb();
|
|
1346
|
+
const ownerEmailStmt = db.prepare(
|
|
1347
|
+
"SELECT email FROM users WHERE id = ?"
|
|
1348
|
+
);
|
|
1349
|
+
const callerEmail = resolveCallerEmail(userId);
|
|
1350
|
+
const annotate = (n) => {
|
|
1351
|
+
const permission = effectivePermission(n.id, userId);
|
|
1352
|
+
const is_owner = permission === "owner";
|
|
1353
|
+
let owner_email = null;
|
|
1354
|
+
const roles = is_owner ? ["owner"] : resolveUserRoles(n.id, callerEmail);
|
|
1355
|
+
if (!is_owner && n.user_id !== ANON_USER_ID) {
|
|
1356
|
+
const row = ownerEmailStmt.get(n.user_id);
|
|
1357
|
+
owner_email = row?.email ?? null;
|
|
1358
|
+
}
|
|
1359
|
+
return { ...n, permission, is_owner, owner_email, roles };
|
|
1360
|
+
};
|
|
1361
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1362
|
+
const out = [];
|
|
1363
|
+
for (const n of [...owned, ...shared, ...publicExtras]) {
|
|
1364
|
+
if (seen.has(n.id)) continue;
|
|
1365
|
+
seen.add(n.id);
|
|
1366
|
+
out.push(annotate(n));
|
|
1367
|
+
}
|
|
1368
|
+
return c.json({ nests: out });
|
|
1369
|
+
});
|
|
1370
|
+
nestRoutes.post("/", async (c) => {
|
|
1371
|
+
const body = await c.req.json();
|
|
1372
|
+
if (!body.name) {
|
|
1373
|
+
throw new ValidationError("name is required");
|
|
1374
|
+
}
|
|
1375
|
+
const nest = await createNest(c.get("userId"), body.name, body.description);
|
|
1376
|
+
return c.json({ nest }, 201);
|
|
1377
|
+
});
|
|
1378
|
+
nestRoutes.post("/import", async (c) => {
|
|
1379
|
+
const body = await c.req.json();
|
|
1380
|
+
if (!body.name) {
|
|
1381
|
+
throw new ValidationError("name is required");
|
|
1382
|
+
}
|
|
1383
|
+
const files = Array.isArray(body.files) ? body.files : [];
|
|
1384
|
+
const userId = c.get("userId");
|
|
1385
|
+
const nest = await importNest(userId, body.name, files);
|
|
1386
|
+
const documents = await registerImportedDocuments(
|
|
1387
|
+
nest.id,
|
|
1388
|
+
resolveCallerEmail(userId)
|
|
1389
|
+
);
|
|
1390
|
+
return c.json({ nest, documents }, 201);
|
|
1391
|
+
});
|
|
1392
|
+
nestRoutes.get("/:nestId", async (c) => {
|
|
1393
|
+
const nestId = c.req.param("nestId");
|
|
1394
|
+
const userId = c.get("userId");
|
|
1395
|
+
const permission = effectivePermission(nestId, userId);
|
|
1396
|
+
if (permission === "none") {
|
|
1397
|
+
throw new NotFoundError("Nest not found");
|
|
1398
|
+
}
|
|
1399
|
+
const email = resolveCallerEmail(userId);
|
|
1400
|
+
let roles = resolveUserRoles(nestId, email);
|
|
1401
|
+
if (permission === "owner" && !roles.includes("owner")) {
|
|
1402
|
+
roles = ["owner", ...roles];
|
|
1403
|
+
}
|
|
1404
|
+
const myStewards = getStewardsForUser(nestId, email);
|
|
1405
|
+
const nest = getNest(nestId);
|
|
1406
|
+
return c.json({ nest, permission, roles, myStewards });
|
|
1407
|
+
});
|
|
1408
|
+
nestRoutes.delete("/:nestId", async (c) => {
|
|
1409
|
+
const nestId = c.req.param("nestId");
|
|
1410
|
+
const userId = c.get("userId");
|
|
1411
|
+
const nest = getNest(nestId);
|
|
1412
|
+
if (!nest) {
|
|
1413
|
+
throw new NotFoundError("Nest not found");
|
|
1414
|
+
}
|
|
1415
|
+
const permission = effectivePermission(nestId, userId);
|
|
1416
|
+
const isAnonOwned = nest.user_id === ANON_USER_ID;
|
|
1417
|
+
const adminCaretaker = config.AUTH_MODE !== "open" && isAnonOwned && isLicenseAdminUserId(userId);
|
|
1418
|
+
if (permission !== "owner" && !adminCaretaker) {
|
|
1419
|
+
throw new ForbiddenError(
|
|
1420
|
+
"You don't have permission to delete this nest. Only the nest owner can delete it."
|
|
1421
|
+
);
|
|
1422
|
+
}
|
|
1423
|
+
await deleteNest(nestId);
|
|
1424
|
+
return c.json({ deleted: true });
|
|
1425
|
+
});
|
|
1426
|
+
nestRoutes.get("/:nestId/settings", async (c) => {
|
|
1427
|
+
const nestId = c.req.param("nestId");
|
|
1428
|
+
const permission = effectivePermission(nestId, c.get("userId"));
|
|
1429
|
+
if (permission === "none") {
|
|
1430
|
+
throw new NotFoundError("Nest not found");
|
|
1431
|
+
}
|
|
1432
|
+
return c.json({
|
|
1433
|
+
stewardship_enabled: isStewardshipEnabled(nestId),
|
|
1434
|
+
allow_self_approve: nestAllowsSelfApprove(nestId)
|
|
1435
|
+
});
|
|
1436
|
+
});
|
|
1437
|
+
nestRoutes.patch("/:nestId/settings", async (c) => {
|
|
1438
|
+
const nestId = c.req.param("nestId");
|
|
1439
|
+
const userId = c.get("userId");
|
|
1440
|
+
const isServerAdmin = isLicenseAdminUserId(userId);
|
|
1441
|
+
const permission = effectivePermission(nestId, userId);
|
|
1442
|
+
if (!isServerAdmin && permission !== "owner") {
|
|
1443
|
+
return c.json(
|
|
1444
|
+
{
|
|
1445
|
+
error: "Only the nest owner or the server license-admin can update nest settings."
|
|
1446
|
+
},
|
|
1447
|
+
403
|
|
1448
|
+
);
|
|
1449
|
+
}
|
|
1450
|
+
const body = await c.req.json();
|
|
1451
|
+
let wiped = null;
|
|
1452
|
+
if (typeof body.stewardship_enabled === "boolean") {
|
|
1453
|
+
if (body.stewardship_enabled) {
|
|
1454
|
+
setStewardshipEnabled(nestId, true);
|
|
1455
|
+
} else {
|
|
1456
|
+
wiped = disableStewardshipAndWipeGovernance(nestId);
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
if (typeof body.allow_self_approve === "boolean") {
|
|
1460
|
+
setAllowSelfApprove(nestId, body.allow_self_approve);
|
|
1461
|
+
}
|
|
1462
|
+
return c.json({
|
|
1463
|
+
stewardship_enabled: isStewardshipEnabled(nestId),
|
|
1464
|
+
allow_self_approve: nestAllowsSelfApprove(nestId),
|
|
1465
|
+
wiped
|
|
1466
|
+
});
|
|
1467
|
+
});
|
|
1468
|
+
|
|
1469
|
+
// src/nests/sharing-routes.ts
|
|
1470
|
+
import { Hono as Hono3 } from "hono";
|
|
1471
|
+
|
|
1472
|
+
// src/nests/sharing-service.ts
|
|
1473
|
+
import { v4 as uuid2 } from "uuid";
|
|
1474
|
+
var VALID_PERMISSIONS = ["read", "write", "admin"];
|
|
1475
|
+
async function addCollaborator(params) {
|
|
1476
|
+
const { nestId } = params;
|
|
1477
|
+
if (!params.permission || !VALID_PERMISSIONS.includes(params.permission)) {
|
|
1478
|
+
throw new ValidationError("permission must be read, write, or admin");
|
|
1479
|
+
}
|
|
1480
|
+
const db = getDb();
|
|
1481
|
+
let userId = params.userId;
|
|
1482
|
+
if (!userId && params.email) {
|
|
1483
|
+
const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(params.email);
|
|
1484
|
+
if (existing) {
|
|
1485
|
+
userId = existing.id;
|
|
1486
|
+
} else {
|
|
1487
|
+
const { hashPassword: hashPassword2 } = await import("./keys-73STFJJB.js");
|
|
1488
|
+
userId = uuid2();
|
|
1489
|
+
db.prepare(
|
|
1490
|
+
"INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)"
|
|
1491
|
+
).run(userId, params.email, null, await hashPassword2(uuid2()));
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
if (!userId) {
|
|
1495
|
+
throw new ValidationError("user_id or email is required");
|
|
1496
|
+
}
|
|
1497
|
+
const ownerRow = db.prepare("SELECT user_id FROM nests WHERE id = ?").get(nestId);
|
|
1498
|
+
if (!ownerRow) {
|
|
1499
|
+
throw new ValidationError("Nest not found");
|
|
1500
|
+
}
|
|
1501
|
+
if (ownerRow.user_id === userId) {
|
|
1502
|
+
throw new ValidationError(
|
|
1503
|
+
"The nest owner already has full access and can't be added as a collaborator."
|
|
1504
|
+
);
|
|
1505
|
+
}
|
|
1506
|
+
const selfByEmail = !!params.email && !!params.grantedByEmail && params.email.trim().toLowerCase() === params.grantedByEmail.trim().toLowerCase();
|
|
1507
|
+
const selfById = !!params.grantedByUserId && params.grantedByUserId === userId;
|
|
1508
|
+
if (selfByEmail || selfById) {
|
|
1509
|
+
throw new ValidationError("You can't add yourself as a collaborator.");
|
|
1510
|
+
}
|
|
1511
|
+
const dupe = db.prepare("SELECT id FROM nest_collaborators WHERE nest_id = ? AND user_id = ?").get(nestId, userId);
|
|
1512
|
+
if (dupe) {
|
|
1513
|
+
throw new ConflictError(
|
|
1514
|
+
`${params.email || "This user"} already has access to this nest. Change their permission instead of adding them again.`
|
|
1515
|
+
);
|
|
1516
|
+
}
|
|
1517
|
+
const granterByEmail = !params.grantedByUserId && params.grantedByEmail ? db.prepare("SELECT id FROM users WHERE LOWER(email) = LOWER(?)").get(params.grantedByEmail)?.id : void 0;
|
|
1518
|
+
const granterId = params.grantedByUserId || granterByEmail || ownerRow.user_id;
|
|
1519
|
+
const collabId = uuid2();
|
|
1520
|
+
db.prepare(
|
|
1521
|
+
"INSERT INTO nest_collaborators (id, nest_id, user_id, permission, granted_by) VALUES (?, ?, ?, ?, ?)"
|
|
1522
|
+
).run(collabId, nestId, userId, params.permission, granterId);
|
|
1523
|
+
return db.prepare("SELECT * FROM nest_collaborators WHERE id = ?").get(collabId);
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
// src/nests/sharing-routes.ts
|
|
1527
|
+
var sharingRoutes = new Hono3();
|
|
1528
|
+
sharingRoutes.get("/collaborators", async (c) => {
|
|
1529
|
+
const db = getDb();
|
|
1530
|
+
const nestId = c.req.param("nestId");
|
|
1531
|
+
const collabs = db.prepare(
|
|
1532
|
+
`SELECT nc.*, u.email FROM nest_collaborators nc
|
|
1533
|
+
LEFT JOIN users u ON nc.user_id = u.id
|
|
1534
|
+
WHERE nc.nest_id = ?
|
|
1535
|
+
ORDER BY nc.granted_at`
|
|
1536
|
+
).all(nestId);
|
|
1537
|
+
const enriched = collabs.map((collab) => {
|
|
1538
|
+
if (!collab.email) return { ...collab, stewardRoles: [], roles: [] };
|
|
1539
|
+
return {
|
|
1540
|
+
...collab,
|
|
1541
|
+
stewardRoles: getStewardRolesForUser(nestId, collab.email),
|
|
1542
|
+
roles: resolveUserRoles(nestId, collab.email)
|
|
1543
|
+
};
|
|
1544
|
+
});
|
|
1545
|
+
return c.json({ collaborators: enriched });
|
|
1546
|
+
});
|
|
1547
|
+
sharingRoutes.post("/collaborators", async (c) => {
|
|
1548
|
+
const body = await c.req.json();
|
|
1549
|
+
const collab = await addCollaborator({
|
|
1550
|
+
nestId: c.req.param("nestId"),
|
|
1551
|
+
email: body.email,
|
|
1552
|
+
userId: body.user_id,
|
|
1553
|
+
permission: body.permission ?? "",
|
|
1554
|
+
grantedByUserId: c.get("userId")
|
|
1555
|
+
});
|
|
1556
|
+
return c.json({ collaborator: collab }, 201);
|
|
1557
|
+
});
|
|
1558
|
+
sharingRoutes.patch("/collaborators/:collabId", async (c) => {
|
|
1559
|
+
const body = await c.req.json();
|
|
1560
|
+
if (!body.permission || !["read", "write", "admin"].includes(body.permission)) {
|
|
1561
|
+
throw new ValidationError("permission must be read, write, or admin");
|
|
1562
|
+
}
|
|
1563
|
+
const db = getDb();
|
|
1564
|
+
db.prepare("UPDATE nest_collaborators SET permission = ? WHERE id = ?").run(
|
|
1565
|
+
body.permission,
|
|
1566
|
+
c.req.param("collabId")
|
|
1567
|
+
);
|
|
1568
|
+
return c.json({ updated: true });
|
|
1569
|
+
});
|
|
1570
|
+
sharingRoutes.delete("/collaborators/:collabId", async (c) => {
|
|
1571
|
+
const db = getDb();
|
|
1572
|
+
db.prepare("DELETE FROM nest_collaborators WHERE id = ?").run(
|
|
1573
|
+
c.req.param("collabId")
|
|
1574
|
+
);
|
|
1575
|
+
return c.json({ removed: true });
|
|
1576
|
+
});
|
|
1577
|
+
sharingRoutes.patch("/visibility", async (c) => {
|
|
1578
|
+
const body = await c.req.json();
|
|
1579
|
+
if (!body.visibility || !["private", "public"].includes(body.visibility)) {
|
|
1580
|
+
throw new ValidationError("visibility must be private or public");
|
|
1581
|
+
}
|
|
1582
|
+
const db = getDb();
|
|
1583
|
+
db.prepare("UPDATE nests SET visibility = ? WHERE id = ?").run(
|
|
1584
|
+
body.visibility,
|
|
1585
|
+
c.req.param("nestId")
|
|
1586
|
+
);
|
|
1587
|
+
return c.json({ visibility: body.visibility });
|
|
1588
|
+
});
|
|
1589
|
+
|
|
1590
|
+
// src/nodes/routes.ts
|
|
1591
|
+
import { Hono as Hono4 } from "hono";
|
|
1592
|
+
var nodeRoutes = new Hono4();
|
|
1593
|
+
nodeRoutes.get("/", async (c) => {
|
|
1594
|
+
const nestId = c.req.param("nestId");
|
|
1595
|
+
const userId = c.get("userId");
|
|
1596
|
+
const nodes = await listNodesForCaller(nestId, userId);
|
|
1597
|
+
return c.json({ count: nodes.length, nodes });
|
|
1598
|
+
});
|
|
1599
|
+
nodeRoutes.post("/", async (c) => {
|
|
1600
|
+
const body = await c.req.json();
|
|
1601
|
+
if (!body.title || !body.content) {
|
|
1602
|
+
throw new ValidationError("title and content are required");
|
|
1603
|
+
}
|
|
1604
|
+
const nestId = c.req.param("nestId");
|
|
1747
1605
|
const authorEmail = getUserEmail(c);
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
console.error("VersionManager.createVersion failed (node create)", err);
|
|
1753
|
-
}
|
|
1754
|
-
createVersion({
|
|
1755
|
-
nestId,
|
|
1756
|
-
nodeId: id,
|
|
1757
|
-
version: 1,
|
|
1606
|
+
const { node } = await createNode(
|
|
1607
|
+
nestId,
|
|
1608
|
+
{
|
|
1609
|
+
title: body.title,
|
|
1758
1610
|
content: body.content,
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
note: "Auto-published on create (no stewards configured)"
|
|
1768
|
-
});
|
|
1769
|
-
const publishedVersion = result.node.frontmatter.version || 2;
|
|
1770
|
-
createVersion({
|
|
1771
|
-
nestId,
|
|
1772
|
-
nodeId: id,
|
|
1773
|
-
version: publishedVersion,
|
|
1774
|
-
content: result.node.body || "",
|
|
1775
|
-
author: authorEmail,
|
|
1776
|
-
status: "published",
|
|
1777
|
-
tags
|
|
1778
|
-
});
|
|
1779
|
-
setApprovedVersion(nestId, id, publishedVersion, authorEmail);
|
|
1780
|
-
node = result.node;
|
|
1781
|
-
} catch (err) {
|
|
1782
|
-
console.error("publishDocument failed (node create auto-publish)", err);
|
|
1783
|
-
createVersion({
|
|
1784
|
-
nestId,
|
|
1785
|
-
nodeId: id,
|
|
1786
|
-
version: 1,
|
|
1787
|
-
content: body.content,
|
|
1788
|
-
author: authorEmail,
|
|
1789
|
-
status: "draft",
|
|
1790
|
-
tags
|
|
1791
|
-
});
|
|
1792
|
-
}
|
|
1793
|
-
}
|
|
1794
|
-
trackEvent("node.create", { nestId, nodeId: id });
|
|
1795
|
-
const resolved = resolveStewardsForNode(nestId, id);
|
|
1611
|
+
type: body.type,
|
|
1612
|
+
tags: body.tags,
|
|
1613
|
+
scope: body.scope,
|
|
1614
|
+
status: body.status
|
|
1615
|
+
},
|
|
1616
|
+
authorEmail
|
|
1617
|
+
);
|
|
1618
|
+
const resolved = resolveStewardsForNode(nestId, node.id);
|
|
1796
1619
|
return c.json({
|
|
1797
1620
|
node: toNodeResponse(node),
|
|
1798
1621
|
stewards: resolved.length > 0 ? resolved.map((r) => ({
|
|
@@ -1805,7 +1628,7 @@ nodeRoutes.post("/", async (c) => {
|
|
|
1805
1628
|
nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
|
|
1806
1629
|
const nestId = c.req.param("nestId");
|
|
1807
1630
|
const nodeId = c.req.param("nodeId");
|
|
1808
|
-
const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-
|
|
1631
|
+
const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-3XGX7QIN.js");
|
|
1809
1632
|
const { stewards, fallbackToOwner, ownerEmail } = resolveStewardsWithFallback2(
|
|
1810
1633
|
nestId,
|
|
1811
1634
|
nodeId
|
|
@@ -1826,7 +1649,7 @@ nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
|
|
|
1826
1649
|
nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
|
|
1827
1650
|
const nestId = c.req.param("nestId");
|
|
1828
1651
|
const nodeId = c.req.param("nodeId");
|
|
1829
|
-
const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-
|
|
1652
|
+
const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-UODXLAOJ.js");
|
|
1830
1653
|
const allVersions = getVersions2(nestId, nodeId);
|
|
1831
1654
|
const approved = getApprovedVersion2(nestId, nodeId);
|
|
1832
1655
|
const db = getDb();
|
|
@@ -1857,16 +1680,17 @@ nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
|
|
|
1857
1680
|
nodeRoutes.get("/:nodeId{.+?}/reviews", async (c) => {
|
|
1858
1681
|
const nestId = c.req.param("nestId");
|
|
1859
1682
|
const nodeId = c.req.param("nodeId");
|
|
1860
|
-
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-
|
|
1683
|
+
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-3OJIPYNV.js");
|
|
1861
1684
|
const history = getReviewHistory2(nestId, nodeId);
|
|
1862
1685
|
return c.json({ reviews: history });
|
|
1863
1686
|
});
|
|
1864
1687
|
nodeRoutes.get("/:nodeId{.+}", async (c) => {
|
|
1865
1688
|
const nestId = c.req.param("nestId");
|
|
1866
1689
|
const nodeId = c.req.param("nodeId");
|
|
1867
|
-
const { storage } = engineCache.get(nestId);
|
|
1868
|
-
const
|
|
1869
|
-
|
|
1690
|
+
const { storage, versions: versionManager } = engineCache.get(nestId);
|
|
1691
|
+
const userId = c.get("userId");
|
|
1692
|
+
const userEmail = resolveCallerEmail(userId);
|
|
1693
|
+
if (!canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
1870
1694
|
return c.json(
|
|
1871
1695
|
{ error: "Access denied \u2014 no steward assignment for this node" },
|
|
1872
1696
|
403
|
|
@@ -1886,15 +1710,49 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
|
|
|
1886
1710
|
} catch (err) {
|
|
1887
1711
|
console.error("[external-edit] stage-on-read failed:", err);
|
|
1888
1712
|
}
|
|
1713
|
+
try {
|
|
1714
|
+
const latest = await loadLatestApprovedNode(nestId, nodeId);
|
|
1715
|
+
if (latest) {
|
|
1716
|
+
node = { ...latest, pendingChange: node.pendingChange };
|
|
1717
|
+
}
|
|
1718
|
+
} catch (err) {
|
|
1719
|
+
console.error("[external-edit] reconstruct-latest failed:", err);
|
|
1720
|
+
}
|
|
1889
1721
|
}
|
|
1890
1722
|
const response = toNodeResponse(node);
|
|
1723
|
+
if (isPublicReader(nestId, userId)) {
|
|
1724
|
+
const approved = getApprovedVersion(nestId, nodeId);
|
|
1725
|
+
if (approved != null) {
|
|
1726
|
+
try {
|
|
1727
|
+
const raw = await versionManager.reconstructVersion(
|
|
1728
|
+
nodeId,
|
|
1729
|
+
approved
|
|
1730
|
+
);
|
|
1731
|
+
response.content = bodyOnly(nodeId, raw);
|
|
1732
|
+
} catch (err) {
|
|
1733
|
+
console.error(
|
|
1734
|
+
"reconstructVersion failed (public single)",
|
|
1735
|
+
nodeId,
|
|
1736
|
+
approved,
|
|
1737
|
+
err
|
|
1738
|
+
);
|
|
1739
|
+
response.content = "";
|
|
1740
|
+
}
|
|
1741
|
+
response.version = approved;
|
|
1742
|
+
response.status = "published";
|
|
1743
|
+
return c.json({ node: response });
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1891
1746
|
response.status = node.pendingChange ? "external_edit_pending" : getDisplayStatus(nestId, nodeId);
|
|
1747
|
+
if (response.status === "pending_review") {
|
|
1748
|
+
const pending = getPendingReview(nestId, nodeId);
|
|
1749
|
+
response.pendingReviewBy = pending?.requestedBy ?? null;
|
|
1750
|
+
}
|
|
1892
1751
|
return c.json({ node: response });
|
|
1893
1752
|
});
|
|
1894
1753
|
nodeRoutes.patch("/:nodeId{.+}", async (c) => {
|
|
1895
1754
|
const nestId = c.req.param("nestId");
|
|
1896
1755
|
const nodeId = c.req.param("nodeId");
|
|
1897
|
-
const { storage, versions: versionManager } = engineCache.get(nestId);
|
|
1898
1756
|
const body = await c.req.json();
|
|
1899
1757
|
const baseVersionHeader = c.req.header("X-Base-Version");
|
|
1900
1758
|
if (baseVersionHeader) {
|
|
@@ -1914,127 +1772,20 @@ nodeRoutes.patch("/:nodeId{.+}", async (c) => {
|
|
|
1914
1772
|
);
|
|
1915
1773
|
}
|
|
1916
1774
|
}
|
|
1917
|
-
let node;
|
|
1918
|
-
try {
|
|
1919
|
-
node = await storage.readDocument(nodeId);
|
|
1920
|
-
} catch {
|
|
1921
|
-
throw new NotFoundError(`Node not found: ${nodeId}`);
|
|
1922
|
-
}
|
|
1923
|
-
if (body.content !== void 0) {
|
|
1924
|
-
node = { ...node, body: body.content };
|
|
1925
|
-
}
|
|
1926
|
-
if (body.append) {
|
|
1927
|
-
node = { ...node, body: (node.body || "") + "\n\n" + body.append };
|
|
1928
|
-
}
|
|
1929
|
-
if (body.tags) {
|
|
1930
|
-
const newTags = body.tags.map((t) => t.startsWith("#") ? t : `#${t}`);
|
|
1931
|
-
const merged = [.../* @__PURE__ */ new Set([...node.frontmatter.tags || [], ...newTags])];
|
|
1932
|
-
node = { ...node, frontmatter: { ...node.frontmatter, tags: merged } };
|
|
1933
|
-
}
|
|
1934
|
-
if (body.status) {
|
|
1935
|
-
node = {
|
|
1936
|
-
...node,
|
|
1937
|
-
frontmatter: { ...node.frontmatter, status: body.status }
|
|
1938
|
-
};
|
|
1939
|
-
}
|
|
1940
|
-
if (body.title) {
|
|
1941
|
-
node = {
|
|
1942
|
-
...node,
|
|
1943
|
-
frontmatter: { ...node.frontmatter, title: body.title }
|
|
1944
|
-
};
|
|
1945
|
-
}
|
|
1946
1775
|
const authorEmail = getUserEmail(c);
|
|
1947
|
-
const
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
const newVersion = currentVersion + 1;
|
|
1957
|
-
node = {
|
|
1958
|
-
...node,
|
|
1959
|
-
frontmatter: {
|
|
1960
|
-
...node.frontmatter,
|
|
1961
|
-
version: newVersion,
|
|
1962
|
-
updated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1963
|
-
// Strip the stale published-state checksum — the body just
|
|
1964
|
-
// changed, so leaving the old hash would make the next GET (with
|
|
1965
|
-
// verifyChecksum: true) flag the file as an external edit even
|
|
1966
|
-
// though *this* app wrote it. Engine treats absent checksum as
|
|
1967
|
-
// "no baseline" and skips drift detection until publish runs.
|
|
1968
|
-
checksum: void 0
|
|
1969
|
-
}
|
|
1970
|
-
};
|
|
1971
|
-
const fm = Object.fromEntries(
|
|
1972
|
-
Object.entries(node.frontmatter).filter(([, v]) => v !== void 0)
|
|
1973
|
-
);
|
|
1974
|
-
node = { ...node, frontmatter: fm };
|
|
1975
|
-
await storage.writeDocument(nodeId, serializeDocument(node));
|
|
1976
|
-
syncNodeTags(nestId, nodeId, currentTags);
|
|
1977
|
-
try {
|
|
1978
|
-
await versionManager.createVersion(node, authorEmail, {
|
|
1979
|
-
note: body.changeNote
|
|
1980
|
-
});
|
|
1981
|
-
} catch (err) {
|
|
1982
|
-
console.error("VersionManager.createVersion failed (node patch)", err);
|
|
1983
|
-
}
|
|
1984
|
-
createVersion({
|
|
1985
|
-
nestId,
|
|
1986
|
-
nodeId,
|
|
1987
|
-
version: newVersion,
|
|
1988
|
-
content: node.body || "",
|
|
1989
|
-
author: authorEmail,
|
|
1990
|
-
status: "draft",
|
|
1991
|
-
tags: currentTags,
|
|
1992
|
-
changeNote: body.changeNote
|
|
1993
|
-
});
|
|
1994
|
-
responseVersion = newVersion;
|
|
1995
|
-
} else {
|
|
1996
|
-
node = {
|
|
1997
|
-
...node,
|
|
1998
|
-
frontmatter: {
|
|
1999
|
-
...node.frontmatter,
|
|
2000
|
-
updated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2001
|
-
// Drop the prior published-state checksum before the interim
|
|
2002
|
-
// write — publish recomputes it from the new body, but if
|
|
2003
|
-
// publish errors out the file would otherwise be left with new
|
|
2004
|
-
// body + stale checksum and drift on next read.
|
|
2005
|
-
checksum: void 0
|
|
2006
|
-
}
|
|
2007
|
-
};
|
|
2008
|
-
const fm = Object.fromEntries(
|
|
2009
|
-
Object.entries(node.frontmatter).filter(([, v]) => v !== void 0)
|
|
2010
|
-
);
|
|
2011
|
-
node = { ...node, frontmatter: fm };
|
|
2012
|
-
await storage.writeDocument(nodeId, serializeDocument(node));
|
|
2013
|
-
syncNodeTags(nestId, nodeId, currentTags);
|
|
2014
|
-
let publishedVersion = (node.frontmatter.version || 0) + 1;
|
|
2015
|
-
try {
|
|
2016
|
-
const result = await safePublishDocument(storage, nodeId, {
|
|
2017
|
-
editedBy: authorEmail,
|
|
2018
|
-
note: body.changeNote || "Auto-published on edit (no stewards)"
|
|
2019
|
-
});
|
|
2020
|
-
publishedVersion = result.node.frontmatter.version || publishedVersion;
|
|
2021
|
-
node = result.node;
|
|
2022
|
-
} catch (err) {
|
|
2023
|
-
console.error("publishDocument failed (node patch auto-publish)", err);
|
|
2024
|
-
}
|
|
2025
|
-
createVersion({
|
|
2026
|
-
nestId,
|
|
2027
|
-
nodeId,
|
|
2028
|
-
version: publishedVersion,
|
|
2029
|
-
content: node.body || "",
|
|
2030
|
-
author: authorEmail,
|
|
2031
|
-
status: "published",
|
|
2032
|
-
tags: currentTags,
|
|
1776
|
+
const { node, version: responseVersion } = await updateNode(
|
|
1777
|
+
nestId,
|
|
1778
|
+
nodeId,
|
|
1779
|
+
{
|
|
1780
|
+
content: body.content,
|
|
1781
|
+
append: body.append,
|
|
1782
|
+
tags: body.tags,
|
|
1783
|
+
title: body.title,
|
|
1784
|
+
status: body.status,
|
|
2033
1785
|
changeNote: body.changeNote
|
|
2034
|
-
}
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
}
|
|
1786
|
+
},
|
|
1787
|
+
authorEmail
|
|
1788
|
+
);
|
|
2038
1789
|
return c.json({ node: toNodeResponse(node), version: responseVersion });
|
|
2039
1790
|
});
|
|
2040
1791
|
nodeRoutes.delete("/:nodeId{.+}", async (c) => {
|
|
@@ -2058,6 +1809,10 @@ nodeRoutes.delete("/:nodeId{.+}", async (c) => {
|
|
|
2058
1809
|
db.prepare(
|
|
2059
1810
|
"DELETE FROM approved_versions WHERE nest_id = ? AND node_id = ?"
|
|
2060
1811
|
).run(nestId, nodeId);
|
|
1812
|
+
db.prepare(
|
|
1813
|
+
`DELETE FROM stewards
|
|
1814
|
+
WHERE nest_id = ? AND scope = 'document' AND node_pattern = ?`
|
|
1815
|
+
).run(nestId, nodeId);
|
|
2061
1816
|
})();
|
|
2062
1817
|
trackEvent("node.delete", { nestId, nodeId });
|
|
2063
1818
|
return c.json({ deleted: true });
|
|
@@ -2327,9 +2082,10 @@ queryRoutes.post("/context", async (c) => {
|
|
|
2327
2082
|
}
|
|
2328
2083
|
}
|
|
2329
2084
|
}
|
|
2330
|
-
const
|
|
2085
|
+
const userId = c.get("userId");
|
|
2086
|
+
const userEmail = resolveCallerEmail(userId);
|
|
2331
2087
|
const beforePermission = documents.length;
|
|
2332
|
-
const accessible = filterAccessible(nestId, userEmail, documents);
|
|
2088
|
+
const accessible = filterAccessible(nestId, userId, userEmail, documents);
|
|
2333
2089
|
const permissionFiltered = beforePermission - accessible.length;
|
|
2334
2090
|
const included = [];
|
|
2335
2091
|
let tokenCount = 0;
|
|
@@ -2386,8 +2142,9 @@ queryRoutes.post("/query", async (c) => {
|
|
|
2386
2142
|
const result = await queryEngine.query(body.query, {
|
|
2387
2143
|
hops: body.hops ?? 2
|
|
2388
2144
|
});
|
|
2389
|
-
const
|
|
2390
|
-
const
|
|
2145
|
+
const userId = c.get("userId");
|
|
2146
|
+
const userEmail = resolveCallerEmail(userId);
|
|
2147
|
+
const accessible = filterAccessible(nestId, userId, userEmail, result.documents);
|
|
2391
2148
|
return c.json({
|
|
2392
2149
|
query: body.query,
|
|
2393
2150
|
count: accessible.length,
|
|
@@ -2418,8 +2175,9 @@ queryRoutes.get("/search", async (c) => {
|
|
|
2418
2175
|
].join(" ").toLowerCase();
|
|
2419
2176
|
return terms.every((term) => haystack.includes(term));
|
|
2420
2177
|
});
|
|
2421
|
-
const
|
|
2422
|
-
const
|
|
2178
|
+
const userId = c.get("userId");
|
|
2179
|
+
const userEmail = resolveCallerEmail(userId);
|
|
2180
|
+
const accessible = filterAccessible(nestId, userId, userEmail, matches);
|
|
2423
2181
|
return c.json({
|
|
2424
2182
|
query: q,
|
|
2425
2183
|
count: accessible.length,
|
|
@@ -2520,7 +2278,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2520
2278
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
2521
2279
|
|
|
2522
2280
|
// src/mcp/tools.ts
|
|
2523
|
-
import { serializeDocument as serializeDocument3 } from "@promptowl/contextnest-engine";
|
|
2524
2281
|
var TOOL_DEFINITIONS = [
|
|
2525
2282
|
{
|
|
2526
2283
|
name: "context_init",
|
|
@@ -2733,6 +2490,21 @@ var TOOL_DEFINITIONS = [
|
|
|
2733
2490
|
},
|
|
2734
2491
|
required: ["email", "scope"]
|
|
2735
2492
|
}
|
|
2493
|
+
},
|
|
2494
|
+
{
|
|
2495
|
+
name: "context_share_nest",
|
|
2496
|
+
description: "Share this nest with another person by granting them access. This is plain access sharing (not a steward role): viewer = read, editor = read+edit, admin = full access except deleting the nest.",
|
|
2497
|
+
inputSchema: {
|
|
2498
|
+
type: "object",
|
|
2499
|
+
properties: {
|
|
2500
|
+
email: { type: "string", description: "Email of the person to share with" },
|
|
2501
|
+
permission: {
|
|
2502
|
+
type: "string",
|
|
2503
|
+
description: "Access level: read (viewer, default), write (editor), or admin"
|
|
2504
|
+
}
|
|
2505
|
+
},
|
|
2506
|
+
required: ["email"]
|
|
2507
|
+
}
|
|
2736
2508
|
}
|
|
2737
2509
|
];
|
|
2738
2510
|
async function resolveLlmBody(ctx, node) {
|
|
@@ -2848,24 +2620,14 @@ ${list}`;
|
|
|
2848
2620
|
${body || "(no content)"}`;
|
|
2849
2621
|
}
|
|
2850
2622
|
case "context_list": {
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
}
|
|
2858
|
-
|
|
2859
|
-
docs = docs.filter(
|
|
2860
|
-
(n) => getApprovedVersion(nestId, n.id) != null
|
|
2861
|
-
);
|
|
2862
|
-
}
|
|
2863
|
-
docs = docs.slice(0, args.limit || 50);
|
|
2864
|
-
if (!docs.length) return "No nodes found with the given filters.";
|
|
2865
|
-
const list = docs.map(
|
|
2866
|
-
(n, i) => `${i + 1}. **${n.frontmatter.title}** [${n.frontmatter.type || "document"}]`
|
|
2867
|
-
).join("\n");
|
|
2868
|
-
return `${docs.length} node(s):
|
|
2623
|
+
const nodes = await listNodesForCallerByEmail(nestId, userEmail, {
|
|
2624
|
+
type: args.type,
|
|
2625
|
+
tag: args.tag,
|
|
2626
|
+
limit: args.limit || 50
|
|
2627
|
+
});
|
|
2628
|
+
if (!nodes.length) return "No nodes found with the given filters.";
|
|
2629
|
+
const list = nodes.map((n, i) => `${i + 1}. **${n.title}** [${n.type}]`).join("\n");
|
|
2630
|
+
return `${nodes.length} node(s):
|
|
2869
2631
|
|
|
2870
2632
|
${list}`;
|
|
2871
2633
|
}
|
|
@@ -2886,50 +2648,21 @@ ${n.body || ""}`;
|
|
|
2886
2648
|
return resolved.join("\n\n---\n\n") || "No nodes resolved.";
|
|
2887
2649
|
}
|
|
2888
2650
|
case "context_create": {
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2892
|
-
const tags = (args.tags || []).map(
|
|
2893
|
-
(t) => t.startsWith("#") ? t : `#${t}`
|
|
2894
|
-
);
|
|
2895
|
-
const hasStewards = isStewardshipEnabled(nestId);
|
|
2896
|
-
const initialStatus = hasStewards ? "draft" : "approved";
|
|
2897
|
-
const node = {
|
|
2898
|
-
id,
|
|
2899
|
-
filePath: "",
|
|
2900
|
-
frontmatter: {
|
|
2901
|
-
title: args.title,
|
|
2902
|
-
type: args.type || "document",
|
|
2903
|
-
tags,
|
|
2904
|
-
status: initialStatus,
|
|
2905
|
-
version: 1,
|
|
2906
|
-
created_at: now,
|
|
2907
|
-
updated_at: now,
|
|
2908
|
-
metadata: { owners: ["*"], scope: args.scope || "team" }
|
|
2909
|
-
},
|
|
2910
|
-
body: args.content,
|
|
2911
|
-
rawContent: ""
|
|
2912
|
-
};
|
|
2913
|
-
await storage.writeDocument(id, serializeDocument3(node));
|
|
2914
|
-
syncNodeTags(nestId, id, tags);
|
|
2915
|
-
try {
|
|
2916
|
-
await versionManager.createVersion(node, userEmail);
|
|
2917
|
-
} catch (err) {
|
|
2918
|
-
console.error("VersionManager.createVersion failed (mcp create)", err);
|
|
2651
|
+
if (!canCreateInNest(nestId, userEmail)) {
|
|
2652
|
+
return "You don't have permission to create documents in this nest.";
|
|
2919
2653
|
}
|
|
2920
|
-
|
|
2654
|
+
const { node } = await createNode(
|
|
2921
2655
|
nestId,
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
}
|
|
2932
|
-
return `Created node: **${args.title}** (${id}) \u2014 status: ${initialStatus}`;
|
|
2656
|
+
{
|
|
2657
|
+
title: args.title,
|
|
2658
|
+
content: args.content,
|
|
2659
|
+
type: args.type,
|
|
2660
|
+
tags: args.tags,
|
|
2661
|
+
scope: args.scope
|
|
2662
|
+
},
|
|
2663
|
+
userEmail
|
|
2664
|
+
);
|
|
2665
|
+
return `Created node: **${args.title}** (${node.id}) \u2014 status: ${node.frontmatter.status}`;
|
|
2933
2666
|
}
|
|
2934
2667
|
case "context_update": {
|
|
2935
2668
|
const docs = await storage.discoverDocuments();
|
|
@@ -2937,56 +2670,21 @@ ${n.body || ""}`;
|
|
|
2937
2670
|
(n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
|
|
2938
2671
|
);
|
|
2939
2672
|
if (!node) return `Node not found: ${args.title}`;
|
|
2940
|
-
|
|
2941
|
-
if (
|
|
2942
|
-
|
|
2943
|
-
let tags = node.frontmatter.tags || [];
|
|
2944
|
-
if (args.tags) {
|
|
2945
|
-
const newTags = args.tags.map(
|
|
2946
|
-
(t) => t.startsWith("#") ? t : `#${t}`
|
|
2947
|
-
);
|
|
2948
|
-
tags = [.../* @__PURE__ */ new Set([...tags, ...newTags])];
|
|
2949
|
-
}
|
|
2950
|
-
const prevVersion = getCurrentVersion(nestId, node.id);
|
|
2951
|
-
const newVersion = prevVersion + 1;
|
|
2952
|
-
const hasStewards = isStewardshipEnabled(nestId);
|
|
2953
|
-
const updated = {
|
|
2954
|
-
...node,
|
|
2955
|
-
body,
|
|
2956
|
-
frontmatter: {
|
|
2957
|
-
...node.frontmatter,
|
|
2958
|
-
tags,
|
|
2959
|
-
version: newVersion,
|
|
2960
|
-
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2961
|
-
}
|
|
2962
|
-
};
|
|
2963
|
-
await storage.writeDocument(node.id, serializeDocument3(updated));
|
|
2964
|
-
try {
|
|
2965
|
-
await versionManager.createVersion(updated, userEmail);
|
|
2966
|
-
} catch (err) {
|
|
2967
|
-
console.error("VersionManager.createVersion failed (mcp update)", err);
|
|
2673
|
+
const editCheck = canUserEdit(nestId, node.id, userEmail);
|
|
2674
|
+
if (!editCheck.allowed) {
|
|
2675
|
+
return `You don't have permission to edit "${args.title}": ${editCheck.reason}`;
|
|
2968
2676
|
}
|
|
2969
|
-
|
|
2970
|
-
createVersion({
|
|
2677
|
+
const { node: updated } = await updateNode(
|
|
2971
2678
|
nestId,
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
nestId,
|
|
2982
|
-
nodeId: node.id,
|
|
2983
|
-
cancelledBy: userEmail
|
|
2984
|
-
});
|
|
2985
|
-
}
|
|
2986
|
-
if (!hasStewards) {
|
|
2987
|
-
setApprovedVersion(nestId, node.id, newVersion, userEmail);
|
|
2988
|
-
}
|
|
2989
|
-
return `Updated node: **${node.frontmatter.title}**`;
|
|
2679
|
+
node.id,
|
|
2680
|
+
{
|
|
2681
|
+
content: args.content,
|
|
2682
|
+
append: args.append,
|
|
2683
|
+
tags: args.tags
|
|
2684
|
+
},
|
|
2685
|
+
userEmail
|
|
2686
|
+
);
|
|
2687
|
+
return `Updated node: **${updated.frontmatter.title}**`;
|
|
2990
2688
|
}
|
|
2991
2689
|
// ─── Governance Tool Handlers ──────────────────────────────────────
|
|
2992
2690
|
case "context_stewards": {
|
|
@@ -3007,13 +2705,16 @@ ${n.body || ""}`;
|
|
|
3007
2705
|
|
|
3008
2706
|
${list}`;
|
|
3009
2707
|
}
|
|
3010
|
-
|
|
2708
|
+
if (!canManageStewards(ctx.userEmail)) {
|
|
2709
|
+
return "You don't have permission to list stewards. Only the super admin can do this.";
|
|
2710
|
+
}
|
|
2711
|
+
const allStewards = await getStewardsForNest(ctx.nestId);
|
|
3011
2712
|
if (allStewards.length === 0) {
|
|
3012
2713
|
return "No stewards configured. All changes are auto-approved.\n\nTo configure stewards, add a `stewards.yaml` file to the vault root, or use `context_assign_steward`.";
|
|
3013
2714
|
}
|
|
3014
2715
|
const byScope = {};
|
|
3015
2716
|
for (const s of allStewards) {
|
|
3016
|
-
const key = s.scope === "nest" ? "Nest-level" : s.scope === "tag" ? `Tag: ${s.tagName}` : `Document: ${s.nodePattern}`;
|
|
2717
|
+
const key = s.scope === "nest" ? "Nest-level" : s.scope === "tag" ? `Tag: ${s.tagName}` : `Document: ${s.nodeTitle || s.nodePattern}`;
|
|
3017
2718
|
(byScope[key] = byScope[key] || []).push(s);
|
|
3018
2719
|
}
|
|
3019
2720
|
const sections = Object.entries(byScope).map(
|
|
@@ -3026,7 +2727,7 @@ ${sections}`;
|
|
|
3026
2727
|
}
|
|
3027
2728
|
case "context_review_queue": {
|
|
3028
2729
|
const status = args.status || "pending";
|
|
3029
|
-
const result = getReviewQueue({
|
|
2730
|
+
const result = await getReviewQueue({
|
|
3030
2731
|
nestId: ctx.nestId,
|
|
3031
2732
|
status
|
|
3032
2733
|
});
|
|
@@ -3034,7 +2735,7 @@ ${sections}`;
|
|
|
3034
2735
|
return status === "pending" ? "No documents pending review. All caught up!" : `No reviews with status "${status}".`;
|
|
3035
2736
|
}
|
|
3036
2737
|
const list = result.requests.map(
|
|
3037
|
-
(r, i) => `${i + 1}. **${r.nodeId}** v${r.version} \u2014 ${r.priority} priority
|
|
2738
|
+
(r, i) => `${i + 1}. **${r.title || r.nodeId}** v${r.version} \u2014 ${r.priority} priority
|
|
3038
2739
|
Submitted by: ${r.requestedBy} at ${r.requestedAt}${r.requestNote ? `
|
|
3039
2740
|
Note: "${r.requestNote}"` : ""}`
|
|
3040
2741
|
).join("\n\n");
|
|
@@ -3049,6 +2750,10 @@ ${list}`;
|
|
|
3049
2750
|
(n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
|
|
3050
2751
|
);
|
|
3051
2752
|
if (!node) return `Node not found: ${args.title}`;
|
|
2753
|
+
const submitCheck = canUserEdit(ctx.nestId, node.id, userEmail);
|
|
2754
|
+
if (!submitCheck.allowed) {
|
|
2755
|
+
return `You don't have permission to submit "${args.title}" for review: ${submitCheck.reason}`;
|
|
2756
|
+
}
|
|
3052
2757
|
const currentVersion = getCurrentVersion(ctx.nestId, node.id);
|
|
3053
2758
|
if (currentVersion === 0) return `No versions found for "${args.title}"`;
|
|
3054
2759
|
try {
|
|
@@ -3139,17 +2844,17 @@ ${list}`;
|
|
|
3139
2844
|
if (!["nest", "tag", "document"].includes(scope)) {
|
|
3140
2845
|
return `Invalid scope "${args.scope}". Use: nest, tag, or document.`;
|
|
3141
2846
|
}
|
|
2847
|
+
if (!canManageStewards(ctx.userEmail)) {
|
|
2848
|
+
return "You don't have permission to manage stewards. Only the super admin can do this.";
|
|
2849
|
+
}
|
|
3142
2850
|
try {
|
|
3143
|
-
|
|
2851
|
+
await createStewardRecord({
|
|
3144
2852
|
nestId: ctx.nestId,
|
|
3145
2853
|
scope,
|
|
3146
|
-
|
|
2854
|
+
documentId: scope === "document" ? args.target : void 0,
|
|
3147
2855
|
tagName: scope === "tag" ? args.target : void 0,
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
assignedBy: ctx.userEmail,
|
|
3151
|
-
assignedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3152
|
-
isActive: true
|
|
2856
|
+
users: [{ email: args.email, role: args.role || "reviewer" }],
|
|
2857
|
+
assignedBy: ctx.userEmail
|
|
3153
2858
|
});
|
|
3154
2859
|
const targetDesc = scope === "nest" ? "all documents" : `${scope}: ${args.target}`;
|
|
3155
2860
|
return `Assigned **${args.email}** as ${args.role || "reviewer"} for ${targetDesc}.`;
|
|
@@ -3157,6 +2862,25 @@ ${list}`;
|
|
|
3157
2862
|
return `Failed to assign steward: ${err.message}`;
|
|
3158
2863
|
}
|
|
3159
2864
|
}
|
|
2865
|
+
case "context_share_nest": {
|
|
2866
|
+
const roles = resolveUserRoles(ctx.nestId, ctx.userEmail);
|
|
2867
|
+
if (!canManageWith(roles)) {
|
|
2868
|
+
return "You don't have permission to share this nest. Only the nest owner or an admin can add people.";
|
|
2869
|
+
}
|
|
2870
|
+
const permission = args.permission || "read";
|
|
2871
|
+
try {
|
|
2872
|
+
await addCollaborator({
|
|
2873
|
+
nestId: ctx.nestId,
|
|
2874
|
+
email: args.email,
|
|
2875
|
+
permission,
|
|
2876
|
+
grantedByEmail: ctx.userEmail
|
|
2877
|
+
});
|
|
2878
|
+
const label = permission === "admin" ? "admin" : permission === "write" ? "editor" : "viewer";
|
|
2879
|
+
return `Shared this nest with **${args.email}** as ${label}.`;
|
|
2880
|
+
} catch (err) {
|
|
2881
|
+
return `Failed to share nest: ${err.message}`;
|
|
2882
|
+
}
|
|
2883
|
+
}
|
|
3160
2884
|
default:
|
|
3161
2885
|
return `Unknown tool: ${toolName}`;
|
|
3162
2886
|
}
|
|
@@ -3225,8 +2949,8 @@ mcpRoutes.all("/", async (c) => {
|
|
|
3225
2949
|
import { Hono as Hono7 } from "hono";
|
|
3226
2950
|
|
|
3227
2951
|
// src/governance/stewards-parser.ts
|
|
3228
|
-
import { readFileSync
|
|
3229
|
-
import { join as
|
|
2952
|
+
import { readFileSync, existsSync } from "fs";
|
|
2953
|
+
import { join as join2 } from "path";
|
|
3230
2954
|
function parseStewardsYaml(content) {
|
|
3231
2955
|
const result = { version: 1 };
|
|
3232
2956
|
const lines = content.split("\n");
|
|
@@ -3291,15 +3015,15 @@ function parseEntry(str) {
|
|
|
3291
3015
|
}
|
|
3292
3016
|
function loadStewardsConfig(nestId) {
|
|
3293
3017
|
const dataRoot = config.DATA_ROOT;
|
|
3294
|
-
const
|
|
3018
|
+
const nestPath = join2(dataRoot, "nests", nestId);
|
|
3295
3019
|
const candidates = [
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3020
|
+
join2(nestPath, "stewards.yaml"),
|
|
3021
|
+
join2(nestPath, "stewards.yml"),
|
|
3022
|
+
join2(nestPath, ".context", "stewards.yaml")
|
|
3299
3023
|
];
|
|
3300
3024
|
for (const candidatePath of candidates) {
|
|
3301
|
-
if (
|
|
3302
|
-
const content =
|
|
3025
|
+
if (existsSync(candidatePath)) {
|
|
3026
|
+
const content = readFileSync(candidatePath, "utf-8");
|
|
3303
3027
|
return parseStewardsYaml(content);
|
|
3304
3028
|
}
|
|
3305
3029
|
}
|
|
@@ -3312,12 +3036,25 @@ governanceRoutes.get("/stewards", async (c) => {
|
|
|
3312
3036
|
const nestId = c.req.param("nestId");
|
|
3313
3037
|
const scope = c.req.query("scope");
|
|
3314
3038
|
const search = c.req.query("search");
|
|
3315
|
-
const stewards = listStewards({
|
|
3039
|
+
const stewards = await listStewards({
|
|
3316
3040
|
nestId,
|
|
3317
3041
|
scope: scope || void 0,
|
|
3318
3042
|
search: search || void 0
|
|
3319
3043
|
});
|
|
3320
|
-
|
|
3044
|
+
const cache = /* @__PURE__ */ new Map();
|
|
3045
|
+
const enriched = stewards.map((s) => {
|
|
3046
|
+
const key = s.userEmail.toLowerCase();
|
|
3047
|
+
let merged = cache.get(key);
|
|
3048
|
+
if (!merged) {
|
|
3049
|
+
merged = {
|
|
3050
|
+
collaboratorRole: getCollaboratorRole(nestId, s.userEmail),
|
|
3051
|
+
roles: resolveUserRoles(nestId, s.userEmail)
|
|
3052
|
+
};
|
|
3053
|
+
cache.set(key, merged);
|
|
3054
|
+
}
|
|
3055
|
+
return { ...s, ...merged };
|
|
3056
|
+
});
|
|
3057
|
+
return c.json({ stewards: enriched });
|
|
3321
3058
|
});
|
|
3322
3059
|
governanceRoutes.post("/stewards", async (c) => {
|
|
3323
3060
|
const nestId = c.req.param("nestId");
|
|
@@ -3358,6 +3095,20 @@ governanceRoutes.post("/stewards", async (c) => {
|
|
|
3358
3095
|
});
|
|
3359
3096
|
return c.json({ steward: created[0] }, 201);
|
|
3360
3097
|
});
|
|
3098
|
+
governanceRoutes.patch("/stewards/:stewardId", async (c) => {
|
|
3099
|
+
const stewardId = c.req.param("stewardId");
|
|
3100
|
+
const body = await c.req.json();
|
|
3101
|
+
if (!body.role && !body.scope) {
|
|
3102
|
+
throw new ValidationError("role or scope is required");
|
|
3103
|
+
}
|
|
3104
|
+
const steward = updateSteward(stewardId, {
|
|
3105
|
+
role: body.role,
|
|
3106
|
+
scope: body.scope,
|
|
3107
|
+
documentId: body.nodePattern,
|
|
3108
|
+
tagName: body.tagName
|
|
3109
|
+
});
|
|
3110
|
+
return c.json({ steward });
|
|
3111
|
+
});
|
|
3361
3112
|
governanceRoutes.delete("/stewards/:stewardId", async (c) => {
|
|
3362
3113
|
const stewardId = c.req.param("stewardId");
|
|
3363
3114
|
removeSteward(stewardId);
|
|
@@ -3377,7 +3128,7 @@ governanceRoutes.get("/review-queue", async (c) => {
|
|
|
3377
3128
|
const status = c.req.query("status") || "pending";
|
|
3378
3129
|
const limit = parseInt(c.req.query("limit") || "50", 10);
|
|
3379
3130
|
const offset = parseInt(c.req.query("offset") || "0", 10);
|
|
3380
|
-
const result = getReviewQueue({
|
|
3131
|
+
const result = await getReviewQueue({
|
|
3381
3132
|
nestId,
|
|
3382
3133
|
status,
|
|
3383
3134
|
limit,
|
|
@@ -3652,14 +3403,14 @@ function ensureAnonymousUser() {
|
|
|
3652
3403
|
// src/app.ts
|
|
3653
3404
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
3654
3405
|
import { fileURLToPath } from "url";
|
|
3655
|
-
import { dirname, join as
|
|
3656
|
-
import { existsSync as
|
|
3406
|
+
import { dirname, join as join3, relative } from "path";
|
|
3407
|
+
import { existsSync as existsSync2 } from "fs";
|
|
3657
3408
|
var HERE = dirname(fileURLToPath(import.meta.url));
|
|
3658
3409
|
var UI_DIR_CANDIDATES = [
|
|
3659
|
-
|
|
3660
|
-
|
|
3410
|
+
join3(HERE, "web3"),
|
|
3411
|
+
join3(process.cwd(), "dist", "web3")
|
|
3661
3412
|
];
|
|
3662
|
-
var UI_DIR_ABS = UI_DIR_CANDIDATES.find((p) =>
|
|
3413
|
+
var UI_DIR_ABS = UI_DIR_CANDIDATES.find((p) => existsSync2(p)) || UI_DIR_CANDIDATES[0];
|
|
3663
3414
|
var UI_DIR_REL = relative(process.cwd(), UI_DIR_ABS) || ".";
|
|
3664
3415
|
var openModeMiddleware = createMiddleware2(async (c, next) => {
|
|
3665
3416
|
const anonId = ensureAnonymousUser();
|
|
@@ -3667,6 +3418,11 @@ var openModeMiddleware = createMiddleware2(async (c, next) => {
|
|
|
3667
3418
|
c.set("nestScope", null);
|
|
3668
3419
|
await next();
|
|
3669
3420
|
});
|
|
3421
|
+
function isPublicReadEligiblePath(method, path) {
|
|
3422
|
+
if (method !== "GET") return false;
|
|
3423
|
+
if (!/^\/nests\/[^/]+(\/.*)?$/.test(path)) return false;
|
|
3424
|
+
return !/\/(collaborators|visibility|settings|mcp)/.test(path);
|
|
3425
|
+
}
|
|
3670
3426
|
var flexAuthMiddleware = createMiddleware2(async (c, next) => {
|
|
3671
3427
|
const hasBearer = c.req.header("Authorization")?.startsWith("Bearer cnst_");
|
|
3672
3428
|
const hasCookie = !!c.req.header("Cookie")?.includes("cnst_session=");
|
|
@@ -3679,6 +3435,12 @@ var flexAuthMiddleware = createMiddleware2(async (c, next) => {
|
|
|
3679
3435
|
c.set("nestScope", null);
|
|
3680
3436
|
return next();
|
|
3681
3437
|
}
|
|
3438
|
+
if (isPublicReadEligiblePath(c.req.method, c.req.path)) {
|
|
3439
|
+
const anonId = ensureAnonymousUser();
|
|
3440
|
+
c.set("userId", anonId);
|
|
3441
|
+
c.set("nestScope", null);
|
|
3442
|
+
return next();
|
|
3443
|
+
}
|
|
3682
3444
|
return c.json({ error: "Missing or invalid credentials" }, 401);
|
|
3683
3445
|
});
|
|
3684
3446
|
function createApp() {
|
|
@@ -3710,6 +3472,7 @@ function createApp() {
|
|
|
3710
3472
|
service: "contextnest-community",
|
|
3711
3473
|
version: "0.1.0",
|
|
3712
3474
|
auth_mode: config.AUTH_MODE,
|
|
3475
|
+
logo_url: config.LOGO_URL,
|
|
3713
3476
|
...isSuspended() && { suspended_reason: getSuspensionReason() }
|
|
3714
3477
|
})
|
|
3715
3478
|
);
|
|
@@ -3797,7 +3560,7 @@ function createApp() {
|
|
|
3797
3560
|
try {
|
|
3798
3561
|
const { storage } = engineCache.get(nest.id);
|
|
3799
3562
|
const docs = await storage.discoverDocuments();
|
|
3800
|
-
documents += filterAccessible(nest.id, userEmail, docs).length;
|
|
3563
|
+
documents += filterAccessible(nest.id, userId, userEmail, docs).length;
|
|
3801
3564
|
} catch {
|
|
3802
3565
|
}
|
|
3803
3566
|
}
|
|
@@ -3859,12 +3622,42 @@ function createApp() {
|
|
|
3859
3622
|
let required = "read";
|
|
3860
3623
|
const path = c.req.path;
|
|
3861
3624
|
const isStewardActionPath = path.includes("/approve") || path.includes("/reject") || path.includes("/submit-review") || path.includes("/cancel-review");
|
|
3625
|
+
const isStewardRoster = path.includes("/stewards") && !path.includes("/nodes/");
|
|
3626
|
+
if (isStewardRoster && !canManageStewards(resolveCallerEmail(userId))) {
|
|
3627
|
+
return c.json(
|
|
3628
|
+
{
|
|
3629
|
+
error: "You don't have permission to manage stewards. Only the super admin can do this."
|
|
3630
|
+
},
|
|
3631
|
+
403
|
|
3632
|
+
);
|
|
3633
|
+
}
|
|
3862
3634
|
if (path.includes("/collaborators") || path.includes("/visibility")) {
|
|
3863
3635
|
required = "admin";
|
|
3864
3636
|
} else if (c.req.method !== "GET" && !isStewardActionPath) {
|
|
3865
3637
|
required = "write";
|
|
3866
3638
|
}
|
|
3867
|
-
|
|
3639
|
+
let stewardEditorBypass = false;
|
|
3640
|
+
if (required === "write" && permission === "read" && parts[1] === "nodes") {
|
|
3641
|
+
const userEmail = resolveCallerEmail(userId);
|
|
3642
|
+
if (parts.length >= 3 && (c.req.method === "PATCH" || c.req.method === "DELETE")) {
|
|
3643
|
+
const rawNodeId = parts.slice(2).join("/");
|
|
3644
|
+
let nodeId = rawNodeId;
|
|
3645
|
+
try {
|
|
3646
|
+
nodeId = decodeURIComponent(rawNodeId);
|
|
3647
|
+
} catch {
|
|
3648
|
+
}
|
|
3649
|
+
const resolved = resolveStewardsForNode(nestId, nodeId);
|
|
3650
|
+
stewardEditorBypass = resolved.some(
|
|
3651
|
+
(r) => r.steward.userEmail.toLowerCase() === userEmail.toLowerCase() && r.steward.role === "editor"
|
|
3652
|
+
);
|
|
3653
|
+
} else if (parts.length === 2 && c.req.method === "POST") {
|
|
3654
|
+
const resolved = resolveStewardsForNode(nestId, "");
|
|
3655
|
+
stewardEditorBypass = resolved.some(
|
|
3656
|
+
(r) => r.steward.userEmail.toLowerCase() === userEmail.toLowerCase() && r.steward.role === "editor" && r.steward.scope === "nest"
|
|
3657
|
+
);
|
|
3658
|
+
}
|
|
3659
|
+
}
|
|
3660
|
+
if (!stewardEditorBypass && permissionLevel(permission) < permissionLevel(required)) {
|
|
3868
3661
|
return c.json(
|
|
3869
3662
|
{
|
|
3870
3663
|
error: `You don't have access to perform this action on this nest. Required permission: '${required}', your permission: '${permission}'. Ask the nest owner or a server admin to grant you ${required} access.`,
|
|
@@ -3964,8 +3757,8 @@ function createApp() {
|
|
|
3964
3757
|
}
|
|
3965
3758
|
|
|
3966
3759
|
// src/db/backfill.ts
|
|
3967
|
-
import { NestStorage
|
|
3968
|
-
import { join as
|
|
3760
|
+
import { NestStorage } from "@promptowl/contextnest-engine";
|
|
3761
|
+
import { join as join4 } from "path";
|
|
3969
3762
|
var MIGRATION_ID = "005_backfill_node_versions_from_history";
|
|
3970
3763
|
async function backfillNodeVersionsFromHistory(db) {
|
|
3971
3764
|
const already = db.prepare("SELECT id FROM schema_migrations WHERE id = ?").get(MIGRATION_ID);
|
|
@@ -3986,8 +3779,8 @@ async function backfillNodeVersionsFromHistory(db) {
|
|
|
3986
3779
|
let totalInserted = 0;
|
|
3987
3780
|
let totalDocs = 0;
|
|
3988
3781
|
for (const { id: nestId } of nests) {
|
|
3989
|
-
const
|
|
3990
|
-
const storage = new
|
|
3782
|
+
const nestPath = join4(config.DATA_ROOT, "nests", nestId);
|
|
3783
|
+
const storage = new NestStorage(nestPath);
|
|
3991
3784
|
let docs;
|
|
3992
3785
|
try {
|
|
3993
3786
|
docs = await storage.discoverDocuments();
|
|
@@ -4085,7 +3878,6 @@ async function main() {
|
|
|
4085
3878
|
`);
|
|
4086
3879
|
}
|
|
4087
3880
|
const app = createApp();
|
|
4088
|
-
startLicenseWatcher();
|
|
4089
3881
|
startLicenseSafetyPoll();
|
|
4090
3882
|
const driftScanIntervalMs = Number(process.env.DRIFT_SCAN_INTERVAL_MS) || 3e4;
|
|
4091
3883
|
if (driftScanIntervalMs > 0) {
|