@promptowl/contextnest-community 1.0.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONFIGURATION.md +7 -4
- package/README.md +97 -7
- package/dist/chunk-5MT4ZBVF.js +1413 -0
- package/dist/{chunk-5VHKEIAW.js → chunk-E7E3JMQR.js} +18 -36
- package/dist/{chunk-KQCWNHDM.js → chunk-G62P54ET.js} +39 -0
- package/dist/{chunk-JMZ75ZCD.js → chunk-LO54V4AU.js} +1 -1
- package/dist/{chunk-7K2LLJXK.js → chunk-XRK6SQSC.js} +1 -1
- package/dist/index.js +1256 -1157
- package/dist/{keys-YV33AJK3.js → keys-73STFJJB.js} +1 -1
- package/dist/{review-service-4WS3XL6K.js → review-service-GYX3AW6E.js} +4 -4
- package/dist/{stewardship-service-C5D2O7ZE.js → stewardship-service-VOD5HY3I.js} +20 -4
- package/dist/{version-service-TFEYNPH7.js → version-service-OCZUV2QP.js} +2 -2
- package/dist/web3/assets/index-72vKyivD.js +756 -0
- package/dist/web3/assets/index-JmSevkg_.css +1 -0
- package/dist/web3/index.html +2 -2
- package/package.json +148 -134
- package/dist/chunk-K22GWPT4.js +0 -498
- package/dist/web3/assets/index-DkLevP7k.js +0 -624
- package/dist/web3/assets/index-DpoBdKrd.css +0 -1
package/dist/index.js
CHANGED
|
@@ -6,48 +6,86 @@ 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-E7E3JMQR.js";
|
|
20
|
+
import {
|
|
21
|
+
checkConflict,
|
|
22
|
+
createVersion,
|
|
23
|
+
getApprovedVersion,
|
|
24
|
+
getCurrentVersion,
|
|
25
|
+
getDisplayStatus,
|
|
26
|
+
getVersions,
|
|
27
|
+
setApprovedVersion
|
|
28
|
+
} from "./chunk-LO54V4AU.js";
|
|
21
29
|
import {
|
|
22
|
-
|
|
30
|
+
AppError,
|
|
31
|
+
ConflictError,
|
|
32
|
+
ForbiddenError,
|
|
33
|
+
LockedError,
|
|
34
|
+
NotFoundError,
|
|
35
|
+
ValidationError,
|
|
36
|
+
canCreateInNest,
|
|
37
|
+
canManageStewards,
|
|
38
|
+
canManageWith,
|
|
23
39
|
canUserAccess,
|
|
24
40
|
canUserApprove,
|
|
25
41
|
canUserEdit,
|
|
42
|
+
createNest,
|
|
26
43
|
createStewardRecord,
|
|
44
|
+
deleteNest,
|
|
45
|
+
disableStewardshipAndWipeGovernance,
|
|
46
|
+
engineCache,
|
|
47
|
+
getCollaboratorRole,
|
|
48
|
+
getCurrentLicense,
|
|
49
|
+
getNest,
|
|
50
|
+
getStewardRolesForUser,
|
|
27
51
|
getStewardsForNest,
|
|
52
|
+
getStewardsForUser,
|
|
53
|
+
getSuspensionReason,
|
|
54
|
+
importNest,
|
|
55
|
+
installLicenseKey,
|
|
56
|
+
isLicenseAdminEmail,
|
|
57
|
+
isLicenseAdminUserId,
|
|
58
|
+
isPublicReader,
|
|
59
|
+
isStewardshipEnabled,
|
|
28
60
|
isSuperAdmin,
|
|
61
|
+
isSuspended,
|
|
62
|
+
listNests,
|
|
63
|
+
listPublicNests,
|
|
64
|
+
listSharedNests,
|
|
29
65
|
listStewards,
|
|
30
66
|
loadAccessConfig,
|
|
67
|
+
nestAllowsSelfApprove,
|
|
68
|
+
permissionLevel,
|
|
31
69
|
removeSteward,
|
|
70
|
+
resolveNestPermission,
|
|
32
71
|
resolveStewardsForNode,
|
|
33
72
|
resolveStewardsWithFallback,
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
} from "./chunk-JMZ75ZCD.js";
|
|
73
|
+
resolveUserRoles,
|
|
74
|
+
setAllowSelfApprove,
|
|
75
|
+
setStewardshipEnabled,
|
|
76
|
+
startLicenseSafetyPoll,
|
|
77
|
+
startTelemetryLoop,
|
|
78
|
+
syncFromConfig,
|
|
79
|
+
trackEvent,
|
|
80
|
+
updateSteward,
|
|
81
|
+
validateLicense
|
|
82
|
+
} from "./chunk-5MT4ZBVF.js";
|
|
45
83
|
import {
|
|
46
84
|
ANON_EMAIL,
|
|
47
85
|
ANON_USER_ID,
|
|
48
86
|
config,
|
|
49
87
|
getDb
|
|
50
|
-
} from "./chunk-
|
|
88
|
+
} from "./chunk-G62P54ET.js";
|
|
51
89
|
|
|
52
90
|
// src/index.ts
|
|
53
91
|
import { serve } from "@hono/node-server";
|
|
@@ -172,463 +210,9 @@ var authMiddleware = createMiddleware(async (c, next) => {
|
|
|
172
210
|
return c.json({ error: "Missing or invalid credentials" }, 401);
|
|
173
211
|
});
|
|
174
212
|
|
|
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
213
|
// src/shared/rate-limit.ts
|
|
628
214
|
var buckets = /* @__PURE__ */ new Map();
|
|
629
|
-
function
|
|
630
|
-
const now = Date.now();
|
|
631
|
-
const cutoff = now - cfg.windowMs;
|
|
215
|
+
function liveBucket(key, cutoff) {
|
|
632
216
|
let bucket = buckets.get(key);
|
|
633
217
|
if (!bucket) {
|
|
634
218
|
bucket = { hits: [] };
|
|
@@ -637,14 +221,29 @@ function tryConsume(key, cfg) {
|
|
|
637
221
|
while (bucket.hits.length && bucket.hits[0] < cutoff) {
|
|
638
222
|
bucket.hits.shift();
|
|
639
223
|
}
|
|
224
|
+
return bucket;
|
|
225
|
+
}
|
|
226
|
+
function tryConsume(key, cfg) {
|
|
227
|
+
const now = Date.now();
|
|
228
|
+
const bucket = liveBucket(key, now - cfg.windowMs);
|
|
640
229
|
if (bucket.hits.length >= cfg.max) {
|
|
641
230
|
return false;
|
|
642
231
|
}
|
|
643
232
|
bucket.hits.push(now);
|
|
644
233
|
return true;
|
|
645
234
|
}
|
|
235
|
+
function isLimited(key, cfg) {
|
|
236
|
+
return liveBucket(key, Date.now() - cfg.windowMs).hits.length >= cfg.max;
|
|
237
|
+
}
|
|
238
|
+
function recordFailure(key, cfg) {
|
|
239
|
+
liveBucket(key, Date.now() - cfg.windowMs).hits.push(Date.now());
|
|
240
|
+
}
|
|
241
|
+
function clear(key) {
|
|
242
|
+
buckets.delete(key);
|
|
243
|
+
}
|
|
646
244
|
|
|
647
245
|
// src/auth/routes.ts
|
|
246
|
+
import { getConnInfo } from "@hono/node-server/conninfo";
|
|
648
247
|
var LOGIN_LIMIT = { max: 5, windowMs: 15 * 6e4 };
|
|
649
248
|
var REGISTER_LIMIT = { max: 3, windowMs: 60 * 6e4 };
|
|
650
249
|
var DEVICE_LIMIT = { max: 10, windowMs: 15 * 6e4 };
|
|
@@ -653,6 +252,11 @@ function clientIp(c) {
|
|
|
653
252
|
if (xff) return xff.split(",")[0].trim();
|
|
654
253
|
const realIp = c.req.header("x-real-ip");
|
|
655
254
|
if (realIp) return realIp.trim();
|
|
255
|
+
try {
|
|
256
|
+
const addr = getConnInfo(c).remote.address;
|
|
257
|
+
if (addr) return addr;
|
|
258
|
+
} catch {
|
|
259
|
+
}
|
|
656
260
|
return "unknown";
|
|
657
261
|
}
|
|
658
262
|
function resolveCallerUserId(c) {
|
|
@@ -669,6 +273,15 @@ function resolveCallerUserId(c) {
|
|
|
669
273
|
}
|
|
670
274
|
return null;
|
|
671
275
|
}
|
|
276
|
+
function deviceGateBlocked(c) {
|
|
277
|
+
const gate = config.PROMPTOWL_SIGN_IN_GATE;
|
|
278
|
+
if (gate === "open") return null;
|
|
279
|
+
const error = "PromptOwl sign-in is restricted on this server. Use email and password, or contact your admin.";
|
|
280
|
+
if (gate === "disabled") return { error, gate };
|
|
281
|
+
const callerId = resolveCallerUserId(c);
|
|
282
|
+
if (callerId && !isLicenseAdminUserId(callerId)) return { error, gate };
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
672
285
|
function setSessionCookie(c, sessionId) {
|
|
673
286
|
c.header(
|
|
674
287
|
"Set-Cookie",
|
|
@@ -731,7 +344,10 @@ authRoutes.post("/login", async (c) => {
|
|
|
731
344
|
}
|
|
732
345
|
const ip = clientIp(c);
|
|
733
346
|
const emailLower = body.email.toLowerCase();
|
|
734
|
-
|
|
347
|
+
const hasIp = ip !== "unknown";
|
|
348
|
+
const ipKey = `login:ip:${ip}`;
|
|
349
|
+
const emailKey = `login:email:${emailLower}`;
|
|
350
|
+
if (hasIp && isLimited(ipKey, LOGIN_LIMIT) || isLimited(emailKey, LOGIN_LIMIT)) {
|
|
735
351
|
return c.json({ error: "Too many login attempts, try again later" }, 429);
|
|
736
352
|
}
|
|
737
353
|
const db = getDb();
|
|
@@ -740,8 +356,14 @@ authRoutes.post("/login", async (c) => {
|
|
|
740
356
|
).get(body.email);
|
|
741
357
|
const check = user ? await verifyPassword(body.password, user.password_hash) : { ok: false, needsRehash: false };
|
|
742
358
|
if (!user || !check.ok) {
|
|
359
|
+
if (hasIp) recordFailure(ipKey, LOGIN_LIMIT);
|
|
360
|
+
recordFailure(emailKey, LOGIN_LIMIT);
|
|
361
|
+
console.warn(`[auth] login FAIL \u2014 counter++ ip=${ip} email=${emailLower}`);
|
|
743
362
|
return c.json({ error: "Invalid credentials" }, 401);
|
|
744
363
|
}
|
|
364
|
+
console.log(`[auth] login OK \u2014 counter reset ip=${ip} email=${emailLower}`);
|
|
365
|
+
if (hasIp) clear(ipKey);
|
|
366
|
+
clear(emailKey);
|
|
745
367
|
if (check.needsRehash) {
|
|
746
368
|
try {
|
|
747
369
|
const newHash = await hashPassword(body.password);
|
|
@@ -843,6 +465,8 @@ authRoutes.delete("/keys/:keyId", authMiddleware, async (c) => {
|
|
|
843
465
|
return c.json({ deleted: true });
|
|
844
466
|
});
|
|
845
467
|
authRoutes.post("/device", async (c) => {
|
|
468
|
+
const blocked = deviceGateBlocked(c);
|
|
469
|
+
if (blocked) return c.json(blocked, 403);
|
|
846
470
|
if (!tryConsume(`device:ip:${clientIp(c)}`, DEVICE_LIMIT)) {
|
|
847
471
|
return c.json({ error: "Too many device auth attempts, try again later" }, 429);
|
|
848
472
|
}
|
|
@@ -864,6 +488,8 @@ authRoutes.post("/device", async (c) => {
|
|
|
864
488
|
return c.json(data);
|
|
865
489
|
});
|
|
866
490
|
authRoutes.get("/device/poll", async (c) => {
|
|
491
|
+
const blocked = deviceGateBlocked(c);
|
|
492
|
+
if (blocked) return c.json(blocked, 403);
|
|
867
493
|
const code = c.req.query("code");
|
|
868
494
|
const clientSecret = c.req.query("client_secret");
|
|
869
495
|
if (!code || !clientSecret) {
|
|
@@ -899,6 +525,18 @@ authRoutes.post("/promptowl", async (c) => {
|
|
|
899
525
|
401
|
|
900
526
|
);
|
|
901
527
|
}
|
|
528
|
+
const gate = config.PROMPTOWL_SIGN_IN_GATE;
|
|
529
|
+
if (gate !== "open") {
|
|
530
|
+
if (gate === "disabled" || !isLicenseAdminEmail(me.email)) {
|
|
531
|
+
return c.json(
|
|
532
|
+
{
|
|
533
|
+
error: "PromptOwl sign-in is restricted on this server. Use email and password, or contact your admin.",
|
|
534
|
+
gate
|
|
535
|
+
},
|
|
536
|
+
403
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
902
540
|
const db = getDb();
|
|
903
541
|
let user = db.prepare("SELECT id, email, name FROM users WHERE email = ?").get(me.email);
|
|
904
542
|
if (!user) {
|
|
@@ -980,9 +618,17 @@ authRoutes.post("/password", authMiddleware, async (c) => {
|
|
|
980
618
|
const db = getDb();
|
|
981
619
|
const userId = c.get("userId");
|
|
982
620
|
const user = db.prepare("SELECT password_hash FROM users WHERE id = ?").get(userId);
|
|
983
|
-
if (!user)
|
|
621
|
+
if (!user) throw new ValidationError("User not found");
|
|
984
622
|
const check = await verifyPassword(body.current, user.password_hash);
|
|
985
|
-
if (!check.ok)
|
|
623
|
+
if (!check.ok) {
|
|
624
|
+
throw new ValidationError("Current password is incorrect");
|
|
625
|
+
}
|
|
626
|
+
const sameAsCurrent = await verifyPassword(body.next, user.password_hash);
|
|
627
|
+
if (sameAsCurrent.ok) {
|
|
628
|
+
throw new ValidationError(
|
|
629
|
+
"new password must be different from the current password"
|
|
630
|
+
);
|
|
631
|
+
}
|
|
986
632
|
const newHash = await hashPassword(body.next);
|
|
987
633
|
db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(
|
|
988
634
|
newHash,
|
|
@@ -992,6 +638,92 @@ authRoutes.post("/password", authMiddleware, async (c) => {
|
|
|
992
638
|
clearSessionCookie(c);
|
|
993
639
|
return c.json({ ok: true });
|
|
994
640
|
});
|
|
641
|
+
authRoutes.post("/admin/reset-password/:userId", async (c) => {
|
|
642
|
+
const callerId = resolveCallerUserId(c);
|
|
643
|
+
if (!callerId) {
|
|
644
|
+
return c.json({ error: "Authentication required." }, 401);
|
|
645
|
+
}
|
|
646
|
+
if (!isLicenseAdminUserId(callerId)) {
|
|
647
|
+
return c.json(
|
|
648
|
+
{ error: "Only the license-admin user can reset passwords." },
|
|
649
|
+
403
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
const targetId = c.req.param("userId");
|
|
653
|
+
const db = getDb();
|
|
654
|
+
const target = db.prepare("SELECT id, email FROM users WHERE id = ?").get(targetId);
|
|
655
|
+
if (!target) return c.json({ error: "User not found" }, 404);
|
|
656
|
+
let supplied;
|
|
657
|
+
try {
|
|
658
|
+
supplied = (await c.req.json()).password;
|
|
659
|
+
} catch {
|
|
660
|
+
supplied = void 0;
|
|
661
|
+
}
|
|
662
|
+
if (supplied && supplied.length < 8) {
|
|
663
|
+
throw new ValidationError("password must be at least 8 characters");
|
|
664
|
+
}
|
|
665
|
+
const generated = supplied ? null : uuid().replace(/-/g, "").slice(0, 16);
|
|
666
|
+
const newPassword = supplied ?? generated;
|
|
667
|
+
const newHash = await hashPassword(newPassword);
|
|
668
|
+
db.transaction(() => {
|
|
669
|
+
db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(
|
|
670
|
+
newHash,
|
|
671
|
+
target.id
|
|
672
|
+
);
|
|
673
|
+
db.prepare("DELETE FROM api_keys WHERE user_id = ?").run(target.id);
|
|
674
|
+
})();
|
|
675
|
+
deleteAllSessionsForUser(target.id);
|
|
676
|
+
trackEvent("admin.reset_password", { adminId: callerId, userId: target.id });
|
|
677
|
+
return c.json({
|
|
678
|
+
ok: true,
|
|
679
|
+
email: target.email,
|
|
680
|
+
keys_revoked: true,
|
|
681
|
+
// Plaintext returned ONCE, only when the server generated it.
|
|
682
|
+
temporary_password: generated ?? void 0
|
|
683
|
+
});
|
|
684
|
+
});
|
|
685
|
+
authRoutes.delete("/users/:userId", async (c) => {
|
|
686
|
+
const callerId = resolveCallerUserId(c);
|
|
687
|
+
if (!callerId) {
|
|
688
|
+
return c.json({ error: "Authentication required." }, 401);
|
|
689
|
+
}
|
|
690
|
+
if (!isLicenseAdminUserId(callerId)) {
|
|
691
|
+
return c.json(
|
|
692
|
+
{ error: "Only the license-admin user can remove users." },
|
|
693
|
+
403
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
const targetId = c.req.param("userId");
|
|
697
|
+
if (targetId === callerId) {
|
|
698
|
+
return c.json({ error: "You can't remove your own admin account." }, 400);
|
|
699
|
+
}
|
|
700
|
+
const db = getDb();
|
|
701
|
+
const target = db.prepare("SELECT id, email FROM users WHERE id = ?").get(targetId);
|
|
702
|
+
if (!target) return c.json({ error: "User not found" }, 404);
|
|
703
|
+
const ownedNests = db.prepare("SELECT COUNT(*) AS c FROM nests WHERE user_id = ?").get(target.id).c;
|
|
704
|
+
if (ownedNests > 0) {
|
|
705
|
+
return c.json(
|
|
706
|
+
{
|
|
707
|
+
error: `This user owns ${ownedNests} nest${ownedNests === 1 ? "" : "s"}. Transfer or delete them before removing the user.`,
|
|
708
|
+
owned_nests: ownedNests
|
|
709
|
+
},
|
|
710
|
+
409
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
db.transaction(() => {
|
|
714
|
+
db.prepare(
|
|
715
|
+
"DELETE FROM stewards WHERE user_id = ? OR lower(user_email) = lower(?)"
|
|
716
|
+
).run(target.id, target.email);
|
|
717
|
+
db.prepare("DELETE FROM nest_collaborators WHERE user_id = ?").run(
|
|
718
|
+
target.id
|
|
719
|
+
);
|
|
720
|
+
db.prepare("DELETE FROM api_keys WHERE user_id = ?").run(target.id);
|
|
721
|
+
db.prepare("DELETE FROM users WHERE id = ?").run(target.id);
|
|
722
|
+
})();
|
|
723
|
+
deleteAllSessionsForUser(target.id);
|
|
724
|
+
trackEvent("admin.remove_user", { adminId: callerId, userId: target.id });
|
|
725
|
+
return c.json({ ok: true, email: target.email });
|
|
726
|
+
});
|
|
995
727
|
authRoutes.post("/invite", async (c) => {
|
|
996
728
|
const body = await c.req.json();
|
|
997
729
|
if (!body.email) throw new ValidationError("email is required");
|
|
@@ -1079,8 +811,13 @@ authRoutes.get("/teammates", async (c) => {
|
|
|
1079
811
|
WHERE s.is_active = 1
|
|
1080
812
|
AND NOT EXISTS (
|
|
1081
813
|
SELECT 1 FROM users u
|
|
1082
|
-
JOIN api_keys k ON k.user_id = u.id
|
|
1083
814
|
WHERE lower(u.email) = lower(s.user_email)
|
|
815
|
+
AND (
|
|
816
|
+
u.is_invited = 0
|
|
817
|
+
OR EXISTS (
|
|
818
|
+
SELECT 1 FROM api_keys k WHERE k.user_id = u.id
|
|
819
|
+
)
|
|
820
|
+
)
|
|
1084
821
|
)
|
|
1085
822
|
ORDER BY s.user_email`
|
|
1086
823
|
).all();
|
|
@@ -1094,355 +831,64 @@ authRoutes.get("/teammates", async (c) => {
|
|
|
1094
831
|
// src/nests/routes.ts
|
|
1095
832
|
import { Hono as Hono2 } from "hono";
|
|
1096
833
|
|
|
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
|
-
}
|
|
834
|
+
// src/nodes/service.ts
|
|
835
|
+
import { serializeDocument, parseDocument as parseDocument2 } from "@promptowl/contextnest-engine";
|
|
1123
836
|
|
|
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;
|
|
837
|
+
// src/governance/tag-index-service.ts
|
|
838
|
+
function normalizeTag(raw) {
|
|
839
|
+
return raw.trim().replace(/^#+/, "").toLowerCase();
|
|
1139
840
|
}
|
|
1140
|
-
function
|
|
841
|
+
function syncNodeTags(nestId, nodeId, tags) {
|
|
1141
842
|
const db = getDb();
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
843
|
+
const normalized = Array.from(
|
|
844
|
+
new Set(
|
|
845
|
+
tags.filter((t) => typeof t === "string").map(normalizeTag).filter(Boolean)
|
|
846
|
+
)
|
|
1145
847
|
);
|
|
848
|
+
db.transaction(() => {
|
|
849
|
+
db.prepare(
|
|
850
|
+
"DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?"
|
|
851
|
+
).run(nestId, nodeId);
|
|
852
|
+
const insert = db.prepare(
|
|
853
|
+
"INSERT OR IGNORE INTO node_tag_index (nest_id, node_id, tag_name) VALUES (?, ?, ?)"
|
|
854
|
+
);
|
|
855
|
+
for (const tag of normalized) {
|
|
856
|
+
insert.run(nestId, nodeId, tag);
|
|
857
|
+
}
|
|
858
|
+
})();
|
|
1146
859
|
}
|
|
1147
|
-
|
|
1148
|
-
const id = uuid2();
|
|
1149
|
-
const slug = toSlug(name);
|
|
860
|
+
function removeNodeFromTagIndex(nestId, nodeId) {
|
|
1150
861
|
const db = getDb();
|
|
1151
|
-
const visibility = userId === ANON_USER_ID ? "public" : "private";
|
|
1152
862
|
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);
|
|
863
|
+
"DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?"
|
|
864
|
+
).run(nestId, nodeId);
|
|
1161
865
|
}
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
}
|
|
1167
|
-
const includeAnon = config.AUTH_MODE === "open" || isLicenseAdminUserId(userId);
|
|
1168
|
-
if (!includeAnon) {
|
|
1169
|
-
return db.prepare("SELECT * FROM nests WHERE user_id = ? ORDER BY created_at DESC").all(userId);
|
|
1170
|
-
}
|
|
1171
|
-
return db.prepare(
|
|
1172
|
-
"SELECT * FROM nests WHERE user_id = ? OR (user_id = ? AND visibility = 'public') ORDER BY created_at DESC"
|
|
1173
|
-
).all(userId, ANON_USER_ID);
|
|
1174
|
-
}
|
|
1175
|
-
function listSharedNests(userId) {
|
|
1176
|
-
const db = getDb();
|
|
1177
|
-
return db.prepare(
|
|
1178
|
-
`SELECT DISTINCT n.* FROM nests n
|
|
1179
|
-
JOIN nest_collaborators nc ON nc.nest_id = n.id
|
|
1180
|
-
WHERE n.user_id != ? AND nc.user_id = ?
|
|
1181
|
-
ORDER BY n.created_at DESC`
|
|
1182
|
-
).all(userId, userId);
|
|
1183
|
-
}
|
|
1184
|
-
function getNest(nestId) {
|
|
1185
|
-
const db = getDb();
|
|
1186
|
-
return db.prepare("SELECT * FROM nests WHERE id = ?").get(nestId) || null;
|
|
1187
|
-
}
|
|
1188
|
-
async function deleteNest(nestId) {
|
|
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);
|
|
1205
|
-
try {
|
|
1206
|
-
rmSync(path, { recursive: true, force: true });
|
|
1207
|
-
} catch (err) {
|
|
1208
|
-
console.warn(`[nests] failed to remove nest directory ${path}:`, err);
|
|
1209
|
-
}
|
|
1210
|
-
trackEvent("nest.delete", { nestId });
|
|
1211
|
-
}
|
|
1212
|
-
|
|
1213
|
-
// src/nests/routes.ts
|
|
1214
|
-
function effectivePermission(nestId, userId) {
|
|
1215
|
-
if (config.AUTH_MODE === "open") {
|
|
1216
|
-
const db = getDb();
|
|
1217
|
-
const nest = db.prepare("SELECT user_id FROM nests WHERE id = ?").get(nestId);
|
|
1218
|
-
if (nest && nest.user_id === ANON_USER_ID) return "owner";
|
|
1219
|
-
}
|
|
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";
|
|
866
|
+
|
|
867
|
+
// src/governance/access-guard.ts
|
|
868
|
+
function resolveCallerEmail(userId) {
|
|
869
|
+
if (!userId) return "admin@localhost";
|
|
1396
870
|
const db = getDb();
|
|
1397
871
|
const row = db.prepare("SELECT email FROM users WHERE id = ?").get(userId);
|
|
1398
872
|
return row?.email || "admin@localhost";
|
|
1399
873
|
}
|
|
1400
|
-
function canReadNode(nestId, nodeId, userEmail) {
|
|
874
|
+
function canReadNode(nestId, nodeId, userId, userEmail) {
|
|
875
|
+
if (isPublicReader(nestId, userId)) {
|
|
876
|
+
return getApprovedVersion(nestId, nodeId) !== null;
|
|
877
|
+
}
|
|
1401
878
|
if (!isStewardshipEnabled(nestId)) return true;
|
|
1402
879
|
return canUserAccess(nestId, nodeId, userEmail).allowed;
|
|
1403
880
|
}
|
|
1404
|
-
function filterAccessible(nestId, userEmail, nodes) {
|
|
881
|
+
function filterAccessible(nestId, userId, userEmail, nodes) {
|
|
882
|
+
if (isPublicReader(nestId, userId)) {
|
|
883
|
+
return nodes.filter((n) => getApprovedVersion(nestId, n.id) !== null);
|
|
884
|
+
}
|
|
1405
885
|
if (!isStewardshipEnabled(nestId)) return nodes;
|
|
1406
886
|
return nodes.filter((n) => canUserAccess(nestId, n.id, userEmail).allowed);
|
|
1407
887
|
}
|
|
1408
888
|
|
|
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
889
|
// src/governance/external-edit-service.ts
|
|
1444
890
|
import { readFile } from "fs/promises";
|
|
1445
|
-
import { join
|
|
891
|
+
import { join } from "path";
|
|
1446
892
|
import {
|
|
1447
893
|
detectDrift,
|
|
1448
894
|
stageSuggestion,
|
|
@@ -1450,7 +896,10 @@ import {
|
|
|
1450
896
|
rejectSuggestion,
|
|
1451
897
|
listSuggestions,
|
|
1452
898
|
readSuggestion,
|
|
1453
|
-
|
|
899
|
+
parseDocument,
|
|
900
|
+
VersionManager,
|
|
901
|
+
computeContentHash,
|
|
902
|
+
getChecksumContent
|
|
1454
903
|
} from "@promptowl/contextnest-engine";
|
|
1455
904
|
var communityRbac = {
|
|
1456
905
|
isCzar: () => false,
|
|
@@ -1458,7 +907,7 @@ var communityRbac = {
|
|
|
1458
907
|
isDocOwner: () => true
|
|
1459
908
|
};
|
|
1460
909
|
function docPath(nestId, documentId) {
|
|
1461
|
-
return
|
|
910
|
+
return join(config.DATA_ROOT, "nests", nestId, `${documentId}.md`);
|
|
1462
911
|
}
|
|
1463
912
|
async function readRaw(nestId, documentId) {
|
|
1464
913
|
try {
|
|
@@ -1481,6 +930,18 @@ async function loadChainHead(storage, documentId) {
|
|
|
1481
930
|
return null;
|
|
1482
931
|
}
|
|
1483
932
|
}
|
|
933
|
+
async function loadLatestApprovedNode(nestId, documentId) {
|
|
934
|
+
const { storage } = engineCache.get(nestId);
|
|
935
|
+
const head = await loadChainHead(storage, documentId);
|
|
936
|
+
if (!head) return null;
|
|
937
|
+
return parseDocument(docPath(nestId, documentId), head.content, documentId);
|
|
938
|
+
}
|
|
939
|
+
async function bodyMatchesLatestVersion(storage, documentId, liveBodyHash) {
|
|
940
|
+
const head = await loadChainHead(storage, documentId);
|
|
941
|
+
if (!head) return false;
|
|
942
|
+
const headBodyHash = computeContentHash(getChecksumContent(head.content));
|
|
943
|
+
return headBodyHash === liveBodyHash;
|
|
944
|
+
}
|
|
1484
945
|
async function scanDocumentForDrift(nestId, documentId, actor = "system:scanner") {
|
|
1485
946
|
const res = await scanDocumentForDriftInternal(nestId, documentId, actor);
|
|
1486
947
|
return res?.meta ?? null;
|
|
@@ -1493,6 +954,9 @@ async function scanDocumentForDriftInternal(nestId, documentId, actor) {
|
|
|
1493
954
|
if (raw == null) return null;
|
|
1494
955
|
const drift = detectDrift(raw, node.frontmatter.checksum);
|
|
1495
956
|
if (!drift.drifted) return null;
|
|
957
|
+
if (await bodyMatchesLatestVersion(storage, documentId, drift.actualHash)) {
|
|
958
|
+
return null;
|
|
959
|
+
}
|
|
1496
960
|
const approved = await loadChainHead(storage, documentId);
|
|
1497
961
|
if (!approved) return null;
|
|
1498
962
|
const existing = await listSuggestions(storage, documentId);
|
|
@@ -1521,20 +985,35 @@ async function scanNestForDrift(nestId, actor = "system:scanner") {
|
|
|
1521
985
|
async function getPendingChange(nestId, documentId) {
|
|
1522
986
|
const { storage } = engineCache.get(nestId);
|
|
1523
987
|
const list = await listSuggestions(storage, documentId);
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
988
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
989
|
+
const meta = list[i];
|
|
990
|
+
if (await bodyMatchesLatestVersion(storage, documentId, meta.proposed_hash)) {
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
return {
|
|
994
|
+
suggestion_id: meta.suggestion_id,
|
|
995
|
+
detected_at: meta.detected_at,
|
|
996
|
+
source: meta.source,
|
|
997
|
+
proposed_hash: meta.proposed_hash
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
return null;
|
|
1532
1001
|
}
|
|
1533
1002
|
async function listNestExternalEdits(nestId) {
|
|
1534
1003
|
const { storage } = engineCache.get(nestId);
|
|
1535
1004
|
const docs = await storage.discoverDocuments();
|
|
1536
1005
|
const lists = await Promise.all(
|
|
1537
|
-
docs.map((doc) =>
|
|
1006
|
+
docs.map(async (doc) => {
|
|
1007
|
+
const metas = await listSuggestions(storage, doc.id);
|
|
1008
|
+
const fresh = [];
|
|
1009
|
+
for (const meta of metas) {
|
|
1010
|
+
if (await bodyMatchesLatestVersion(storage, doc.id, meta.proposed_hash)) {
|
|
1011
|
+
continue;
|
|
1012
|
+
}
|
|
1013
|
+
fresh.push(meta);
|
|
1014
|
+
}
|
|
1015
|
+
return fresh;
|
|
1016
|
+
})
|
|
1538
1017
|
);
|
|
1539
1018
|
const entries = lists.flat().map((meta) => ({
|
|
1540
1019
|
suggestion_id: meta.suggestion_id,
|
|
@@ -1590,7 +1069,7 @@ async function approveExternalEdit(input) {
|
|
|
1590
1069
|
const node = await storage.readDocument(input.documentId);
|
|
1591
1070
|
const versionNum = result.versionEntry.version;
|
|
1592
1071
|
const tags = node.frontmatter.tags || [];
|
|
1593
|
-
const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-
|
|
1072
|
+
const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-OCZUV2QP.js");
|
|
1594
1073
|
createVersion2({
|
|
1595
1074
|
nestId: input.nestId,
|
|
1596
1075
|
nodeId: input.documentId,
|
|
@@ -1656,48 +1135,79 @@ function startDriftScanner(intervalMs = 3e4) {
|
|
|
1656
1135
|
scannerTimer.unref?.();
|
|
1657
1136
|
}
|
|
1658
1137
|
|
|
1659
|
-
// src/nodes/
|
|
1660
|
-
|
|
1138
|
+
// src/nodes/service.ts
|
|
1139
|
+
function userIdFromEmail(email) {
|
|
1140
|
+
const db = getDb();
|
|
1141
|
+
const row = db.prepare("SELECT id FROM users WHERE LOWER(email) = LOWER(?)").get(email);
|
|
1142
|
+
return row?.id ?? ANON_USER_ID;
|
|
1143
|
+
}
|
|
1144
|
+
var normalizeTag2 = (t) => t.startsWith("#") ? t : `#${t}`;
|
|
1145
|
+
var stripUndefined = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== void 0));
|
|
1146
|
+
function bodyOnly(nodeId, raw) {
|
|
1147
|
+
try {
|
|
1148
|
+
return parseDocument2(`${nodeId}.md`, raw, nodeId).body ?? "";
|
|
1149
|
+
} catch {
|
|
1150
|
+
return raw;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1661
1153
|
function toNodeResponse(node) {
|
|
1154
|
+
const fm = node.frontmatter;
|
|
1155
|
+
const title = fm.title === void 0 || fm.title === null ? "" : String(fm.title);
|
|
1156
|
+
const tags = Array.isArray(fm.tags) ? fm.tags.map((t) => String(t)) : [];
|
|
1662
1157
|
return {
|
|
1663
1158
|
id: node.id,
|
|
1664
|
-
title
|
|
1665
|
-
type:
|
|
1666
|
-
tags
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
created_at: node.frontmatter.created_at,
|
|
1675
|
-
updated_at: node.frontmatter.updated_at,
|
|
1676
|
-
content: node.body,
|
|
1159
|
+
title,
|
|
1160
|
+
type: fm.type || "document",
|
|
1161
|
+
tags,
|
|
1162
|
+
status: fm.status || "draft",
|
|
1163
|
+
version: fm.version || 1,
|
|
1164
|
+
author: fm.author,
|
|
1165
|
+
description: fm.description,
|
|
1166
|
+
created_at: fm.created_at,
|
|
1167
|
+
updated_at: fm.updated_at,
|
|
1168
|
+
content: node.body || "",
|
|
1677
1169
|
pendingChange: node.pendingChange ?? void 0
|
|
1678
1170
|
};
|
|
1679
1171
|
}
|
|
1680
|
-
|
|
1681
|
-
const
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1172
|
+
async function listNodesForCaller(nestId, userId, filters = {}) {
|
|
1173
|
+
const { storage, versions: versionManager } = engineCache.get(nestId);
|
|
1174
|
+
let documents = await storage.discoverDocuments();
|
|
1175
|
+
if (filters.type) {
|
|
1176
|
+
documents = documents.filter((n) => n.frontmatter.type === filters.type);
|
|
1177
|
+
}
|
|
1178
|
+
if (filters.tag) {
|
|
1179
|
+
const tag = normalizeTag2(filters.tag);
|
|
1180
|
+
documents = documents.filter(
|
|
1181
|
+
(n) => (n.frontmatter.tags || []).includes(tag)
|
|
1182
|
+
);
|
|
1183
|
+
}
|
|
1184
|
+
const userEmail = resolveCallerEmail(userId);
|
|
1185
|
+
const accessible = filterAccessible(nestId, userId, userEmail, documents);
|
|
1186
|
+
const publicReader = isPublicReader(nestId, userId);
|
|
1187
|
+
const enriched = await Promise.all(
|
|
1688
1188
|
accessible.map(async (doc) => {
|
|
1189
|
+
const r = toNodeResponse(doc);
|
|
1190
|
+
if (publicReader) {
|
|
1191
|
+
const approved = getApprovedVersion(nestId, doc.id);
|
|
1192
|
+
if (approved != null) {
|
|
1193
|
+
try {
|
|
1194
|
+
const raw = await versionManager.reconstructVersion(doc.id, approved);
|
|
1195
|
+
r.content = bodyOnly(doc.id, raw);
|
|
1196
|
+
} catch (err) {
|
|
1197
|
+
console.error("reconstructVersion failed (list)", doc.id, approved, err);
|
|
1198
|
+
r.content = "";
|
|
1199
|
+
}
|
|
1200
|
+
r.version = approved;
|
|
1201
|
+
r.status = "published";
|
|
1202
|
+
}
|
|
1203
|
+
return r;
|
|
1204
|
+
}
|
|
1205
|
+
let pending = null;
|
|
1689
1206
|
try {
|
|
1690
|
-
|
|
1207
|
+
pending = await getPendingChange(nestId, doc.id);
|
|
1691
1208
|
} catch {
|
|
1692
|
-
|
|
1209
|
+
pending = null;
|
|
1693
1210
|
}
|
|
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
1211
|
if (pending) {
|
|
1702
1212
|
r.pendingChange = pending;
|
|
1703
1213
|
r.status = "external_edit_pending";
|
|
@@ -1706,48 +1216,43 @@ nodeRoutes.get("/", async (c) => {
|
|
|
1706
1216
|
}
|
|
1707
1217
|
return r;
|
|
1708
1218
|
})
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
const nestId = c.req.param("nestId");
|
|
1219
|
+
);
|
|
1220
|
+
return filters.limit ? enriched.slice(0, filters.limit) : enriched;
|
|
1221
|
+
}
|
|
1222
|
+
async function listNodesForCallerByEmail(nestId, userEmail, filters = {}) {
|
|
1223
|
+
return listNodesForCaller(nestId, userIdFromEmail(userEmail), filters);
|
|
1224
|
+
}
|
|
1225
|
+
async function createNode(nestId, input, userEmail) {
|
|
1717
1226
|
const { storage, versions: versionManager } = engineCache.get(nestId);
|
|
1718
|
-
const slug =
|
|
1227
|
+
const slug = input.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1719
1228
|
const id = `nodes/${slug}`;
|
|
1720
1229
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1721
|
-
const tags =
|
|
1230
|
+
const tags = (input.tags || []).map(normalizeTag2);
|
|
1722
1231
|
const hasStewards = isStewardshipEnabled(nestId);
|
|
1723
|
-
const initialStatus = hasStewards ? "draft" : "
|
|
1232
|
+
const initialStatus = hasStewards ? "draft" : "published";
|
|
1724
1233
|
const initialVersion = hasStewards ? 1 : 0;
|
|
1725
1234
|
let node = {
|
|
1726
1235
|
id,
|
|
1727
1236
|
filePath: "",
|
|
1728
1237
|
frontmatter: {
|
|
1729
|
-
title:
|
|
1730
|
-
type:
|
|
1238
|
+
title: input.title,
|
|
1239
|
+
type: input.type || "document",
|
|
1731
1240
|
tags,
|
|
1732
|
-
status:
|
|
1241
|
+
status: input.status || initialStatus,
|
|
1733
1242
|
version: initialVersion,
|
|
1734
1243
|
created_at: now,
|
|
1735
1244
|
updated_at: now,
|
|
1736
|
-
metadata: {
|
|
1737
|
-
owners: ["*"],
|
|
1738
|
-
scope: body.scope || "team"
|
|
1739
|
-
}
|
|
1245
|
+
metadata: { owners: ["*"], scope: input.scope || "team" }
|
|
1740
1246
|
},
|
|
1741
|
-
body:
|
|
1247
|
+
body: input.content,
|
|
1742
1248
|
rawContent: ""
|
|
1743
1249
|
};
|
|
1744
|
-
|
|
1745
|
-
await storage.writeDocument(id, serialized);
|
|
1250
|
+
await storage.writeDocument(id, serializeDocument(node));
|
|
1746
1251
|
syncNodeTags(nestId, id, tags);
|
|
1747
|
-
|
|
1252
|
+
let savedVersion = 1;
|
|
1748
1253
|
if (hasStewards) {
|
|
1749
1254
|
try {
|
|
1750
|
-
await versionManager.createVersion(node,
|
|
1255
|
+
await versionManager.createVersion(node, userEmail);
|
|
1751
1256
|
} catch (err) {
|
|
1752
1257
|
console.error("VersionManager.createVersion failed (node create)", err);
|
|
1753
1258
|
}
|
|
@@ -1755,28 +1260,28 @@ nodeRoutes.post("/", async (c) => {
|
|
|
1755
1260
|
nestId,
|
|
1756
1261
|
nodeId: id,
|
|
1757
1262
|
version: 1,
|
|
1758
|
-
content:
|
|
1759
|
-
author:
|
|
1263
|
+
content: input.content,
|
|
1264
|
+
author: userEmail,
|
|
1760
1265
|
status: "draft",
|
|
1761
1266
|
tags
|
|
1762
1267
|
});
|
|
1763
1268
|
} else {
|
|
1764
1269
|
try {
|
|
1765
1270
|
const result = await safePublishDocument(storage, id, {
|
|
1766
|
-
editedBy:
|
|
1271
|
+
editedBy: userEmail,
|
|
1767
1272
|
note: "Auto-published on create (no stewards configured)"
|
|
1768
1273
|
});
|
|
1769
|
-
|
|
1274
|
+
savedVersion = result.node.frontmatter.version || 1;
|
|
1770
1275
|
createVersion({
|
|
1771
1276
|
nestId,
|
|
1772
1277
|
nodeId: id,
|
|
1773
|
-
version:
|
|
1278
|
+
version: savedVersion,
|
|
1774
1279
|
content: result.node.body || "",
|
|
1775
|
-
author:
|
|
1280
|
+
author: userEmail,
|
|
1776
1281
|
status: "published",
|
|
1777
1282
|
tags
|
|
1778
1283
|
});
|
|
1779
|
-
setApprovedVersion(nestId, id,
|
|
1284
|
+
setApprovedVersion(nestId, id, savedVersion, userEmail);
|
|
1780
1285
|
node = result.node;
|
|
1781
1286
|
} catch (err) {
|
|
1782
1287
|
console.error("publishDocument failed (node create auto-publish)", err);
|
|
@@ -1784,15 +1289,486 @@ nodeRoutes.post("/", async (c) => {
|
|
|
1784
1289
|
nestId,
|
|
1785
1290
|
nodeId: id,
|
|
1786
1291
|
version: 1,
|
|
1787
|
-
content:
|
|
1788
|
-
author:
|
|
1292
|
+
content: input.content,
|
|
1293
|
+
author: userEmail,
|
|
1789
1294
|
status: "draft",
|
|
1790
1295
|
tags
|
|
1791
1296
|
});
|
|
1792
1297
|
}
|
|
1793
1298
|
}
|
|
1794
1299
|
trackEvent("node.create", { nestId, nodeId: id });
|
|
1795
|
-
|
|
1300
|
+
return { node, version: savedVersion };
|
|
1301
|
+
}
|
|
1302
|
+
async function registerImportedDocuments(nestId, userEmail) {
|
|
1303
|
+
const { storage } = engineCache.get(nestId);
|
|
1304
|
+
let docs;
|
|
1305
|
+
try {
|
|
1306
|
+
docs = await storage.discoverDocuments();
|
|
1307
|
+
} catch (err) {
|
|
1308
|
+
console.error("registerImportedDocuments: discovery failed", nestId, err);
|
|
1309
|
+
return 0;
|
|
1310
|
+
}
|
|
1311
|
+
let registered = 0;
|
|
1312
|
+
for (const doc of docs) {
|
|
1313
|
+
const nodeId = doc.id;
|
|
1314
|
+
if (getCurrentVersion(nestId, nodeId) > 0) continue;
|
|
1315
|
+
const rawTags = Array.isArray(doc.frontmatter?.tags) ? doc.frontmatter.tags : [];
|
|
1316
|
+
const tags = rawTags.map((t) => normalizeTag2(String(t)));
|
|
1317
|
+
const fmVersion = Number(doc.frontmatter?.version);
|
|
1318
|
+
let version = Number.isFinite(fmVersion) && fmVersion > 0 ? fmVersion : 1;
|
|
1319
|
+
let content = doc.body || "";
|
|
1320
|
+
try {
|
|
1321
|
+
const result = await safePublishDocument(storage, nodeId, {
|
|
1322
|
+
editedBy: userEmail,
|
|
1323
|
+
note: "Imported from existing folder"
|
|
1324
|
+
});
|
|
1325
|
+
version = result.node.frontmatter.version || version;
|
|
1326
|
+
content = result.node.body || content;
|
|
1327
|
+
} catch (err) {
|
|
1328
|
+
console.error("safePublishDocument failed (import register)", nodeId, err);
|
|
1329
|
+
}
|
|
1330
|
+
createVersion({
|
|
1331
|
+
nestId,
|
|
1332
|
+
nodeId,
|
|
1333
|
+
version,
|
|
1334
|
+
content,
|
|
1335
|
+
author: userEmail,
|
|
1336
|
+
status: "published",
|
|
1337
|
+
changeNote: "Imported from existing folder",
|
|
1338
|
+
tags
|
|
1339
|
+
});
|
|
1340
|
+
setApprovedVersion(nestId, nodeId, version, userEmail);
|
|
1341
|
+
syncNodeTags(nestId, nodeId, tags);
|
|
1342
|
+
registered++;
|
|
1343
|
+
}
|
|
1344
|
+
trackEvent("nest.import.documents", { nestId, registered });
|
|
1345
|
+
return registered;
|
|
1346
|
+
}
|
|
1347
|
+
async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
1348
|
+
const { storage, versions: versionManager } = engineCache.get(nestId);
|
|
1349
|
+
let node;
|
|
1350
|
+
try {
|
|
1351
|
+
node = await storage.readDocument(nodeId);
|
|
1352
|
+
} catch {
|
|
1353
|
+
throw new NotFoundError(`Node not found: ${nodeId}`);
|
|
1354
|
+
}
|
|
1355
|
+
if (patch.content !== void 0) {
|
|
1356
|
+
node = { ...node, body: patch.content };
|
|
1357
|
+
}
|
|
1358
|
+
if (patch.append) {
|
|
1359
|
+
node = { ...node, body: (node.body || "") + "\n\n" + patch.append };
|
|
1360
|
+
}
|
|
1361
|
+
if (patch.tags) {
|
|
1362
|
+
const newTags = patch.tags.map(normalizeTag2);
|
|
1363
|
+
const merged = [.../* @__PURE__ */ new Set([...node.frontmatter.tags || [], ...newTags])];
|
|
1364
|
+
node = { ...node, frontmatter: { ...node.frontmatter, tags: merged } };
|
|
1365
|
+
}
|
|
1366
|
+
if (patch.status) {
|
|
1367
|
+
node = { ...node, frontmatter: { ...node.frontmatter, status: patch.status } };
|
|
1368
|
+
}
|
|
1369
|
+
if (patch.title) {
|
|
1370
|
+
node = { ...node, frontmatter: { ...node.frontmatter, title: patch.title } };
|
|
1371
|
+
}
|
|
1372
|
+
const hasStewards = isStewardshipEnabled(nestId);
|
|
1373
|
+
const currentTags = node.frontmatter.tags || [];
|
|
1374
|
+
if (hasStewards && getPendingReview(nestId, nodeId)) {
|
|
1375
|
+
throw new LockedError(
|
|
1376
|
+
"This document is awaiting steward review and is locked. Approve or reject the pending review before editing."
|
|
1377
|
+
);
|
|
1378
|
+
}
|
|
1379
|
+
let responseVersion;
|
|
1380
|
+
if (hasStewards) {
|
|
1381
|
+
const currentVersion = getCurrentVersion(nestId, nodeId);
|
|
1382
|
+
const newVersion = currentVersion + 1;
|
|
1383
|
+
node = {
|
|
1384
|
+
...node,
|
|
1385
|
+
frontmatter: {
|
|
1386
|
+
...node.frontmatter,
|
|
1387
|
+
version: newVersion,
|
|
1388
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1389
|
+
// Drop stale published-state checksum so the next verified read
|
|
1390
|
+
// doesn't flag this write as external drift.
|
|
1391
|
+
checksum: void 0
|
|
1392
|
+
}
|
|
1393
|
+
};
|
|
1394
|
+
node = { ...node, frontmatter: stripUndefined(node.frontmatter) };
|
|
1395
|
+
await storage.writeDocument(nodeId, serializeDocument(node));
|
|
1396
|
+
syncNodeTags(nestId, nodeId, currentTags);
|
|
1397
|
+
try {
|
|
1398
|
+
await versionManager.createVersion(node, userEmail, { note: patch.changeNote });
|
|
1399
|
+
} catch (err) {
|
|
1400
|
+
console.error("VersionManager.createVersion failed (node patch)", err);
|
|
1401
|
+
}
|
|
1402
|
+
createVersion({
|
|
1403
|
+
nestId,
|
|
1404
|
+
nodeId,
|
|
1405
|
+
version: newVersion,
|
|
1406
|
+
content: node.body || "",
|
|
1407
|
+
author: userEmail,
|
|
1408
|
+
status: "draft",
|
|
1409
|
+
tags: currentTags,
|
|
1410
|
+
changeNote: patch.changeNote
|
|
1411
|
+
});
|
|
1412
|
+
responseVersion = newVersion;
|
|
1413
|
+
} else {
|
|
1414
|
+
node = {
|
|
1415
|
+
...node,
|
|
1416
|
+
frontmatter: {
|
|
1417
|
+
...node.frontmatter,
|
|
1418
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1419
|
+
checksum: void 0
|
|
1420
|
+
}
|
|
1421
|
+
};
|
|
1422
|
+
node = { ...node, frontmatter: stripUndefined(node.frontmatter) };
|
|
1423
|
+
await storage.writeDocument(nodeId, serializeDocument(node));
|
|
1424
|
+
syncNodeTags(nestId, nodeId, currentTags);
|
|
1425
|
+
let publishedVersion = (node.frontmatter.version || 0) + 1;
|
|
1426
|
+
try {
|
|
1427
|
+
const result = await safePublishDocument(storage, nodeId, {
|
|
1428
|
+
editedBy: userEmail,
|
|
1429
|
+
note: patch.changeNote || "Auto-published on edit (no stewards)"
|
|
1430
|
+
});
|
|
1431
|
+
publishedVersion = result.node.frontmatter.version || publishedVersion;
|
|
1432
|
+
node = result.node;
|
|
1433
|
+
} catch (err) {
|
|
1434
|
+
console.error("publishDocument failed (node patch auto-publish)", err);
|
|
1435
|
+
}
|
|
1436
|
+
createVersion({
|
|
1437
|
+
nestId,
|
|
1438
|
+
nodeId,
|
|
1439
|
+
version: publishedVersion,
|
|
1440
|
+
content: node.body || "",
|
|
1441
|
+
author: userEmail,
|
|
1442
|
+
status: "published",
|
|
1443
|
+
tags: currentTags,
|
|
1444
|
+
changeNote: patch.changeNote
|
|
1445
|
+
});
|
|
1446
|
+
setApprovedVersion(nestId, nodeId, publishedVersion, userEmail);
|
|
1447
|
+
responseVersion = publishedVersion;
|
|
1448
|
+
}
|
|
1449
|
+
return { node, version: responseVersion };
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
// src/nests/routes.ts
|
|
1453
|
+
function effectivePermission(nestId, userId) {
|
|
1454
|
+
if (config.AUTH_MODE === "open") {
|
|
1455
|
+
const db = getDb();
|
|
1456
|
+
const nest = db.prepare("SELECT user_id FROM nests WHERE id = ?").get(nestId);
|
|
1457
|
+
if (nest && nest.user_id === ANON_USER_ID) return "owner";
|
|
1458
|
+
}
|
|
1459
|
+
return resolveNestPermission(nestId, userId);
|
|
1460
|
+
}
|
|
1461
|
+
var nestRoutes = new Hono2();
|
|
1462
|
+
nestRoutes.get("/", async (c) => {
|
|
1463
|
+
const userId = c.get("userId");
|
|
1464
|
+
const owned = listNests(userId);
|
|
1465
|
+
const shared = listSharedNests(userId);
|
|
1466
|
+
const publicExtras = listPublicNests(userId);
|
|
1467
|
+
const db = getDb();
|
|
1468
|
+
const ownerEmailStmt = db.prepare(
|
|
1469
|
+
"SELECT email FROM users WHERE id = ?"
|
|
1470
|
+
);
|
|
1471
|
+
const callerEmail = resolveCallerEmail(userId);
|
|
1472
|
+
const annotate = (n) => {
|
|
1473
|
+
const permission = effectivePermission(n.id, userId);
|
|
1474
|
+
const is_owner = permission === "owner";
|
|
1475
|
+
let owner_email = null;
|
|
1476
|
+
const roles = is_owner ? ["owner"] : resolveUserRoles(n.id, callerEmail);
|
|
1477
|
+
if (!is_owner && n.user_id !== ANON_USER_ID) {
|
|
1478
|
+
const row = ownerEmailStmt.get(n.user_id);
|
|
1479
|
+
owner_email = row?.email ?? null;
|
|
1480
|
+
}
|
|
1481
|
+
return { ...n, permission, is_owner, owner_email, roles };
|
|
1482
|
+
};
|
|
1483
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1484
|
+
const out = [];
|
|
1485
|
+
for (const n of [...owned, ...shared, ...publicExtras]) {
|
|
1486
|
+
if (seen.has(n.id)) continue;
|
|
1487
|
+
seen.add(n.id);
|
|
1488
|
+
out.push(annotate(n));
|
|
1489
|
+
}
|
|
1490
|
+
return c.json({ nests: out });
|
|
1491
|
+
});
|
|
1492
|
+
nestRoutes.post("/", async (c) => {
|
|
1493
|
+
const body = await c.req.json();
|
|
1494
|
+
if (!body.name) {
|
|
1495
|
+
throw new ValidationError("name is required");
|
|
1496
|
+
}
|
|
1497
|
+
const nest = await createNest(c.get("userId"), body.name, body.description);
|
|
1498
|
+
return c.json({ nest }, 201);
|
|
1499
|
+
});
|
|
1500
|
+
nestRoutes.post("/import", async (c) => {
|
|
1501
|
+
const body = await c.req.json();
|
|
1502
|
+
if (!body.name) {
|
|
1503
|
+
throw new ValidationError("name is required");
|
|
1504
|
+
}
|
|
1505
|
+
const files = Array.isArray(body.files) ? body.files : [];
|
|
1506
|
+
const userId = c.get("userId");
|
|
1507
|
+
const nest = await importNest(userId, body.name, files);
|
|
1508
|
+
const documents = await registerImportedDocuments(
|
|
1509
|
+
nest.id,
|
|
1510
|
+
resolveCallerEmail(userId)
|
|
1511
|
+
);
|
|
1512
|
+
return c.json({ nest, documents }, 201);
|
|
1513
|
+
});
|
|
1514
|
+
nestRoutes.get("/:nestId", async (c) => {
|
|
1515
|
+
const nestId = c.req.param("nestId");
|
|
1516
|
+
const userId = c.get("userId");
|
|
1517
|
+
const permission = effectivePermission(nestId, userId);
|
|
1518
|
+
if (permission === "none") {
|
|
1519
|
+
throw new NotFoundError("Nest not found");
|
|
1520
|
+
}
|
|
1521
|
+
const email = resolveCallerEmail(userId);
|
|
1522
|
+
let roles = resolveUserRoles(nestId, email);
|
|
1523
|
+
if (permission === "owner" && !roles.includes("owner")) {
|
|
1524
|
+
roles = ["owner", ...roles];
|
|
1525
|
+
}
|
|
1526
|
+
const myStewards = getStewardsForUser(nestId, email);
|
|
1527
|
+
const nest = getNest(nestId);
|
|
1528
|
+
return c.json({ nest, permission, roles, myStewards });
|
|
1529
|
+
});
|
|
1530
|
+
nestRoutes.delete("/:nestId", async (c) => {
|
|
1531
|
+
const nestId = c.req.param("nestId");
|
|
1532
|
+
const userId = c.get("userId");
|
|
1533
|
+
const nest = getNest(nestId);
|
|
1534
|
+
if (!nest) {
|
|
1535
|
+
throw new NotFoundError("Nest not found");
|
|
1536
|
+
}
|
|
1537
|
+
const permission = effectivePermission(nestId, userId);
|
|
1538
|
+
const isAnonOwned = nest.user_id === ANON_USER_ID;
|
|
1539
|
+
const adminCaretaker = config.AUTH_MODE !== "open" && isAnonOwned && isLicenseAdminUserId(userId);
|
|
1540
|
+
if (permission !== "owner" && !adminCaretaker) {
|
|
1541
|
+
throw new ForbiddenError(
|
|
1542
|
+
"You don't have permission to delete this nest. Only the nest owner can delete it."
|
|
1543
|
+
);
|
|
1544
|
+
}
|
|
1545
|
+
await deleteNest(nestId);
|
|
1546
|
+
return c.json({ deleted: true });
|
|
1547
|
+
});
|
|
1548
|
+
nestRoutes.get("/:nestId/settings", async (c) => {
|
|
1549
|
+
const nestId = c.req.param("nestId");
|
|
1550
|
+
const permission = effectivePermission(nestId, c.get("userId"));
|
|
1551
|
+
if (permission === "none") {
|
|
1552
|
+
throw new NotFoundError("Nest not found");
|
|
1553
|
+
}
|
|
1554
|
+
return c.json({
|
|
1555
|
+
stewardship_enabled: isStewardshipEnabled(nestId),
|
|
1556
|
+
allow_self_approve: nestAllowsSelfApprove(nestId)
|
|
1557
|
+
});
|
|
1558
|
+
});
|
|
1559
|
+
nestRoutes.patch("/:nestId/settings", async (c) => {
|
|
1560
|
+
const nestId = c.req.param("nestId");
|
|
1561
|
+
const userId = c.get("userId");
|
|
1562
|
+
const isServerAdmin = isLicenseAdminUserId(userId);
|
|
1563
|
+
const permission = effectivePermission(nestId, userId);
|
|
1564
|
+
if (!isServerAdmin && permission !== "owner") {
|
|
1565
|
+
return c.json(
|
|
1566
|
+
{
|
|
1567
|
+
error: "Only the nest owner or the server license-admin can update nest settings."
|
|
1568
|
+
},
|
|
1569
|
+
403
|
|
1570
|
+
);
|
|
1571
|
+
}
|
|
1572
|
+
const body = await c.req.json();
|
|
1573
|
+
let wiped = null;
|
|
1574
|
+
if (typeof body.stewardship_enabled === "boolean") {
|
|
1575
|
+
if (body.stewardship_enabled) {
|
|
1576
|
+
setStewardshipEnabled(nestId, true);
|
|
1577
|
+
} else {
|
|
1578
|
+
wiped = disableStewardshipAndWipeGovernance(nestId);
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
if (typeof body.allow_self_approve === "boolean") {
|
|
1582
|
+
setAllowSelfApprove(nestId, body.allow_self_approve);
|
|
1583
|
+
}
|
|
1584
|
+
return c.json({
|
|
1585
|
+
stewardship_enabled: isStewardshipEnabled(nestId),
|
|
1586
|
+
allow_self_approve: nestAllowsSelfApprove(nestId),
|
|
1587
|
+
wiped
|
|
1588
|
+
});
|
|
1589
|
+
});
|
|
1590
|
+
|
|
1591
|
+
// src/nests/sharing-routes.ts
|
|
1592
|
+
import { Hono as Hono3 } from "hono";
|
|
1593
|
+
|
|
1594
|
+
// src/nests/sharing-service.ts
|
|
1595
|
+
import { v4 as uuid2 } from "uuid";
|
|
1596
|
+
var VALID_PERMISSIONS = ["read", "write", "admin"];
|
|
1597
|
+
async function addCollaborator(params) {
|
|
1598
|
+
const { nestId } = params;
|
|
1599
|
+
if (!params.permission || !VALID_PERMISSIONS.includes(params.permission)) {
|
|
1600
|
+
throw new ValidationError("permission must be read, write, or admin");
|
|
1601
|
+
}
|
|
1602
|
+
const db = getDb();
|
|
1603
|
+
let userId = params.userId;
|
|
1604
|
+
if (!userId && params.email) {
|
|
1605
|
+
const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(params.email);
|
|
1606
|
+
if (existing) {
|
|
1607
|
+
userId = existing.id;
|
|
1608
|
+
} else {
|
|
1609
|
+
const { hashPassword: hashPassword2 } = await import("./keys-73STFJJB.js");
|
|
1610
|
+
userId = uuid2();
|
|
1611
|
+
db.prepare(
|
|
1612
|
+
"INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)"
|
|
1613
|
+
).run(userId, params.email, null, await hashPassword2(uuid2()));
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
if (!userId) {
|
|
1617
|
+
throw new ValidationError("user_id or email is required");
|
|
1618
|
+
}
|
|
1619
|
+
const ownerRow = db.prepare("SELECT user_id FROM nests WHERE id = ?").get(nestId);
|
|
1620
|
+
if (!ownerRow) {
|
|
1621
|
+
throw new ValidationError("Nest not found");
|
|
1622
|
+
}
|
|
1623
|
+
if (ownerRow.user_id === userId) {
|
|
1624
|
+
throw new ValidationError(
|
|
1625
|
+
"The nest owner already has full access and can't be added as a collaborator."
|
|
1626
|
+
);
|
|
1627
|
+
}
|
|
1628
|
+
const selfByEmail = !!params.email && !!params.grantedByEmail && params.email.trim().toLowerCase() === params.grantedByEmail.trim().toLowerCase();
|
|
1629
|
+
const selfById = !!params.grantedByUserId && params.grantedByUserId === userId;
|
|
1630
|
+
if (selfByEmail || selfById) {
|
|
1631
|
+
throw new ValidationError("You can't add yourself as a collaborator.");
|
|
1632
|
+
}
|
|
1633
|
+
const dupe = db.prepare("SELECT id FROM nest_collaborators WHERE nest_id = ? AND user_id = ?").get(nestId, userId);
|
|
1634
|
+
if (dupe) {
|
|
1635
|
+
throw new ConflictError(
|
|
1636
|
+
`${params.email || "This user"} already has access to this nest. Change their permission instead of adding them again.`
|
|
1637
|
+
);
|
|
1638
|
+
}
|
|
1639
|
+
const granterByEmail = !params.grantedByUserId && params.grantedByEmail ? db.prepare("SELECT id FROM users WHERE LOWER(email) = LOWER(?)").get(params.grantedByEmail)?.id : void 0;
|
|
1640
|
+
const granterId = params.grantedByUserId || granterByEmail || ownerRow.user_id;
|
|
1641
|
+
const collabId = uuid2();
|
|
1642
|
+
db.prepare(
|
|
1643
|
+
"INSERT INTO nest_collaborators (id, nest_id, user_id, permission, granted_by) VALUES (?, ?, ?, ?, ?)"
|
|
1644
|
+
).run(collabId, nestId, userId, params.permission, granterId);
|
|
1645
|
+
return db.prepare("SELECT * FROM nest_collaborators WHERE id = ?").get(collabId);
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
// src/nests/sharing-routes.ts
|
|
1649
|
+
var sharingRoutes = new Hono3();
|
|
1650
|
+
sharingRoutes.get("/collaborators", async (c) => {
|
|
1651
|
+
const db = getDb();
|
|
1652
|
+
const nestId = c.req.param("nestId");
|
|
1653
|
+
const collabs = db.prepare(
|
|
1654
|
+
`SELECT nc.*, u.email FROM nest_collaborators nc
|
|
1655
|
+
LEFT JOIN users u ON nc.user_id = u.id
|
|
1656
|
+
WHERE nc.nest_id = ?
|
|
1657
|
+
ORDER BY nc.granted_at`
|
|
1658
|
+
).all(nestId);
|
|
1659
|
+
const enriched = collabs.map((collab) => {
|
|
1660
|
+
if (!collab.email) return { ...collab, stewardRoles: [], roles: [] };
|
|
1661
|
+
return {
|
|
1662
|
+
...collab,
|
|
1663
|
+
stewardRoles: getStewardRolesForUser(nestId, collab.email),
|
|
1664
|
+
roles: resolveUserRoles(nestId, collab.email)
|
|
1665
|
+
};
|
|
1666
|
+
});
|
|
1667
|
+
return c.json({ collaborators: enriched });
|
|
1668
|
+
});
|
|
1669
|
+
sharingRoutes.post("/collaborators", async (c) => {
|
|
1670
|
+
const body = await c.req.json();
|
|
1671
|
+
const collab = await addCollaborator({
|
|
1672
|
+
nestId: c.req.param("nestId"),
|
|
1673
|
+
email: body.email,
|
|
1674
|
+
userId: body.user_id,
|
|
1675
|
+
permission: body.permission ?? "",
|
|
1676
|
+
grantedByUserId: c.get("userId")
|
|
1677
|
+
});
|
|
1678
|
+
return c.json({ collaborator: collab }, 201);
|
|
1679
|
+
});
|
|
1680
|
+
sharingRoutes.patch("/collaborators/:collabId", async (c) => {
|
|
1681
|
+
const body = await c.req.json();
|
|
1682
|
+
if (!body.permission || !["read", "write", "admin"].includes(body.permission)) {
|
|
1683
|
+
throw new ValidationError("permission must be read, write, or admin");
|
|
1684
|
+
}
|
|
1685
|
+
const db = getDb();
|
|
1686
|
+
db.prepare("UPDATE nest_collaborators SET permission = ? WHERE id = ?").run(
|
|
1687
|
+
body.permission,
|
|
1688
|
+
c.req.param("collabId")
|
|
1689
|
+
);
|
|
1690
|
+
return c.json({ updated: true });
|
|
1691
|
+
});
|
|
1692
|
+
sharingRoutes.delete("/collaborators/:collabId", async (c) => {
|
|
1693
|
+
const db = getDb();
|
|
1694
|
+
db.prepare("DELETE FROM nest_collaborators WHERE id = ?").run(
|
|
1695
|
+
c.req.param("collabId")
|
|
1696
|
+
);
|
|
1697
|
+
return c.json({ removed: true });
|
|
1698
|
+
});
|
|
1699
|
+
sharingRoutes.patch("/visibility", async (c) => {
|
|
1700
|
+
const body = await c.req.json();
|
|
1701
|
+
if (!body.visibility || !["private", "public"].includes(body.visibility)) {
|
|
1702
|
+
throw new ValidationError("visibility must be private or public");
|
|
1703
|
+
}
|
|
1704
|
+
const db = getDb();
|
|
1705
|
+
db.prepare("UPDATE nests SET visibility = ? WHERE id = ?").run(
|
|
1706
|
+
body.visibility,
|
|
1707
|
+
c.req.param("nestId")
|
|
1708
|
+
);
|
|
1709
|
+
return c.json({ visibility: body.visibility });
|
|
1710
|
+
});
|
|
1711
|
+
|
|
1712
|
+
// src/nodes/routes.ts
|
|
1713
|
+
import { Hono as Hono4 } from "hono";
|
|
1714
|
+
|
|
1715
|
+
// src/nodes/markdown-export.ts
|
|
1716
|
+
function nodeToMarkdown(node) {
|
|
1717
|
+
const tags = node.tags ?? [];
|
|
1718
|
+
return [
|
|
1719
|
+
"---",
|
|
1720
|
+
`title: ${JSON.stringify(node.title ?? "")}`,
|
|
1721
|
+
`tags: [${tags.map((t) => JSON.stringify(t)).join(", ")}]`,
|
|
1722
|
+
`status: ${JSON.stringify(node.status ?? "")}`,
|
|
1723
|
+
`id: ${JSON.stringify(node.id)}`,
|
|
1724
|
+
"---",
|
|
1725
|
+
""
|
|
1726
|
+
].join("\n") + (node.body ?? "");
|
|
1727
|
+
}
|
|
1728
|
+
function nodesToMarkdown(nodes) {
|
|
1729
|
+
return nodes.map(nodeToMarkdown).join("\n\n---\n\n");
|
|
1730
|
+
}
|
|
1731
|
+
function isMarkdownFormat(c) {
|
|
1732
|
+
return c.req.query("format") === "markdown";
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
// src/nodes/routes.ts
|
|
1736
|
+
var nodeRoutes = new Hono4();
|
|
1737
|
+
function nodeAsMarkdown(response, nodeId) {
|
|
1738
|
+
return nodeToMarkdown({
|
|
1739
|
+
id: nodeId,
|
|
1740
|
+
title: response.title,
|
|
1741
|
+
tags: response.tags,
|
|
1742
|
+
status: response.status,
|
|
1743
|
+
body: response.content
|
|
1744
|
+
});
|
|
1745
|
+
}
|
|
1746
|
+
nodeRoutes.get("/", async (c) => {
|
|
1747
|
+
const nestId = c.req.param("nestId");
|
|
1748
|
+
const userId = c.get("userId");
|
|
1749
|
+
const nodes = await listNodesForCaller(nestId, userId);
|
|
1750
|
+
return c.json({ count: nodes.length, nodes });
|
|
1751
|
+
});
|
|
1752
|
+
nodeRoutes.post("/", async (c) => {
|
|
1753
|
+
const body = await c.req.json();
|
|
1754
|
+
if (!body.title || !body.content) {
|
|
1755
|
+
throw new ValidationError("title and content are required");
|
|
1756
|
+
}
|
|
1757
|
+
const nestId = c.req.param("nestId");
|
|
1758
|
+
const authorEmail = getUserEmail(c);
|
|
1759
|
+
const { node } = await createNode(
|
|
1760
|
+
nestId,
|
|
1761
|
+
{
|
|
1762
|
+
title: body.title,
|
|
1763
|
+
content: body.content,
|
|
1764
|
+
type: body.type,
|
|
1765
|
+
tags: body.tags,
|
|
1766
|
+
scope: body.scope,
|
|
1767
|
+
status: body.status
|
|
1768
|
+
},
|
|
1769
|
+
authorEmail
|
|
1770
|
+
);
|
|
1771
|
+
const resolved = resolveStewardsForNode(nestId, node.id);
|
|
1796
1772
|
return c.json({
|
|
1797
1773
|
node: toNodeResponse(node),
|
|
1798
1774
|
stewards: resolved.length > 0 ? resolved.map((r) => ({
|
|
@@ -1805,7 +1781,7 @@ nodeRoutes.post("/", async (c) => {
|
|
|
1805
1781
|
nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
|
|
1806
1782
|
const nestId = c.req.param("nestId");
|
|
1807
1783
|
const nodeId = c.req.param("nodeId");
|
|
1808
|
-
const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-
|
|
1784
|
+
const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-VOD5HY3I.js");
|
|
1809
1785
|
const { stewards, fallbackToOwner, ownerEmail } = resolveStewardsWithFallback2(
|
|
1810
1786
|
nestId,
|
|
1811
1787
|
nodeId
|
|
@@ -1826,7 +1802,7 @@ nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
|
|
|
1826
1802
|
nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
|
|
1827
1803
|
const nestId = c.req.param("nestId");
|
|
1828
1804
|
const nodeId = c.req.param("nodeId");
|
|
1829
|
-
const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-
|
|
1805
|
+
const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-OCZUV2QP.js");
|
|
1830
1806
|
const allVersions = getVersions2(nestId, nodeId);
|
|
1831
1807
|
const approved = getApprovedVersion2(nestId, nodeId);
|
|
1832
1808
|
const db = getDb();
|
|
@@ -1857,16 +1833,52 @@ nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
|
|
|
1857
1833
|
nodeRoutes.get("/:nodeId{.+?}/reviews", async (c) => {
|
|
1858
1834
|
const nestId = c.req.param("nestId");
|
|
1859
1835
|
const nodeId = c.req.param("nodeId");
|
|
1860
|
-
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-
|
|
1836
|
+
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-GYX3AW6E.js");
|
|
1861
1837
|
const history = getReviewHistory2(nestId, nodeId);
|
|
1862
1838
|
return c.json({ reviews: history });
|
|
1863
1839
|
});
|
|
1840
|
+
nodeRoutes.post("/:nodeId{.+}/revert", async (c) => {
|
|
1841
|
+
const nestId = c.req.param("nestId");
|
|
1842
|
+
const nodeId = c.req.param("nodeId");
|
|
1843
|
+
const { versions: versionManager } = engineCache.get(nestId);
|
|
1844
|
+
const userId = c.get("userId");
|
|
1845
|
+
const userEmail = resolveCallerEmail(userId);
|
|
1846
|
+
if (!canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
1847
|
+
return c.json(
|
|
1848
|
+
{ error: "Access denied \u2014 no steward assignment for this node" },
|
|
1849
|
+
403
|
|
1850
|
+
);
|
|
1851
|
+
}
|
|
1852
|
+
const body = await c.req.json().catch(() => ({}));
|
|
1853
|
+
const targetVersion = Number(body.targetVersion);
|
|
1854
|
+
if (!Number.isInteger(targetVersion) || targetVersion < 1) {
|
|
1855
|
+
throw new ValidationError("targetVersion (a positive integer) is required");
|
|
1856
|
+
}
|
|
1857
|
+
let raw;
|
|
1858
|
+
try {
|
|
1859
|
+
raw = await versionManager.reconstructVersion(nodeId, targetVersion);
|
|
1860
|
+
} catch {
|
|
1861
|
+
throw new NotFoundError(
|
|
1862
|
+
`Version ${targetVersion} not found for ${nodeId}`
|
|
1863
|
+
);
|
|
1864
|
+
}
|
|
1865
|
+
const content = bodyOnly(nodeId, raw);
|
|
1866
|
+
const { node, version } = await updateNode(
|
|
1867
|
+
nestId,
|
|
1868
|
+
nodeId,
|
|
1869
|
+
{ content, changeNote: `Restored from version ${targetVersion}` },
|
|
1870
|
+
userEmail
|
|
1871
|
+
);
|
|
1872
|
+
trackEvent("node.revert", { nestId, nodeId, targetVersion });
|
|
1873
|
+
return c.json({ ok: true, version, node: toNodeResponse(node) });
|
|
1874
|
+
});
|
|
1864
1875
|
nodeRoutes.get("/:nodeId{.+}", async (c) => {
|
|
1865
1876
|
const nestId = c.req.param("nestId");
|
|
1866
1877
|
const nodeId = c.req.param("nodeId");
|
|
1867
|
-
const { storage } = engineCache.get(nestId);
|
|
1868
|
-
const
|
|
1869
|
-
|
|
1878
|
+
const { storage, versions: versionManager } = engineCache.get(nestId);
|
|
1879
|
+
const userId = c.get("userId");
|
|
1880
|
+
const userEmail = resolveCallerEmail(userId);
|
|
1881
|
+
if (!canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
1870
1882
|
return c.json(
|
|
1871
1883
|
{ error: "Access denied \u2014 no steward assignment for this node" },
|
|
1872
1884
|
403
|
|
@@ -1886,15 +1898,59 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
|
|
|
1886
1898
|
} catch (err) {
|
|
1887
1899
|
console.error("[external-edit] stage-on-read failed:", err);
|
|
1888
1900
|
}
|
|
1901
|
+
try {
|
|
1902
|
+
const latest = await loadLatestApprovedNode(nestId, nodeId);
|
|
1903
|
+
if (latest) {
|
|
1904
|
+
node = { ...latest, pendingChange: node.pendingChange };
|
|
1905
|
+
}
|
|
1906
|
+
} catch (err) {
|
|
1907
|
+
console.error("[external-edit] reconstruct-latest failed:", err);
|
|
1908
|
+
}
|
|
1889
1909
|
}
|
|
1890
1910
|
const response = toNodeResponse(node);
|
|
1911
|
+
if (isPublicReader(nestId, userId)) {
|
|
1912
|
+
const approved = getApprovedVersion(nestId, nodeId);
|
|
1913
|
+
if (approved != null) {
|
|
1914
|
+
try {
|
|
1915
|
+
const raw = await versionManager.reconstructVersion(
|
|
1916
|
+
nodeId,
|
|
1917
|
+
approved
|
|
1918
|
+
);
|
|
1919
|
+
response.content = bodyOnly(nodeId, raw);
|
|
1920
|
+
} catch (err) {
|
|
1921
|
+
console.error(
|
|
1922
|
+
"reconstructVersion failed (public single)",
|
|
1923
|
+
nodeId,
|
|
1924
|
+
approved,
|
|
1925
|
+
err
|
|
1926
|
+
);
|
|
1927
|
+
response.content = "";
|
|
1928
|
+
}
|
|
1929
|
+
response.version = approved;
|
|
1930
|
+
response.status = "published";
|
|
1931
|
+
if (isMarkdownFormat(c)) {
|
|
1932
|
+
return c.body(nodeAsMarkdown(response, nodeId), 200, {
|
|
1933
|
+
"Content-Type": "text/markdown; charset=utf-8"
|
|
1934
|
+
});
|
|
1935
|
+
}
|
|
1936
|
+
return c.json({ node: response });
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1891
1939
|
response.status = node.pendingChange ? "external_edit_pending" : getDisplayStatus(nestId, nodeId);
|
|
1940
|
+
if (response.status === "pending_review") {
|
|
1941
|
+
const pending = getPendingReview(nestId, nodeId);
|
|
1942
|
+
response.pendingReviewBy = pending?.requestedBy ?? null;
|
|
1943
|
+
}
|
|
1944
|
+
if (isMarkdownFormat(c)) {
|
|
1945
|
+
return c.body(nodeAsMarkdown(response, nodeId), 200, {
|
|
1946
|
+
"Content-Type": "text/markdown; charset=utf-8"
|
|
1947
|
+
});
|
|
1948
|
+
}
|
|
1892
1949
|
return c.json({ node: response });
|
|
1893
1950
|
});
|
|
1894
1951
|
nodeRoutes.patch("/:nodeId{.+}", async (c) => {
|
|
1895
1952
|
const nestId = c.req.param("nestId");
|
|
1896
1953
|
const nodeId = c.req.param("nodeId");
|
|
1897
|
-
const { storage, versions: versionManager } = engineCache.get(nestId);
|
|
1898
1954
|
const body = await c.req.json();
|
|
1899
1955
|
const baseVersionHeader = c.req.header("X-Base-Version");
|
|
1900
1956
|
if (baseVersionHeader) {
|
|
@@ -1914,133 +1970,31 @@ nodeRoutes.patch("/:nodeId{.+}", async (c) => {
|
|
|
1914
1970
|
);
|
|
1915
1971
|
}
|
|
1916
1972
|
}
|
|
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
1973
|
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,
|
|
1974
|
+
const { node, version: responseVersion } = await updateNode(
|
|
1975
|
+
nestId,
|
|
1976
|
+
nodeId,
|
|
1977
|
+
{
|
|
1978
|
+
content: body.content,
|
|
1979
|
+
append: body.append,
|
|
1980
|
+
tags: body.tags,
|
|
1981
|
+
title: body.title,
|
|
1982
|
+
status: body.status,
|
|
2033
1983
|
changeNote: body.changeNote
|
|
2034
|
-
}
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
}
|
|
1984
|
+
},
|
|
1985
|
+
authorEmail
|
|
1986
|
+
);
|
|
2038
1987
|
return c.json({ node: toNodeResponse(node), version: responseVersion });
|
|
2039
1988
|
});
|
|
2040
1989
|
nodeRoutes.delete("/:nodeId{.+}", async (c) => {
|
|
2041
1990
|
const nestId = c.req.param("nestId");
|
|
2042
1991
|
const nodeId = c.req.param("nodeId");
|
|
2043
1992
|
const { storage } = engineCache.get(nestId);
|
|
1993
|
+
if (isStewardshipEnabled(nestId) && getPendingReview(nestId, nodeId)) {
|
|
1994
|
+
throw new LockedError(
|
|
1995
|
+
"This document is awaiting steward review and is locked. Approve or reject the pending review before deleting."
|
|
1996
|
+
);
|
|
1997
|
+
}
|
|
2044
1998
|
try {
|
|
2045
1999
|
await storage.deleteDocument(nodeId);
|
|
2046
2000
|
} catch {
|
|
@@ -2058,6 +2012,10 @@ nodeRoutes.delete("/:nodeId{.+}", async (c) => {
|
|
|
2058
2012
|
db.prepare(
|
|
2059
2013
|
"DELETE FROM approved_versions WHERE nest_id = ? AND node_id = ?"
|
|
2060
2014
|
).run(nestId, nodeId);
|
|
2015
|
+
db.prepare(
|
|
2016
|
+
`DELETE FROM stewards
|
|
2017
|
+
WHERE nest_id = ? AND scope = 'document' AND node_pattern = ?`
|
|
2018
|
+
).run(nestId, nodeId);
|
|
2061
2019
|
})();
|
|
2062
2020
|
trackEvent("node.delete", { nestId, nodeId });
|
|
2063
2021
|
return c.json({ deleted: true });
|
|
@@ -2262,6 +2220,32 @@ function compilePrompt(prompt, nestId, titles) {
|
|
|
2262
2220
|
};
|
|
2263
2221
|
}
|
|
2264
2222
|
|
|
2223
|
+
// src/nodes/readable-body.ts
|
|
2224
|
+
async function resolveReadableBody(nestId, nodeId, userId, workingBody) {
|
|
2225
|
+
if (!isPublicReader(nestId, userId)) return workingBody;
|
|
2226
|
+
const approved = getApprovedVersion(nestId, nodeId);
|
|
2227
|
+
if (approved == null) return "";
|
|
2228
|
+
try {
|
|
2229
|
+
const { versions } = engineCache.get(nestId);
|
|
2230
|
+
const raw = await versions.reconstructVersion(nodeId, approved);
|
|
2231
|
+
return bodyOnly(nodeId, raw);
|
|
2232
|
+
} catch {
|
|
2233
|
+
return "";
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
async function resolveExportBody(nestId, nodeId, workingBody) {
|
|
2237
|
+
if (!isStewardshipEnabled(nestId)) return workingBody;
|
|
2238
|
+
const approved = getApprovedVersion(nestId, nodeId);
|
|
2239
|
+
if (approved == null) return null;
|
|
2240
|
+
try {
|
|
2241
|
+
const { versions } = engineCache.get(nestId);
|
|
2242
|
+
const raw = await versions.reconstructVersion(nodeId, approved);
|
|
2243
|
+
return bodyOnly(nodeId, raw);
|
|
2244
|
+
} catch {
|
|
2245
|
+
return null;
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2265
2249
|
// src/nodes/query-routes.ts
|
|
2266
2250
|
var queryRoutes = new Hono5();
|
|
2267
2251
|
function approxTokens(text) {
|
|
@@ -2327,13 +2311,20 @@ queryRoutes.post("/context", async (c) => {
|
|
|
2327
2311
|
}
|
|
2328
2312
|
}
|
|
2329
2313
|
}
|
|
2330
|
-
const
|
|
2314
|
+
const userId = c.get("userId");
|
|
2315
|
+
const userEmail = resolveCallerEmail(userId);
|
|
2331
2316
|
const beforePermission = documents.length;
|
|
2332
|
-
const accessible = filterAccessible(nestId, userEmail, documents);
|
|
2317
|
+
const accessible = filterAccessible(nestId, userId, userEmail, documents);
|
|
2333
2318
|
const permissionFiltered = beforePermission - accessible.length;
|
|
2319
|
+
const readable = await Promise.all(
|
|
2320
|
+
accessible.map(async (doc) => ({
|
|
2321
|
+
...doc,
|
|
2322
|
+
body: await resolveReadableBody(nestId, doc.id, userId, doc.body || "")
|
|
2323
|
+
}))
|
|
2324
|
+
);
|
|
2334
2325
|
const included = [];
|
|
2335
2326
|
let tokenCount = 0;
|
|
2336
|
-
for (const doc of
|
|
2327
|
+
for (const doc of readable) {
|
|
2337
2328
|
const block = formatContextBlock(doc);
|
|
2338
2329
|
const blockTokens = approxTokens(block);
|
|
2339
2330
|
if (tokenCount + blockTokens > maxTokens && included.length > 0) break;
|
|
@@ -2386,8 +2377,9 @@ queryRoutes.post("/query", async (c) => {
|
|
|
2386
2377
|
const result = await queryEngine.query(body.query, {
|
|
2387
2378
|
hops: body.hops ?? 2
|
|
2388
2379
|
});
|
|
2389
|
-
const
|
|
2390
|
-
const
|
|
2380
|
+
const userId = c.get("userId");
|
|
2381
|
+
const userEmail = resolveCallerEmail(userId);
|
|
2382
|
+
const accessible = filterAccessible(nestId, userId, userEmail, result.documents);
|
|
2391
2383
|
return c.json({
|
|
2392
2384
|
query: body.query,
|
|
2393
2385
|
count: accessible.length,
|
|
@@ -2418,8 +2410,9 @@ queryRoutes.get("/search", async (c) => {
|
|
|
2418
2410
|
].join(" ").toLowerCase();
|
|
2419
2411
|
return terms.every((term) => haystack.includes(term));
|
|
2420
2412
|
});
|
|
2421
|
-
const
|
|
2422
|
-
const
|
|
2413
|
+
const userId = c.get("userId");
|
|
2414
|
+
const userEmail = resolveCallerEmail(userId);
|
|
2415
|
+
const accessible = filterAccessible(nestId, userId, userEmail, matches);
|
|
2423
2416
|
return c.json({
|
|
2424
2417
|
query: q,
|
|
2425
2418
|
count: accessible.length,
|
|
@@ -2462,6 +2455,64 @@ queryRoutes.get("/context", async (c) => {
|
|
|
2462
2455
|
const content = await storage.readContextMd();
|
|
2463
2456
|
return c.json({ content: content || "" });
|
|
2464
2457
|
});
|
|
2458
|
+
queryRoutes.get("/export", async (c) => {
|
|
2459
|
+
if (!isMarkdownFormat(c)) {
|
|
2460
|
+
throw new ValidationError("format=markdown is required");
|
|
2461
|
+
}
|
|
2462
|
+
const nestId = c.req.param("nestId");
|
|
2463
|
+
const { storage, query: queryEngine } = engineCache.get(nestId);
|
|
2464
|
+
const selector = c.req.query("selector")?.trim() || null;
|
|
2465
|
+
let documents;
|
|
2466
|
+
if (selector) {
|
|
2467
|
+
const result = await queryEngine.query(selector, { hops: 2, full: true });
|
|
2468
|
+
documents = result.documents;
|
|
2469
|
+
} else {
|
|
2470
|
+
documents = await storage.discoverDocuments();
|
|
2471
|
+
}
|
|
2472
|
+
const userId = c.get("userId");
|
|
2473
|
+
const userEmail = resolveCallerEmail(userId);
|
|
2474
|
+
const accessible = filterAccessible(nestId, userId, userEmail, documents);
|
|
2475
|
+
const governed = isStewardshipEnabled(nestId);
|
|
2476
|
+
const resolved = await Promise.all(
|
|
2477
|
+
accessible.map(async (n) => {
|
|
2478
|
+
const body = await resolveExportBody(nestId, n.id, n.body || "");
|
|
2479
|
+
if (body == null) return null;
|
|
2480
|
+
return {
|
|
2481
|
+
id: n.id,
|
|
2482
|
+
title: n.frontmatter.title,
|
|
2483
|
+
tags: n.frontmatter.tags || [],
|
|
2484
|
+
status: governed ? "published" : n.frontmatter.status,
|
|
2485
|
+
body
|
|
2486
|
+
};
|
|
2487
|
+
})
|
|
2488
|
+
);
|
|
2489
|
+
const fields = resolved.filter(
|
|
2490
|
+
(f) => f != null
|
|
2491
|
+
);
|
|
2492
|
+
const maxParam = parseInt(c.req.query("max_tokens") ?? "", 10);
|
|
2493
|
+
const maxTokens = Number.isFinite(maxParam) && maxParam > 0 ? maxParam : null;
|
|
2494
|
+
let included = fields;
|
|
2495
|
+
if (maxTokens) {
|
|
2496
|
+
const kept = [];
|
|
2497
|
+
let tokens = 0;
|
|
2498
|
+
for (const f of fields) {
|
|
2499
|
+
const t = approxTokens(nodeToMarkdown(f));
|
|
2500
|
+
if (tokens + t > maxTokens && kept.length > 0) break;
|
|
2501
|
+
kept.push(f);
|
|
2502
|
+
tokens += t;
|
|
2503
|
+
}
|
|
2504
|
+
included = kept;
|
|
2505
|
+
}
|
|
2506
|
+
trackEvent("nest.export", {
|
|
2507
|
+
nestId,
|
|
2508
|
+
count: included.length,
|
|
2509
|
+
selector,
|
|
2510
|
+
truncated_by_budget: fields.length - included.length
|
|
2511
|
+
});
|
|
2512
|
+
return c.body(nodesToMarkdown(included), 200, {
|
|
2513
|
+
"Content-Type": "text/markdown; charset=utf-8"
|
|
2514
|
+
});
|
|
2515
|
+
});
|
|
2465
2516
|
queryRoutes.post("/publish", async (c) => {
|
|
2466
2517
|
const body = await c.req.json();
|
|
2467
2518
|
if (!body.documents?.length && !body.context_md) {
|
|
@@ -2520,7 +2571,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2520
2571
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
2521
2572
|
|
|
2522
2573
|
// src/mcp/tools.ts
|
|
2523
|
-
import { serializeDocument as serializeDocument3 } from "@promptowl/contextnest-engine";
|
|
2524
2574
|
var TOOL_DEFINITIONS = [
|
|
2525
2575
|
{
|
|
2526
2576
|
name: "context_init",
|
|
@@ -2733,6 +2783,21 @@ var TOOL_DEFINITIONS = [
|
|
|
2733
2783
|
},
|
|
2734
2784
|
required: ["email", "scope"]
|
|
2735
2785
|
}
|
|
2786
|
+
},
|
|
2787
|
+
{
|
|
2788
|
+
name: "context_share_nest",
|
|
2789
|
+
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.",
|
|
2790
|
+
inputSchema: {
|
|
2791
|
+
type: "object",
|
|
2792
|
+
properties: {
|
|
2793
|
+
email: { type: "string", description: "Email of the person to share with" },
|
|
2794
|
+
permission: {
|
|
2795
|
+
type: "string",
|
|
2796
|
+
description: "Access level: read (viewer, default), write (editor), or admin"
|
|
2797
|
+
}
|
|
2798
|
+
},
|
|
2799
|
+
required: ["email"]
|
|
2800
|
+
}
|
|
2736
2801
|
}
|
|
2737
2802
|
];
|
|
2738
2803
|
async function resolveLlmBody(ctx, node) {
|
|
@@ -2848,24 +2913,14 @@ ${list}`;
|
|
|
2848
2913
|
${body || "(no content)"}`;
|
|
2849
2914
|
}
|
|
2850
2915
|
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):
|
|
2916
|
+
const nodes = await listNodesForCallerByEmail(nestId, userEmail, {
|
|
2917
|
+
type: args.type,
|
|
2918
|
+
tag: args.tag,
|
|
2919
|
+
limit: args.limit || 50
|
|
2920
|
+
});
|
|
2921
|
+
if (!nodes.length) return "No nodes found with the given filters.";
|
|
2922
|
+
const list = nodes.map((n, i) => `${i + 1}. **${n.title}** [${n.type}]`).join("\n");
|
|
2923
|
+
return `${nodes.length} node(s):
|
|
2869
2924
|
|
|
2870
2925
|
${list}`;
|
|
2871
2926
|
}
|
|
@@ -2886,50 +2941,21 @@ ${n.body || ""}`;
|
|
|
2886
2941
|
return resolved.join("\n\n---\n\n") || "No nodes resolved.";
|
|
2887
2942
|
}
|
|
2888
2943
|
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);
|
|
2944
|
+
if (!canCreateInNest(nestId, userEmail)) {
|
|
2945
|
+
return "You don't have permission to create documents in this nest.";
|
|
2919
2946
|
}
|
|
2920
|
-
|
|
2947
|
+
const { node } = await createNode(
|
|
2921
2948
|
nestId,
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
}
|
|
2932
|
-
return `Created node: **${args.title}** (${id}) \u2014 status: ${initialStatus}`;
|
|
2949
|
+
{
|
|
2950
|
+
title: args.title,
|
|
2951
|
+
content: args.content,
|
|
2952
|
+
type: args.type,
|
|
2953
|
+
tags: args.tags,
|
|
2954
|
+
scope: args.scope
|
|
2955
|
+
},
|
|
2956
|
+
userEmail
|
|
2957
|
+
);
|
|
2958
|
+
return `Created node: **${args.title}** (${node.id}) \u2014 status: ${node.frontmatter.status}`;
|
|
2933
2959
|
}
|
|
2934
2960
|
case "context_update": {
|
|
2935
2961
|
const docs = await storage.discoverDocuments();
|
|
@@ -2937,56 +2963,21 @@ ${n.body || ""}`;
|
|
|
2937
2963
|
(n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
|
|
2938
2964
|
);
|
|
2939
2965
|
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);
|
|
2966
|
+
const editCheck = canUserEdit(nestId, node.id, userEmail);
|
|
2967
|
+
if (!editCheck.allowed) {
|
|
2968
|
+
return `You don't have permission to edit "${args.title}": ${editCheck.reason}`;
|
|
2968
2969
|
}
|
|
2969
|
-
|
|
2970
|
-
createVersion({
|
|
2970
|
+
const { node: updated } = await updateNode(
|
|
2971
2971
|
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}**`;
|
|
2972
|
+
node.id,
|
|
2973
|
+
{
|
|
2974
|
+
content: args.content,
|
|
2975
|
+
append: args.append,
|
|
2976
|
+
tags: args.tags
|
|
2977
|
+
},
|
|
2978
|
+
userEmail
|
|
2979
|
+
);
|
|
2980
|
+
return `Updated node: **${updated.frontmatter.title}**`;
|
|
2990
2981
|
}
|
|
2991
2982
|
// ─── Governance Tool Handlers ──────────────────────────────────────
|
|
2992
2983
|
case "context_stewards": {
|
|
@@ -3007,13 +2998,16 @@ ${n.body || ""}`;
|
|
|
3007
2998
|
|
|
3008
2999
|
${list}`;
|
|
3009
3000
|
}
|
|
3010
|
-
|
|
3001
|
+
if (!canManageStewards(ctx.userEmail)) {
|
|
3002
|
+
return "You don't have permission to list stewards. Only the super admin can do this.";
|
|
3003
|
+
}
|
|
3004
|
+
const allStewards = await getStewardsForNest(ctx.nestId);
|
|
3011
3005
|
if (allStewards.length === 0) {
|
|
3012
3006
|
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
3007
|
}
|
|
3014
3008
|
const byScope = {};
|
|
3015
3009
|
for (const s of allStewards) {
|
|
3016
|
-
const key = s.scope === "nest" ? "Nest-level" : s.scope === "tag" ? `Tag: ${s.tagName}` : `Document: ${s.nodePattern}`;
|
|
3010
|
+
const key = s.scope === "nest" ? "Nest-level" : s.scope === "tag" ? `Tag: ${s.tagName}` : `Document: ${s.nodeTitle || s.nodePattern}`;
|
|
3017
3011
|
(byScope[key] = byScope[key] || []).push(s);
|
|
3018
3012
|
}
|
|
3019
3013
|
const sections = Object.entries(byScope).map(
|
|
@@ -3026,7 +3020,7 @@ ${sections}`;
|
|
|
3026
3020
|
}
|
|
3027
3021
|
case "context_review_queue": {
|
|
3028
3022
|
const status = args.status || "pending";
|
|
3029
|
-
const result = getReviewQueue({
|
|
3023
|
+
const result = await getReviewQueue({
|
|
3030
3024
|
nestId: ctx.nestId,
|
|
3031
3025
|
status
|
|
3032
3026
|
});
|
|
@@ -3034,7 +3028,7 @@ ${sections}`;
|
|
|
3034
3028
|
return status === "pending" ? "No documents pending review. All caught up!" : `No reviews with status "${status}".`;
|
|
3035
3029
|
}
|
|
3036
3030
|
const list = result.requests.map(
|
|
3037
|
-
(r, i) => `${i + 1}. **${r.nodeId}** v${r.version} \u2014 ${r.priority} priority
|
|
3031
|
+
(r, i) => `${i + 1}. **${r.title || r.nodeId}** v${r.version} \u2014 ${r.priority} priority
|
|
3038
3032
|
Submitted by: ${r.requestedBy} at ${r.requestedAt}${r.requestNote ? `
|
|
3039
3033
|
Note: "${r.requestNote}"` : ""}`
|
|
3040
3034
|
).join("\n\n");
|
|
@@ -3049,6 +3043,10 @@ ${list}`;
|
|
|
3049
3043
|
(n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
|
|
3050
3044
|
);
|
|
3051
3045
|
if (!node) return `Node not found: ${args.title}`;
|
|
3046
|
+
const submitCheck = canUserEdit(ctx.nestId, node.id, userEmail);
|
|
3047
|
+
if (!submitCheck.allowed) {
|
|
3048
|
+
return `You don't have permission to submit "${args.title}" for review: ${submitCheck.reason}`;
|
|
3049
|
+
}
|
|
3052
3050
|
const currentVersion = getCurrentVersion(ctx.nestId, node.id);
|
|
3053
3051
|
if (currentVersion === 0) return `No versions found for "${args.title}"`;
|
|
3054
3052
|
try {
|
|
@@ -3139,17 +3137,17 @@ ${list}`;
|
|
|
3139
3137
|
if (!["nest", "tag", "document"].includes(scope)) {
|
|
3140
3138
|
return `Invalid scope "${args.scope}". Use: nest, tag, or document.`;
|
|
3141
3139
|
}
|
|
3140
|
+
if (!canManageStewards(ctx.userEmail)) {
|
|
3141
|
+
return "You don't have permission to manage stewards. Only the super admin can do this.";
|
|
3142
|
+
}
|
|
3142
3143
|
try {
|
|
3143
|
-
|
|
3144
|
+
await createStewardRecord({
|
|
3144
3145
|
nestId: ctx.nestId,
|
|
3145
3146
|
scope,
|
|
3146
|
-
|
|
3147
|
+
documentId: scope === "document" ? args.target : void 0,
|
|
3147
3148
|
tagName: scope === "tag" ? args.target : void 0,
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
assignedBy: ctx.userEmail,
|
|
3151
|
-
assignedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3152
|
-
isActive: true
|
|
3149
|
+
users: [{ email: args.email, role: args.role || "reviewer" }],
|
|
3150
|
+
assignedBy: ctx.userEmail
|
|
3153
3151
|
});
|
|
3154
3152
|
const targetDesc = scope === "nest" ? "all documents" : `${scope}: ${args.target}`;
|
|
3155
3153
|
return `Assigned **${args.email}** as ${args.role || "reviewer"} for ${targetDesc}.`;
|
|
@@ -3157,6 +3155,25 @@ ${list}`;
|
|
|
3157
3155
|
return `Failed to assign steward: ${err.message}`;
|
|
3158
3156
|
}
|
|
3159
3157
|
}
|
|
3158
|
+
case "context_share_nest": {
|
|
3159
|
+
const roles = resolveUserRoles(ctx.nestId, ctx.userEmail);
|
|
3160
|
+
if (!canManageWith(roles)) {
|
|
3161
|
+
return "You don't have permission to share this nest. Only the nest owner or an admin can add people.";
|
|
3162
|
+
}
|
|
3163
|
+
const permission = args.permission || "read";
|
|
3164
|
+
try {
|
|
3165
|
+
await addCollaborator({
|
|
3166
|
+
nestId: ctx.nestId,
|
|
3167
|
+
email: args.email,
|
|
3168
|
+
permission,
|
|
3169
|
+
grantedByEmail: ctx.userEmail
|
|
3170
|
+
});
|
|
3171
|
+
const label = permission === "admin" ? "admin" : permission === "write" ? "editor" : "viewer";
|
|
3172
|
+
return `Shared this nest with **${args.email}** as ${label}.`;
|
|
3173
|
+
} catch (err) {
|
|
3174
|
+
return `Failed to share nest: ${err.message}`;
|
|
3175
|
+
}
|
|
3176
|
+
}
|
|
3160
3177
|
default:
|
|
3161
3178
|
return `Unknown tool: ${toolName}`;
|
|
3162
3179
|
}
|
|
@@ -3225,8 +3242,8 @@ mcpRoutes.all("/", async (c) => {
|
|
|
3225
3242
|
import { Hono as Hono7 } from "hono";
|
|
3226
3243
|
|
|
3227
3244
|
// src/governance/stewards-parser.ts
|
|
3228
|
-
import { readFileSync
|
|
3229
|
-
import { join as
|
|
3245
|
+
import { readFileSync, existsSync } from "fs";
|
|
3246
|
+
import { join as join2 } from "path";
|
|
3230
3247
|
function parseStewardsYaml(content) {
|
|
3231
3248
|
const result = { version: 1 };
|
|
3232
3249
|
const lines = content.split("\n");
|
|
@@ -3291,15 +3308,15 @@ function parseEntry(str) {
|
|
|
3291
3308
|
}
|
|
3292
3309
|
function loadStewardsConfig(nestId) {
|
|
3293
3310
|
const dataRoot = config.DATA_ROOT;
|
|
3294
|
-
const
|
|
3311
|
+
const nestPath = join2(dataRoot, "nests", nestId);
|
|
3295
3312
|
const candidates = [
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3313
|
+
join2(nestPath, "stewards.yaml"),
|
|
3314
|
+
join2(nestPath, "stewards.yml"),
|
|
3315
|
+
join2(nestPath, ".context", "stewards.yaml")
|
|
3299
3316
|
];
|
|
3300
3317
|
for (const candidatePath of candidates) {
|
|
3301
|
-
if (
|
|
3302
|
-
const content =
|
|
3318
|
+
if (existsSync(candidatePath)) {
|
|
3319
|
+
const content = readFileSync(candidatePath, "utf-8");
|
|
3303
3320
|
return parseStewardsYaml(content);
|
|
3304
3321
|
}
|
|
3305
3322
|
}
|
|
@@ -3312,12 +3329,25 @@ governanceRoutes.get("/stewards", async (c) => {
|
|
|
3312
3329
|
const nestId = c.req.param("nestId");
|
|
3313
3330
|
const scope = c.req.query("scope");
|
|
3314
3331
|
const search = c.req.query("search");
|
|
3315
|
-
const stewards = listStewards({
|
|
3332
|
+
const stewards = await listStewards({
|
|
3316
3333
|
nestId,
|
|
3317
3334
|
scope: scope || void 0,
|
|
3318
3335
|
search: search || void 0
|
|
3319
3336
|
});
|
|
3320
|
-
|
|
3337
|
+
const cache = /* @__PURE__ */ new Map();
|
|
3338
|
+
const enriched = stewards.map((s) => {
|
|
3339
|
+
const key = s.userEmail.toLowerCase();
|
|
3340
|
+
let merged = cache.get(key);
|
|
3341
|
+
if (!merged) {
|
|
3342
|
+
merged = {
|
|
3343
|
+
collaboratorRole: getCollaboratorRole(nestId, s.userEmail),
|
|
3344
|
+
roles: resolveUserRoles(nestId, s.userEmail)
|
|
3345
|
+
};
|
|
3346
|
+
cache.set(key, merged);
|
|
3347
|
+
}
|
|
3348
|
+
return { ...s, ...merged };
|
|
3349
|
+
});
|
|
3350
|
+
return c.json({ stewards: enriched });
|
|
3321
3351
|
});
|
|
3322
3352
|
governanceRoutes.post("/stewards", async (c) => {
|
|
3323
3353
|
const nestId = c.req.param("nestId");
|
|
@@ -3358,6 +3388,20 @@ governanceRoutes.post("/stewards", async (c) => {
|
|
|
3358
3388
|
});
|
|
3359
3389
|
return c.json({ steward: created[0] }, 201);
|
|
3360
3390
|
});
|
|
3391
|
+
governanceRoutes.patch("/stewards/:stewardId", async (c) => {
|
|
3392
|
+
const stewardId = c.req.param("stewardId");
|
|
3393
|
+
const body = await c.req.json();
|
|
3394
|
+
if (!body.role && !body.scope) {
|
|
3395
|
+
throw new ValidationError("role or scope is required");
|
|
3396
|
+
}
|
|
3397
|
+
const steward = updateSteward(stewardId, {
|
|
3398
|
+
role: body.role,
|
|
3399
|
+
scope: body.scope,
|
|
3400
|
+
documentId: body.nodePattern,
|
|
3401
|
+
tagName: body.tagName
|
|
3402
|
+
});
|
|
3403
|
+
return c.json({ steward });
|
|
3404
|
+
});
|
|
3361
3405
|
governanceRoutes.delete("/stewards/:stewardId", async (c) => {
|
|
3362
3406
|
const stewardId = c.req.param("stewardId");
|
|
3363
3407
|
removeSteward(stewardId);
|
|
@@ -3377,7 +3421,7 @@ governanceRoutes.get("/review-queue", async (c) => {
|
|
|
3377
3421
|
const status = c.req.query("status") || "pending";
|
|
3378
3422
|
const limit = parseInt(c.req.query("limit") || "50", 10);
|
|
3379
3423
|
const offset = parseInt(c.req.query("offset") || "0", 10);
|
|
3380
|
-
const result = getReviewQueue({
|
|
3424
|
+
const result = await getReviewQueue({
|
|
3381
3425
|
nestId,
|
|
3382
3426
|
status,
|
|
3383
3427
|
limit,
|
|
@@ -3423,8 +3467,19 @@ governanceNodeRoutes.get("/:nodeId{.+}/versions", async (c) => {
|
|
|
3423
3467
|
const nodeId = c.req.param("nodeId");
|
|
3424
3468
|
const allVersions = getVersions(nestId, nodeId);
|
|
3425
3469
|
const approved = getApprovedVersion(nestId, nodeId);
|
|
3470
|
+
const { versions: versionManager } = engineCache.get(nestId);
|
|
3471
|
+
const withContent = await Promise.all(
|
|
3472
|
+
allVersions.map(async (v) => {
|
|
3473
|
+
try {
|
|
3474
|
+
const raw = await versionManager.reconstructVersion(nodeId, v.version);
|
|
3475
|
+
return { ...v, content: bodyOnly(nodeId, raw) };
|
|
3476
|
+
} catch {
|
|
3477
|
+
return v;
|
|
3478
|
+
}
|
|
3479
|
+
})
|
|
3480
|
+
);
|
|
3426
3481
|
return c.json({
|
|
3427
|
-
versions:
|
|
3482
|
+
versions: withContent,
|
|
3428
3483
|
approvedVersion: approved,
|
|
3429
3484
|
currentVersion: allVersions[0]?.version || 0
|
|
3430
3485
|
});
|
|
@@ -3652,14 +3707,14 @@ function ensureAnonymousUser() {
|
|
|
3652
3707
|
// src/app.ts
|
|
3653
3708
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
3654
3709
|
import { fileURLToPath } from "url";
|
|
3655
|
-
import { dirname, join as
|
|
3656
|
-
import { existsSync as
|
|
3710
|
+
import { dirname, join as join3, relative } from "path";
|
|
3711
|
+
import { existsSync as existsSync2 } from "fs";
|
|
3657
3712
|
var HERE = dirname(fileURLToPath(import.meta.url));
|
|
3658
3713
|
var UI_DIR_CANDIDATES = [
|
|
3659
|
-
|
|
3660
|
-
|
|
3714
|
+
join3(HERE, "web3"),
|
|
3715
|
+
join3(process.cwd(), "dist", "web3")
|
|
3661
3716
|
];
|
|
3662
|
-
var UI_DIR_ABS = UI_DIR_CANDIDATES.find((p) =>
|
|
3717
|
+
var UI_DIR_ABS = UI_DIR_CANDIDATES.find((p) => existsSync2(p)) || UI_DIR_CANDIDATES[0];
|
|
3663
3718
|
var UI_DIR_REL = relative(process.cwd(), UI_DIR_ABS) || ".";
|
|
3664
3719
|
var openModeMiddleware = createMiddleware2(async (c, next) => {
|
|
3665
3720
|
const anonId = ensureAnonymousUser();
|
|
@@ -3667,6 +3722,11 @@ var openModeMiddleware = createMiddleware2(async (c, next) => {
|
|
|
3667
3722
|
c.set("nestScope", null);
|
|
3668
3723
|
await next();
|
|
3669
3724
|
});
|
|
3725
|
+
function isPublicReadEligiblePath(method, path) {
|
|
3726
|
+
if (method !== "GET") return false;
|
|
3727
|
+
if (!/^\/nests\/[^/]+(\/.*)?$/.test(path)) return false;
|
|
3728
|
+
return !/\/(collaborators|visibility|settings|mcp)/.test(path);
|
|
3729
|
+
}
|
|
3670
3730
|
var flexAuthMiddleware = createMiddleware2(async (c, next) => {
|
|
3671
3731
|
const hasBearer = c.req.header("Authorization")?.startsWith("Bearer cnst_");
|
|
3672
3732
|
const hasCookie = !!c.req.header("Cookie")?.includes("cnst_session=");
|
|
@@ -3679,6 +3739,12 @@ var flexAuthMiddleware = createMiddleware2(async (c, next) => {
|
|
|
3679
3739
|
c.set("nestScope", null);
|
|
3680
3740
|
return next();
|
|
3681
3741
|
}
|
|
3742
|
+
if (isPublicReadEligiblePath(c.req.method, c.req.path)) {
|
|
3743
|
+
const anonId = ensureAnonymousUser();
|
|
3744
|
+
c.set("userId", anonId);
|
|
3745
|
+
c.set("nestScope", null);
|
|
3746
|
+
return next();
|
|
3747
|
+
}
|
|
3682
3748
|
return c.json({ error: "Missing or invalid credentials" }, 401);
|
|
3683
3749
|
});
|
|
3684
3750
|
function createApp() {
|
|
@@ -3710,6 +3776,8 @@ function createApp() {
|
|
|
3710
3776
|
service: "contextnest-community",
|
|
3711
3777
|
version: "0.1.0",
|
|
3712
3778
|
auth_mode: config.AUTH_MODE,
|
|
3779
|
+
logo_url: config.LOGO_URL,
|
|
3780
|
+
promptowl_sign_in_gate: config.PROMPTOWL_SIGN_IN_GATE,
|
|
3713
3781
|
...isSuspended() && { suspended_reason: getSuspensionReason() }
|
|
3714
3782
|
})
|
|
3715
3783
|
);
|
|
@@ -3797,7 +3865,7 @@ function createApp() {
|
|
|
3797
3865
|
try {
|
|
3798
3866
|
const { storage } = engineCache.get(nest.id);
|
|
3799
3867
|
const docs = await storage.discoverDocuments();
|
|
3800
|
-
documents += filterAccessible(nest.id, userEmail, docs).length;
|
|
3868
|
+
documents += filterAccessible(nest.id, userId, userEmail, docs).length;
|
|
3801
3869
|
} catch {
|
|
3802
3870
|
}
|
|
3803
3871
|
}
|
|
@@ -3859,12 +3927,44 @@ function createApp() {
|
|
|
3859
3927
|
let required = "read";
|
|
3860
3928
|
const path = c.req.path;
|
|
3861
3929
|
const isStewardActionPath = path.includes("/approve") || path.includes("/reject") || path.includes("/submit-review") || path.includes("/cancel-review");
|
|
3930
|
+
const isStewardRoster = path.includes("/stewards") && !path.includes("/nodes/");
|
|
3931
|
+
if (isStewardRoster && !canManageStewards(resolveCallerEmail(userId))) {
|
|
3932
|
+
return c.json(
|
|
3933
|
+
{
|
|
3934
|
+
error: "You don't have permission to manage stewards. Only the super admin can do this."
|
|
3935
|
+
},
|
|
3936
|
+
403
|
|
3937
|
+
);
|
|
3938
|
+
}
|
|
3862
3939
|
if (path.includes("/collaborators") || path.includes("/visibility")) {
|
|
3863
3940
|
required = "admin";
|
|
3864
3941
|
} else if (c.req.method !== "GET" && !isStewardActionPath) {
|
|
3865
3942
|
required = "write";
|
|
3866
3943
|
}
|
|
3867
|
-
|
|
3944
|
+
const isNodeRevert = c.req.method === "POST" && parts.length >= 4 && parts[parts.length - 1] === "revert";
|
|
3945
|
+
let stewardEditorBypass = false;
|
|
3946
|
+
if (required === "write" && permission === "read" && parts[1] === "nodes") {
|
|
3947
|
+
const userEmail = resolveCallerEmail(userId);
|
|
3948
|
+
if (parts.length >= 3 && (c.req.method === "PATCH" || c.req.method === "DELETE" || isNodeRevert)) {
|
|
3949
|
+
const idParts = isNodeRevert ? parts.slice(2, -1) : parts.slice(2);
|
|
3950
|
+
const rawNodeId = idParts.join("/");
|
|
3951
|
+
let nodeId = rawNodeId;
|
|
3952
|
+
try {
|
|
3953
|
+
nodeId = decodeURIComponent(rawNodeId);
|
|
3954
|
+
} catch {
|
|
3955
|
+
}
|
|
3956
|
+
const resolved = resolveStewardsForNode(nestId, nodeId);
|
|
3957
|
+
stewardEditorBypass = resolved.some(
|
|
3958
|
+
(r) => r.steward.userEmail.toLowerCase() === userEmail.toLowerCase() && r.steward.role === "editor"
|
|
3959
|
+
);
|
|
3960
|
+
} else if (parts.length === 2 && c.req.method === "POST") {
|
|
3961
|
+
const resolved = resolveStewardsForNode(nestId, "");
|
|
3962
|
+
stewardEditorBypass = resolved.some(
|
|
3963
|
+
(r) => r.steward.userEmail.toLowerCase() === userEmail.toLowerCase() && r.steward.role === "editor" && r.steward.scope === "nest"
|
|
3964
|
+
);
|
|
3965
|
+
}
|
|
3966
|
+
}
|
|
3967
|
+
if (!stewardEditorBypass && permissionLevel(permission) < permissionLevel(required)) {
|
|
3868
3968
|
return c.json(
|
|
3869
3969
|
{
|
|
3870
3970
|
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 +4064,8 @@ function createApp() {
|
|
|
3964
4064
|
}
|
|
3965
4065
|
|
|
3966
4066
|
// src/db/backfill.ts
|
|
3967
|
-
import { NestStorage
|
|
3968
|
-
import { join as
|
|
4067
|
+
import { NestStorage } from "@promptowl/contextnest-engine";
|
|
4068
|
+
import { join as join4 } from "path";
|
|
3969
4069
|
var MIGRATION_ID = "005_backfill_node_versions_from_history";
|
|
3970
4070
|
async function backfillNodeVersionsFromHistory(db) {
|
|
3971
4071
|
const already = db.prepare("SELECT id FROM schema_migrations WHERE id = ?").get(MIGRATION_ID);
|
|
@@ -3986,8 +4086,8 @@ async function backfillNodeVersionsFromHistory(db) {
|
|
|
3986
4086
|
let totalInserted = 0;
|
|
3987
4087
|
let totalDocs = 0;
|
|
3988
4088
|
for (const { id: nestId } of nests) {
|
|
3989
|
-
const
|
|
3990
|
-
const storage = new
|
|
4089
|
+
const nestPath = join4(config.DATA_ROOT, "nests", nestId);
|
|
4090
|
+
const storage = new NestStorage(nestPath);
|
|
3991
4091
|
let docs;
|
|
3992
4092
|
try {
|
|
3993
4093
|
docs = await storage.discoverDocuments();
|
|
@@ -4085,7 +4185,6 @@ async function main() {
|
|
|
4085
4185
|
`);
|
|
4086
4186
|
}
|
|
4087
4187
|
const app = createApp();
|
|
4088
|
-
startLicenseWatcher();
|
|
4089
4188
|
startLicenseSafetyPoll();
|
|
4090
4189
|
const driftScanIntervalMs = Number(process.env.DRIFT_SCAN_INTERVAL_MS) || 3e4;
|
|
4091
4190
|
if (driftScanIntervalMs > 0) {
|