@promptowl/contextnest-community 1.5.0 → 1.7.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 +181 -123
- package/LICENSE.md +142 -142
- package/README.md +224 -211
- package/dist/adapter.postgres-YOODX2BI.js +71 -0
- package/dist/{chunk-EMOE53KX.js → chunk-43DOX4LH.js} +356 -267
- package/dist/{chunk-IWA2UDAT.js → chunk-MOXICJPD.js} +116 -90
- package/dist/{chunk-RMU3LOPH.js → chunk-QMLAXQES.js} +383 -69
- package/dist/chunk-SLTQACJW.js +8 -0
- package/dist/{chunk-HIH7I232.js → chunk-VD5QX2ZQ.js} +69 -53
- package/dist/index.js +1427 -976
- package/dist/migrations.postgres-3HMGLKOV.js +279 -0
- package/dist/{review-service-XNXHF6Q7.js → review-service-2FSKW425.js} +5 -4
- package/dist/{stewardship-service-P4M5UEJW.js → stewardship-service-I2JAFYJU.js} +3 -2
- package/dist/{version-service-REGL5CUT.js → version-service-FJWVTZKR.js} +3 -2
- package/dist/web3/assets/{hootie-C2ocYkn4.svg → hootie-_U3ECslt.svg} +8 -8
- package/dist/web3/assets/index-24dBhQSQ.css +1 -0
- package/dist/web3/assets/index-DpM8J4RN.js +889 -0
- package/dist/web3/index.html +14 -14
- package/package.json +152 -150
- package/dist/web3/assets/index-C2dfT3Et.css +0 -1
- package/dist/web3/assets/index-DEKnE-ty.js +0 -887
package/dist/index.js
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
reject,
|
|
17
17
|
safePublishDocument,
|
|
18
18
|
submitForReview
|
|
19
|
-
} from "./chunk-
|
|
19
|
+
} from "./chunk-MOXICJPD.js";
|
|
20
20
|
import {
|
|
21
21
|
checkConflict,
|
|
22
22
|
createVersion,
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
getDisplayStatus,
|
|
26
26
|
getVersions,
|
|
27
27
|
setApprovedVersion
|
|
28
|
-
} from "./chunk-
|
|
28
|
+
} from "./chunk-VD5QX2ZQ.js";
|
|
29
29
|
import {
|
|
30
30
|
AppError,
|
|
31
31
|
ConflictError,
|
|
@@ -35,10 +35,10 @@ import {
|
|
|
35
35
|
ValidationError,
|
|
36
36
|
canCreateInNest,
|
|
37
37
|
canManageStewards,
|
|
38
|
-
canManageWith,
|
|
39
38
|
canUserAccess,
|
|
40
39
|
canUserApprove,
|
|
41
40
|
canUserEdit,
|
|
41
|
+
collabPermToRole,
|
|
42
42
|
createNest,
|
|
43
43
|
createStewardRecord,
|
|
44
44
|
deleteNest,
|
|
@@ -65,9 +65,11 @@ import {
|
|
|
65
65
|
listStewards,
|
|
66
66
|
loadAccessConfig,
|
|
67
67
|
nestAllowsSelfApprove,
|
|
68
|
+
nestStorageRoot,
|
|
68
69
|
permissionLevel,
|
|
69
70
|
removeSteward,
|
|
70
71
|
renameNest,
|
|
72
|
+
resolveNestPath,
|
|
71
73
|
resolveNestPermission,
|
|
72
74
|
resolveStewardsForNode,
|
|
73
75
|
resolveStewardsWithFallback,
|
|
@@ -82,13 +84,18 @@ import {
|
|
|
82
84
|
updateSteward,
|
|
83
85
|
upsertEnvVar,
|
|
84
86
|
validateLicense
|
|
85
|
-
} from "./chunk-
|
|
87
|
+
} from "./chunk-43DOX4LH.js";
|
|
86
88
|
import {
|
|
87
|
-
ANON_EMAIL,
|
|
88
|
-
ANON_USER_ID,
|
|
89
89
|
config,
|
|
90
|
-
getDb
|
|
91
|
-
|
|
90
|
+
getDb,
|
|
91
|
+
initDb,
|
|
92
|
+
insertOrIgnore,
|
|
93
|
+
nowExpr
|
|
94
|
+
} from "./chunk-QMLAXQES.js";
|
|
95
|
+
import {
|
|
96
|
+
ANON_EMAIL,
|
|
97
|
+
ANON_USER_ID
|
|
98
|
+
} from "./chunk-SLTQACJW.js";
|
|
92
99
|
|
|
93
100
|
// src/index.ts
|
|
94
101
|
import { serve } from "@hono/node-server";
|
|
@@ -116,37 +123,40 @@ function newSessionId() {
|
|
|
116
123
|
function expiryIso(ttlSeconds = SESSION_TTL_SECS) {
|
|
117
124
|
return new Date(Date.now() + ttlSeconds * 1e3).toISOString();
|
|
118
125
|
}
|
|
119
|
-
function createSession(userId, userAgent) {
|
|
126
|
+
async function createSession(userId, userAgent) {
|
|
120
127
|
const db = getDb();
|
|
121
128
|
const id = newSessionId();
|
|
122
|
-
db.
|
|
129
|
+
await db.run(
|
|
123
130
|
`INSERT INTO sessions (id, user_id, expires_at, user_agent)
|
|
124
|
-
VALUES (?, ?, ?, ?)
|
|
125
|
-
|
|
131
|
+
VALUES (?, ?, ?, ?)`,
|
|
132
|
+
[id, userId, expiryIso(), userAgent || null]
|
|
133
|
+
);
|
|
126
134
|
return id;
|
|
127
135
|
}
|
|
128
|
-
function resolveSession(id) {
|
|
136
|
+
async function resolveSession(id) {
|
|
129
137
|
const db = getDb();
|
|
130
|
-
const row = db.
|
|
131
|
-
`SELECT user_id, expires_at FROM sessions WHERE id =
|
|
132
|
-
|
|
138
|
+
const row = await db.get(
|
|
139
|
+
`SELECT user_id, expires_at FROM sessions WHERE id = ?`,
|
|
140
|
+
[id]
|
|
141
|
+
);
|
|
133
142
|
if (!row) return null;
|
|
134
143
|
if (new Date(row.expires_at).getTime() <= Date.now()) {
|
|
135
|
-
db.
|
|
144
|
+
await db.run("DELETE FROM sessions WHERE id = ?", [id]);
|
|
136
145
|
return null;
|
|
137
146
|
}
|
|
138
|
-
db.
|
|
139
|
-
|
|
140
|
-
|
|
147
|
+
await db.run(
|
|
148
|
+
`UPDATE sessions SET last_seen_at = ${nowExpr(db)} WHERE id = ?`,
|
|
149
|
+
[id]
|
|
150
|
+
);
|
|
141
151
|
return row.user_id;
|
|
142
152
|
}
|
|
143
|
-
function deleteSession(id) {
|
|
153
|
+
async function deleteSession(id) {
|
|
144
154
|
const db = getDb();
|
|
145
|
-
db.
|
|
155
|
+
await db.run("DELETE FROM sessions WHERE id = ?", [id]);
|
|
146
156
|
}
|
|
147
|
-
function deleteAllSessionsForUser(userId) {
|
|
157
|
+
async function deleteAllSessionsForUser(userId) {
|
|
148
158
|
const db = getDb();
|
|
149
|
-
db.
|
|
159
|
+
await db.run("DELETE FROM sessions WHERE user_id = ?", [userId]);
|
|
150
160
|
}
|
|
151
161
|
function getSessionIdFromRequest(c) {
|
|
152
162
|
const cookieHeader = c.req.header("Cookie");
|
|
@@ -190,7 +200,7 @@ var authMiddleware = createMiddleware(async (c, next) => {
|
|
|
190
200
|
const db = getDb();
|
|
191
201
|
const sessionId = getSessionIdFromRequest(c);
|
|
192
202
|
if (sessionId) {
|
|
193
|
-
const userId = resolveSession(sessionId);
|
|
203
|
+
const userId = await resolveSession(sessionId);
|
|
194
204
|
if (userId) {
|
|
195
205
|
c.set("userId", userId);
|
|
196
206
|
c.set("nestScope", null);
|
|
@@ -200,11 +210,15 @@ var authMiddleware = createMiddleware(async (c, next) => {
|
|
|
200
210
|
const key = parseBearerToken(c.req.header("Authorization"));
|
|
201
211
|
if (key) {
|
|
202
212
|
const keyHash = hashApiKey(key);
|
|
203
|
-
const record = db.
|
|
213
|
+
const record = await db.get(
|
|
214
|
+
"SELECT user_id, nest_id FROM api_keys WHERE key_hash = ?",
|
|
215
|
+
[keyHash]
|
|
216
|
+
);
|
|
204
217
|
if (record) {
|
|
205
|
-
db.
|
|
206
|
-
|
|
207
|
-
|
|
218
|
+
await db.run(
|
|
219
|
+
`UPDATE api_keys SET last_used_at = ${nowExpr(db)} WHERE key_hash = ?`,
|
|
220
|
+
[keyHash]
|
|
221
|
+
);
|
|
208
222
|
c.set("userId", record.user_id);
|
|
209
223
|
c.set("nestScope", record.nest_id);
|
|
210
224
|
return next();
|
|
@@ -327,27 +341,30 @@ function clientIp(c) {
|
|
|
327
341
|
}
|
|
328
342
|
return "unknown";
|
|
329
343
|
}
|
|
330
|
-
function resolveCallerUserId(c) {
|
|
344
|
+
async function resolveCallerUserId(c) {
|
|
331
345
|
const sessionId = getSessionIdFromRequest(c);
|
|
332
346
|
if (sessionId) {
|
|
333
|
-
const uid = resolveSession(sessionId);
|
|
347
|
+
const uid = await resolveSession(sessionId);
|
|
334
348
|
if (uid) return uid;
|
|
335
349
|
}
|
|
336
350
|
const key = parseBearerToken(c.req.header("Authorization"));
|
|
337
351
|
if (key) {
|
|
338
352
|
const db = getDb();
|
|
339
|
-
const row = db.
|
|
353
|
+
const row = await db.get(
|
|
354
|
+
"SELECT user_id FROM api_keys WHERE key_hash = ?",
|
|
355
|
+
[hashApiKey(key)]
|
|
356
|
+
);
|
|
340
357
|
if (row) return row.user_id;
|
|
341
358
|
}
|
|
342
359
|
return null;
|
|
343
360
|
}
|
|
344
|
-
function deviceGateBlocked(c) {
|
|
361
|
+
async function deviceGateBlocked(c) {
|
|
345
362
|
const gate = config.PROMPTOWL_SIGN_IN_GATE;
|
|
346
363
|
if (gate === "open") return null;
|
|
347
364
|
const error = "PromptOwl sign-in is restricted on this server. Use email and password, or contact your admin.";
|
|
348
365
|
if (gate === "disabled") return { error, gate };
|
|
349
|
-
const callerId = resolveCallerUserId(c);
|
|
350
|
-
if (callerId && !isLicenseAdminUserId(callerId)) return { error, gate };
|
|
366
|
+
const callerId = await resolveCallerUserId(c);
|
|
367
|
+
if (callerId && !await isLicenseAdminUserId(callerId)) return { error, gate };
|
|
351
368
|
return null;
|
|
352
369
|
}
|
|
353
370
|
function setSessionCookie(c, sessionId) {
|
|
@@ -376,13 +393,17 @@ async function provisionPromptowlUser(c, rawEmail, rawName) {
|
|
|
376
393
|
}
|
|
377
394
|
const db = getDb();
|
|
378
395
|
const meEmail = normalizeEmail(rawEmail);
|
|
379
|
-
let user = db.
|
|
396
|
+
let user = await db.get(
|
|
397
|
+
"SELECT id, email, name FROM users WHERE LOWER(email) = ?",
|
|
398
|
+
[meEmail]
|
|
399
|
+
);
|
|
380
400
|
if (!user) {
|
|
381
401
|
const userId = uuid();
|
|
382
402
|
const placeholderHash = await hashPassword(uuid());
|
|
383
|
-
db.
|
|
384
|
-
"INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)"
|
|
385
|
-
|
|
403
|
+
await db.run(
|
|
404
|
+
"INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)",
|
|
405
|
+
[userId, meEmail, rawName || null, placeholderHash]
|
|
406
|
+
);
|
|
386
407
|
user = { id: userId, email: meEmail, name: rawName || null };
|
|
387
408
|
trackEvent("user.register", {
|
|
388
409
|
userId,
|
|
@@ -406,7 +427,7 @@ async function provisionPromptowlUser(c, rawEmail, rawName) {
|
|
|
406
427
|
license_owner_email: lic.ownerEmail
|
|
407
428
|
};
|
|
408
429
|
}
|
|
409
|
-
const sessionId = createSession(user.id, c.req.header("User-Agent"));
|
|
430
|
+
const sessionId = await createSession(user.id, c.req.header("User-Agent"));
|
|
410
431
|
setSessionCookie(c, sessionId);
|
|
411
432
|
return { ok: true, user, isAdmin, claimBlocked };
|
|
412
433
|
}
|
|
@@ -423,25 +444,30 @@ authRoutes.post("/register", async (c) => {
|
|
|
423
444
|
return c.json({ error: "Too many registration attempts, try again later" }, 429);
|
|
424
445
|
}
|
|
425
446
|
const db = getDb();
|
|
426
|
-
const existing = db.
|
|
447
|
+
const existing = await db.get(
|
|
448
|
+
"SELECT id, is_invited FROM users WHERE LOWER(email) = ?",
|
|
449
|
+
[email]
|
|
450
|
+
);
|
|
427
451
|
let userId;
|
|
428
452
|
const passwordHash = await hashPassword(body.password);
|
|
429
453
|
if (existing && existing.is_invited === 1) {
|
|
430
454
|
userId = existing.id;
|
|
431
|
-
db.
|
|
432
|
-
"UPDATE users SET password_hash = ?, name = COALESCE(?, name), is_invited = 0 WHERE id = ?"
|
|
433
|
-
|
|
455
|
+
await db.run(
|
|
456
|
+
"UPDATE users SET password_hash = ?, name = COALESCE(?, name), is_invited = 0 WHERE id = ?",
|
|
457
|
+
[passwordHash, body.name || null, userId]
|
|
458
|
+
);
|
|
434
459
|
trackEvent("user.register", { userId, email, claimed: true });
|
|
435
460
|
} else if (existing) {
|
|
436
461
|
throw new ValidationError("Email already registered");
|
|
437
462
|
} else {
|
|
438
463
|
userId = uuid();
|
|
439
|
-
db.
|
|
440
|
-
"INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)"
|
|
441
|
-
|
|
464
|
+
await db.run(
|
|
465
|
+
"INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)",
|
|
466
|
+
[userId, email, body.name || null, passwordHash]
|
|
467
|
+
);
|
|
442
468
|
trackEvent("user.register", { userId, email });
|
|
443
469
|
}
|
|
444
|
-
const sessionId = createSession(userId, c.req.header("User-Agent"));
|
|
470
|
+
const sessionId = await createSession(userId, c.req.header("User-Agent"));
|
|
445
471
|
setSessionCookie(c, sessionId);
|
|
446
472
|
return c.json(
|
|
447
473
|
{
|
|
@@ -461,7 +487,7 @@ authRoutes.post("/login", async (c) => {
|
|
|
461
487
|
throw new ValidationError("email and password are required");
|
|
462
488
|
}
|
|
463
489
|
const ip = clientIp(c);
|
|
464
|
-
const emailLower = body.email
|
|
490
|
+
const emailLower = normalizeEmail(body.email);
|
|
465
491
|
const hasIp = ip !== "unknown";
|
|
466
492
|
const ipKey = `login:ip:${ip}`;
|
|
467
493
|
const emailKey = `login:email:${emailLower}`;
|
|
@@ -469,9 +495,10 @@ authRoutes.post("/login", async (c) => {
|
|
|
469
495
|
return c.json({ error: "Too many login attempts, try again later" }, 429);
|
|
470
496
|
}
|
|
471
497
|
const db = getDb();
|
|
472
|
-
const user = db.
|
|
473
|
-
"SELECT id, email, name, password_hash, is_admin FROM users WHERE LOWER(email) = ?"
|
|
474
|
-
|
|
498
|
+
const user = await db.get(
|
|
499
|
+
"SELECT id, email, name, password_hash, is_admin, is_invited FROM users WHERE LOWER(email) = ?",
|
|
500
|
+
[emailLower]
|
|
501
|
+
);
|
|
475
502
|
const check = user ? await verifyPassword(body.password, user.password_hash) : { ok: false, needsRehash: false };
|
|
476
503
|
if (!user || !check.ok) {
|
|
477
504
|
if (hasIp) recordFailure(ipKey, LOGIN_LIMIT);
|
|
@@ -482,18 +509,24 @@ authRoutes.post("/login", async (c) => {
|
|
|
482
509
|
console.log(`[auth] login OK \u2014 counter reset ip=${ip} email=${emailLower}`);
|
|
483
510
|
if (hasIp) clear(ipKey);
|
|
484
511
|
clear(emailKey);
|
|
512
|
+
if (user.is_invited === 1) {
|
|
513
|
+
try {
|
|
514
|
+
await db.run("UPDATE users SET is_invited = 0 WHERE id = ?", [user.id]);
|
|
515
|
+
} catch {
|
|
516
|
+
}
|
|
517
|
+
}
|
|
485
518
|
if (check.needsRehash) {
|
|
486
519
|
try {
|
|
487
520
|
const newHash = await hashPassword(body.password);
|
|
488
|
-
db.
|
|
521
|
+
await db.run("UPDATE users SET password_hash = ? WHERE id = ?", [
|
|
489
522
|
newHash,
|
|
490
523
|
user.id
|
|
491
|
-
);
|
|
524
|
+
]);
|
|
492
525
|
} catch {
|
|
493
526
|
}
|
|
494
527
|
}
|
|
495
528
|
trackEvent("user.login", { userId: user.id });
|
|
496
|
-
const sessionId = createSession(user.id, c.req.header("User-Agent"));
|
|
529
|
+
const sessionId = await createSession(user.id, c.req.header("User-Agent"));
|
|
497
530
|
setSessionCookie(c, sessionId);
|
|
498
531
|
return c.json({
|
|
499
532
|
user: {
|
|
@@ -506,7 +539,7 @@ authRoutes.post("/login", async (c) => {
|
|
|
506
539
|
});
|
|
507
540
|
authRoutes.post("/logout", async (c) => {
|
|
508
541
|
const sessionId = getSessionIdFromRequest(c);
|
|
509
|
-
if (sessionId) deleteSession(sessionId);
|
|
542
|
+
if (sessionId) await deleteSession(sessionId);
|
|
510
543
|
clearSessionCookie(c);
|
|
511
544
|
return c.json({ ok: true });
|
|
512
545
|
});
|
|
@@ -516,7 +549,10 @@ authRoutes.post("/keys", authMiddleware, async (c) => {
|
|
|
516
549
|
);
|
|
517
550
|
const db = getDb();
|
|
518
551
|
const userId = c.get("userId");
|
|
519
|
-
const existing = db.
|
|
552
|
+
const existing = await db.get(
|
|
553
|
+
"SELECT key_prefix FROM api_keys WHERE user_id = ?",
|
|
554
|
+
[userId]
|
|
555
|
+
);
|
|
520
556
|
if (existing) {
|
|
521
557
|
return c.json(
|
|
522
558
|
{
|
|
@@ -528,15 +564,16 @@ authRoutes.post("/keys", authMiddleware, async (c) => {
|
|
|
528
564
|
}
|
|
529
565
|
const apiKey = generateApiKey();
|
|
530
566
|
const keyId = uuid();
|
|
531
|
-
db.
|
|
532
|
-
"INSERT INTO api_keys (id, user_id, key_hash, key_prefix, nest_id, label) VALUES (?, ?, ?, ?, ?, ?)"
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
567
|
+
await db.run(
|
|
568
|
+
"INSERT INTO api_keys (id, user_id, key_hash, key_prefix, nest_id, label) VALUES (?, ?, ?, ?, ?, ?)",
|
|
569
|
+
[
|
|
570
|
+
keyId,
|
|
571
|
+
userId,
|
|
572
|
+
hashApiKey(apiKey),
|
|
573
|
+
getKeyPrefix(apiKey),
|
|
574
|
+
body.nest_id || null,
|
|
575
|
+
body.label || null
|
|
576
|
+
]
|
|
540
577
|
);
|
|
541
578
|
return c.json(
|
|
542
579
|
{ api_key: apiKey, key_prefix: getKeyPrefix(apiKey) },
|
|
@@ -551,39 +588,47 @@ authRoutes.post("/keys/rotate", authMiddleware, async (c) => {
|
|
|
551
588
|
const userId = c.get("userId");
|
|
552
589
|
const apiKey = generateApiKey();
|
|
553
590
|
const keyId = uuid();
|
|
554
|
-
const prior = db.
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
591
|
+
const prior = await db.get(
|
|
592
|
+
"SELECT label, nest_id FROM api_keys WHERE user_id = ?",
|
|
593
|
+
[userId]
|
|
594
|
+
);
|
|
595
|
+
await db.transaction(async (tx) => {
|
|
596
|
+
await tx.run("DELETE FROM api_keys WHERE user_id = ?", [userId]);
|
|
597
|
+
await tx.run(
|
|
598
|
+
"INSERT INTO api_keys (id, user_id, key_hash, key_prefix, nest_id, label) VALUES (?, ?, ?, ?, ?, ?)",
|
|
599
|
+
[
|
|
600
|
+
keyId,
|
|
601
|
+
userId,
|
|
602
|
+
hashApiKey(apiKey),
|
|
603
|
+
getKeyPrefix(apiKey),
|
|
604
|
+
body.nest_id ?? prior?.nest_id ?? null,
|
|
605
|
+
body.label ?? prior?.label ?? null
|
|
606
|
+
]
|
|
566
607
|
);
|
|
567
|
-
})
|
|
608
|
+
});
|
|
568
609
|
return c.json({ api_key: apiKey, key_prefix: getKeyPrefix(apiKey) });
|
|
569
610
|
});
|
|
570
611
|
authRoutes.get("/keys", authMiddleware, async (c) => {
|
|
571
612
|
const db = getDb();
|
|
572
|
-
const keys = db.
|
|
573
|
-
"SELECT id, key_prefix, nest_id, label, created_at, last_used_at FROM api_keys WHERE user_id = ?"
|
|
574
|
-
|
|
613
|
+
const keys = await db.all(
|
|
614
|
+
"SELECT id, key_prefix, nest_id, label, created_at, last_used_at FROM api_keys WHERE user_id = ?",
|
|
615
|
+
[c.get("userId")]
|
|
616
|
+
);
|
|
575
617
|
return c.json({ keys });
|
|
576
618
|
});
|
|
577
619
|
authRoutes.delete("/keys/:keyId", authMiddleware, async (c) => {
|
|
578
620
|
const db = getDb();
|
|
579
|
-
const result = db.
|
|
621
|
+
const result = await db.run(
|
|
622
|
+
"DELETE FROM api_keys WHERE id = ? AND user_id = ?",
|
|
623
|
+
[c.req.param("keyId"), c.get("userId")]
|
|
624
|
+
);
|
|
580
625
|
if (result.changes === 0) {
|
|
581
626
|
return c.json({ error: "Key not found" }, 404);
|
|
582
627
|
}
|
|
583
628
|
return c.json({ deleted: true });
|
|
584
629
|
});
|
|
585
630
|
authRoutes.post("/device", async (c) => {
|
|
586
|
-
const blocked = deviceGateBlocked(c);
|
|
631
|
+
const blocked = await deviceGateBlocked(c);
|
|
587
632
|
if (blocked) return c.json(blocked, 403);
|
|
588
633
|
if (!tryConsume(`device:ip:${clientIp(c)}`, DEVICE_LIMIT)) {
|
|
589
634
|
return c.json({ error: "Too many device auth attempts, try again later" }, 429);
|
|
@@ -606,7 +651,7 @@ authRoutes.post("/device", async (c) => {
|
|
|
606
651
|
return c.json(data);
|
|
607
652
|
});
|
|
608
653
|
authRoutes.get("/device/poll", async (c) => {
|
|
609
|
-
const blocked = deviceGateBlocked(c);
|
|
654
|
+
const blocked = await deviceGateBlocked(c);
|
|
610
655
|
if (blocked) return c.json(blocked, 403);
|
|
611
656
|
const code = c.req.query("code");
|
|
612
657
|
const clientSecret = c.req.query("client_secret");
|
|
@@ -689,12 +734,13 @@ authRoutes.get("/sso", async (c) => {
|
|
|
689
734
|
}
|
|
690
735
|
if (!claims.jti) return ssoError("invalid_ticket");
|
|
691
736
|
const db = getDb();
|
|
692
|
-
db.
|
|
737
|
+
await db.run(`DELETE FROM sso_used_jti WHERE expires_at < ${nowExpr(db)}`);
|
|
693
738
|
const expiresAtIso = new Date((claims.exp ?? 0) * 1e3).toISOString();
|
|
694
739
|
try {
|
|
695
|
-
db.
|
|
696
|
-
"INSERT INTO sso_used_jti (jti, expires_at) VALUES (?, ?)"
|
|
697
|
-
|
|
740
|
+
await db.run(
|
|
741
|
+
"INSERT INTO sso_used_jti (jti, expires_at) VALUES (?, ?)",
|
|
742
|
+
[claims.jti, expiresAtIso]
|
|
743
|
+
);
|
|
698
744
|
} catch (err) {
|
|
699
745
|
if (err?.code === "SQLITE_CONSTRAINT_PRIMARYKEY") {
|
|
700
746
|
return ssoError("ticket_used");
|
|
@@ -706,7 +752,7 @@ authRoutes.get("/sso", async (c) => {
|
|
|
706
752
|
try {
|
|
707
753
|
result = await provisionPromptowlUser(c, sub, claims.name);
|
|
708
754
|
} catch (err) {
|
|
709
|
-
db.
|
|
755
|
+
await db.run("DELETE FROM sso_used_jti WHERE jti = ?", [claims.jti]);
|
|
710
756
|
console.error("[sso] provisioning failed; released jti for retry:", err);
|
|
711
757
|
return ssoError("service_error");
|
|
712
758
|
}
|
|
@@ -719,13 +765,19 @@ authRoutes.get("/admin-status", async (c) => {
|
|
|
719
765
|
const ownerEmail = lic?.valid ? lic.ownerEmail : null;
|
|
720
766
|
let admin = null;
|
|
721
767
|
if (ownerEmail) {
|
|
722
|
-
const ownerRow = db.
|
|
768
|
+
const ownerRow = await db.get(
|
|
769
|
+
"SELECT name FROM users WHERE LOWER(email) = LOWER(?) LIMIT 1",
|
|
770
|
+
[ownerEmail]
|
|
771
|
+
);
|
|
723
772
|
admin = { email: ownerEmail, name: ownerRow?.name ?? null };
|
|
724
773
|
}
|
|
725
|
-
const callerId = resolveCallerUserId(c);
|
|
774
|
+
const callerId = await resolveCallerUserId(c);
|
|
726
775
|
let me = null;
|
|
727
776
|
if (callerId) {
|
|
728
|
-
const row = db.
|
|
777
|
+
const row = await db.get(
|
|
778
|
+
"SELECT email, name FROM users WHERE id = ?",
|
|
779
|
+
[callerId]
|
|
780
|
+
);
|
|
729
781
|
if (row) {
|
|
730
782
|
me = {
|
|
731
783
|
email: row.email,
|
|
@@ -748,7 +800,10 @@ authRoutes.post("/password", authMiddleware, async (c) => {
|
|
|
748
800
|
assertValidPassword(body.next, "new password");
|
|
749
801
|
const db = getDb();
|
|
750
802
|
const userId = c.get("userId");
|
|
751
|
-
const user = db.
|
|
803
|
+
const user = await db.get(
|
|
804
|
+
"SELECT password_hash FROM users WHERE id = ?",
|
|
805
|
+
[userId]
|
|
806
|
+
);
|
|
752
807
|
if (!user) throw new ValidationError("User not found");
|
|
753
808
|
const check = await verifyPassword(body.current, user.password_hash);
|
|
754
809
|
if (!check.ok) {
|
|
@@ -761,20 +816,20 @@ authRoutes.post("/password", authMiddleware, async (c) => {
|
|
|
761
816
|
);
|
|
762
817
|
}
|
|
763
818
|
const newHash = await hashPassword(body.next);
|
|
764
|
-
db.
|
|
819
|
+
await db.run("UPDATE users SET password_hash = ?, is_invited = 0 WHERE id = ?", [
|
|
765
820
|
newHash,
|
|
766
821
|
userId
|
|
767
|
-
);
|
|
768
|
-
deleteAllSessionsForUser(userId);
|
|
822
|
+
]);
|
|
823
|
+
await deleteAllSessionsForUser(userId);
|
|
769
824
|
clearSessionCookie(c);
|
|
770
825
|
return c.json({ ok: true });
|
|
771
826
|
});
|
|
772
827
|
authRoutes.post("/admin/reset-password/:userId", async (c) => {
|
|
773
|
-
const callerId = resolveCallerUserId(c);
|
|
828
|
+
const callerId = await resolveCallerUserId(c);
|
|
774
829
|
if (!callerId) {
|
|
775
830
|
return c.json({ error: "Authentication required." }, 401);
|
|
776
831
|
}
|
|
777
|
-
if (!isLicenseAdminUserId(callerId)) {
|
|
832
|
+
if (!await isLicenseAdminUserId(callerId)) {
|
|
778
833
|
return c.json(
|
|
779
834
|
{ error: "Only the license-admin user can reset passwords." },
|
|
780
835
|
403
|
|
@@ -782,7 +837,10 @@ authRoutes.post("/admin/reset-password/:userId", async (c) => {
|
|
|
782
837
|
}
|
|
783
838
|
const targetId = c.req.param("userId");
|
|
784
839
|
const db = getDb();
|
|
785
|
-
const target = db.
|
|
840
|
+
const target = await db.get(
|
|
841
|
+
"SELECT id, email FROM users WHERE id = ?",
|
|
842
|
+
[targetId]
|
|
843
|
+
);
|
|
786
844
|
if (!target) return c.json({ error: "User not found" }, 404);
|
|
787
845
|
let supplied;
|
|
788
846
|
try {
|
|
@@ -796,29 +854,28 @@ authRoutes.post("/admin/reset-password/:userId", async (c) => {
|
|
|
796
854
|
const generated = supplied ? null : uuid().replace(/-/g, "").slice(0, 16);
|
|
797
855
|
const newPassword = supplied ?? generated;
|
|
798
856
|
const newHash = await hashPassword(newPassword);
|
|
799
|
-
db.transaction(() => {
|
|
800
|
-
|
|
857
|
+
await db.transaction(async (tx) => {
|
|
858
|
+
await tx.run("UPDATE users SET password_hash = ? WHERE id = ?", [
|
|
801
859
|
newHash,
|
|
802
860
|
target.id
|
|
803
|
-
);
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
deleteAllSessionsForUser(target.id);
|
|
861
|
+
]);
|
|
862
|
+
});
|
|
863
|
+
await deleteAllSessionsForUser(target.id);
|
|
807
864
|
trackEvent("admin.reset_password", { adminId: callerId, userId: target.id });
|
|
808
865
|
return c.json({
|
|
809
866
|
ok: true,
|
|
810
867
|
email: target.email,
|
|
811
|
-
keys_revoked:
|
|
868
|
+
keys_revoked: false,
|
|
812
869
|
// Plaintext returned ONCE, only when the server generated it.
|
|
813
870
|
temporary_password: generated ?? void 0
|
|
814
871
|
});
|
|
815
872
|
});
|
|
816
873
|
authRoutes.delete("/users/:userId", async (c) => {
|
|
817
|
-
const callerId = resolveCallerUserId(c);
|
|
874
|
+
const callerId = await resolveCallerUserId(c);
|
|
818
875
|
if (!callerId) {
|
|
819
876
|
return c.json({ error: "Authentication required." }, 401);
|
|
820
877
|
}
|
|
821
|
-
if (!isLicenseAdminUserId(callerId)) {
|
|
878
|
+
if (!await isLicenseAdminUserId(callerId)) {
|
|
822
879
|
return c.json(
|
|
823
880
|
{ error: "Only the license-admin user can remove users." },
|
|
824
881
|
403
|
|
@@ -829,9 +886,15 @@ authRoutes.delete("/users/:userId", async (c) => {
|
|
|
829
886
|
return c.json({ error: "You can't remove your own admin account." }, 400);
|
|
830
887
|
}
|
|
831
888
|
const db = getDb();
|
|
832
|
-
const target = db.
|
|
889
|
+
const target = await db.get(
|
|
890
|
+
"SELECT id, email FROM users WHERE id = ?",
|
|
891
|
+
[targetId]
|
|
892
|
+
);
|
|
833
893
|
if (!target) return c.json({ error: "User not found" }, 404);
|
|
834
|
-
const ownedNests =
|
|
894
|
+
const ownedNests = (await db.get(
|
|
895
|
+
"SELECT COUNT(*) AS c FROM nests WHERE user_id = ?",
|
|
896
|
+
[target.id]
|
|
897
|
+
)).c;
|
|
835
898
|
if (ownedNests > 0) {
|
|
836
899
|
return c.json(
|
|
837
900
|
{
|
|
@@ -841,17 +904,18 @@ authRoutes.delete("/users/:userId", async (c) => {
|
|
|
841
904
|
409
|
|
842
905
|
);
|
|
843
906
|
}
|
|
844
|
-
db.transaction(() => {
|
|
845
|
-
|
|
846
|
-
"DELETE FROM stewards WHERE user_id = ? OR lower(user_email) = lower(?)"
|
|
847
|
-
|
|
848
|
-
db.prepare("DELETE FROM nest_collaborators WHERE user_id = ?").run(
|
|
849
|
-
target.id
|
|
907
|
+
await db.transaction(async (tx) => {
|
|
908
|
+
await tx.run(
|
|
909
|
+
"DELETE FROM stewards WHERE user_id = ? OR lower(user_email) = lower(?)",
|
|
910
|
+
[target.id, target.email]
|
|
850
911
|
);
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
912
|
+
await tx.run("DELETE FROM nest_collaborators WHERE user_id = ?", [
|
|
913
|
+
target.id
|
|
914
|
+
]);
|
|
915
|
+
await tx.run("DELETE FROM api_keys WHERE user_id = ?", [target.id]);
|
|
916
|
+
await tx.run("DELETE FROM users WHERE id = ?", [target.id]);
|
|
917
|
+
});
|
|
918
|
+
await deleteAllSessionsForUser(target.id);
|
|
855
919
|
trackEvent("admin.remove_user", { adminId: callerId, userId: target.id });
|
|
856
920
|
return c.json({ ok: true, email: target.email });
|
|
857
921
|
});
|
|
@@ -859,7 +923,7 @@ authRoutes.post("/invite", async (c) => {
|
|
|
859
923
|
const body = await c.req.json();
|
|
860
924
|
if (!body.email) throw new ValidationError("email is required");
|
|
861
925
|
const email = normalizeEmail(body.email);
|
|
862
|
-
const callerId = resolveCallerUserId(c);
|
|
926
|
+
const callerId = await resolveCallerUserId(c);
|
|
863
927
|
if (!callerId) {
|
|
864
928
|
return c.json(
|
|
865
929
|
{
|
|
@@ -869,7 +933,7 @@ authRoutes.post("/invite", async (c) => {
|
|
|
869
933
|
);
|
|
870
934
|
}
|
|
871
935
|
const db = getDb();
|
|
872
|
-
if (!isLicenseAdminUserId(callerId)) {
|
|
936
|
+
if (!await isLicenseAdminUserId(callerId)) {
|
|
873
937
|
return c.json(
|
|
874
938
|
{
|
|
875
939
|
error: "Only the license-admin user can invite teammates. Contact the admin who installed the PromptOwl license on this server to issue invitations."
|
|
@@ -877,41 +941,46 @@ authRoutes.post("/invite", async (c) => {
|
|
|
877
941
|
403
|
|
878
942
|
);
|
|
879
943
|
}
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
944
|
+
const existing = await db.get(
|
|
945
|
+
"SELECT id, email, is_invited FROM users WHERE LOWER(email) = ?",
|
|
946
|
+
[email]
|
|
947
|
+
);
|
|
948
|
+
if (existing && existing.is_invited === 0) {
|
|
949
|
+
return c.json(
|
|
950
|
+
{
|
|
951
|
+
error: "A user with this email already exists. Use Reset password to issue them a new password."
|
|
952
|
+
},
|
|
953
|
+
409
|
|
954
|
+
);
|
|
888
955
|
}
|
|
889
|
-
const
|
|
890
|
-
const
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
956
|
+
const tempPassword = uuid().replace(/-/g, "").slice(0, 16);
|
|
957
|
+
const passwordHash = await hashPassword(tempPassword);
|
|
958
|
+
let userId;
|
|
959
|
+
if (existing) {
|
|
960
|
+
userId = existing.id;
|
|
961
|
+
await db.run("UPDATE users SET password_hash = ? WHERE id = ?", [
|
|
962
|
+
passwordHash,
|
|
963
|
+
userId
|
|
964
|
+
]);
|
|
965
|
+
} else {
|
|
966
|
+
userId = uuid();
|
|
967
|
+
await db.run(
|
|
968
|
+
"INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
|
|
969
|
+
[userId, email, null, passwordHash]
|
|
901
970
|
);
|
|
902
|
-
}
|
|
903
|
-
trackEvent("admin.invite", { adminId: callerId, email
|
|
971
|
+
}
|
|
972
|
+
trackEvent("admin.invite", { adminId: callerId, email });
|
|
904
973
|
return c.json(
|
|
905
974
|
{
|
|
906
|
-
|
|
907
|
-
user: { id:
|
|
908
|
-
message: "
|
|
975
|
+
temporary_password: tempPassword,
|
|
976
|
+
user: { id: userId, email },
|
|
977
|
+
message: "Share this temporary password securely. Your teammate signs in with their email + this password, then can change it and create their own API key."
|
|
909
978
|
},
|
|
910
979
|
201
|
|
911
980
|
);
|
|
912
981
|
});
|
|
913
982
|
authRoutes.get("/teammates", async (c) => {
|
|
914
|
-
const callerId = resolveCallerUserId(c);
|
|
983
|
+
const callerId = await resolveCallerUserId(c);
|
|
915
984
|
if (!callerId) {
|
|
916
985
|
return c.json(
|
|
917
986
|
{
|
|
@@ -921,7 +990,7 @@ authRoutes.get("/teammates", async (c) => {
|
|
|
921
990
|
);
|
|
922
991
|
}
|
|
923
992
|
const db = getDb();
|
|
924
|
-
if (!isLicenseAdminUserId(callerId)) {
|
|
993
|
+
if (!await isLicenseAdminUserId(callerId)) {
|
|
925
994
|
return c.json(
|
|
926
995
|
{
|
|
927
996
|
error: "Only the license-admin user can view the teammates list. Contact the admin who installed the PromptOwl license on this server."
|
|
@@ -929,15 +998,16 @@ authRoutes.get("/teammates", async (c) => {
|
|
|
929
998
|
403
|
|
930
999
|
);
|
|
931
1000
|
}
|
|
932
|
-
const teammates = db.
|
|
1001
|
+
const teammates = await db.all(
|
|
933
1002
|
`SELECT u.id, u.email, u.name, u.is_invited,
|
|
934
1003
|
(SELECT COUNT(*) FROM api_keys WHERE user_id = u.id) as key_count,
|
|
935
1004
|
(SELECT MAX(last_used_at) FROM api_keys WHERE user_id = u.id) as last_active
|
|
936
1005
|
FROM users u
|
|
937
1006
|
WHERE u.id != ?
|
|
938
|
-
ORDER BY u.created_at DESC
|
|
939
|
-
|
|
940
|
-
|
|
1007
|
+
ORDER BY u.created_at DESC`,
|
|
1008
|
+
[ANON_USER_ID]
|
|
1009
|
+
);
|
|
1010
|
+
const pendingStewards = await db.all(
|
|
941
1011
|
`SELECT DISTINCT s.user_email AS email
|
|
942
1012
|
FROM stewards s
|
|
943
1013
|
WHERE s.is_active = 1
|
|
@@ -952,7 +1022,7 @@ authRoutes.get("/teammates", async (c) => {
|
|
|
952
1022
|
)
|
|
953
1023
|
)
|
|
954
1024
|
ORDER BY s.user_email`
|
|
955
|
-
)
|
|
1025
|
+
);
|
|
956
1026
|
const enriched = teammates.map((t) => ({ ...t, is_admin: isLicenseAdminEmail(t.email) })).sort((a, b) => Number(b.is_admin) - Number(a.is_admin));
|
|
957
1027
|
return c.json({
|
|
958
1028
|
teammates: enriched,
|
|
@@ -970,52 +1040,70 @@ import { serializeDocument, parseDocument as parseDocument2 } from "@promptowl/c
|
|
|
970
1040
|
function normalizeTag(raw) {
|
|
971
1041
|
return raw.trim().replace(/^#+/, "").toLowerCase();
|
|
972
1042
|
}
|
|
973
|
-
function syncNodeTags(nestId, nodeId, tags) {
|
|
1043
|
+
async function syncNodeTags(nestId, nodeId, tags) {
|
|
974
1044
|
const db = getDb();
|
|
975
1045
|
const normalized = Array.from(
|
|
976
1046
|
new Set(
|
|
977
1047
|
tags.filter((t) => typeof t === "string").map(normalizeTag).filter(Boolean)
|
|
978
1048
|
)
|
|
979
1049
|
);
|
|
980
|
-
|
|
981
|
-
db
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
1050
|
+
const insertSql = insertOrIgnore(
|
|
1051
|
+
db,
|
|
1052
|
+
"INSERT INTO node_tag_index (nest_id, node_id, tag_name) VALUES (?, ?, ?)"
|
|
1053
|
+
);
|
|
1054
|
+
await db.transaction(async (tx) => {
|
|
1055
|
+
await tx.run(
|
|
1056
|
+
"DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?",
|
|
1057
|
+
[nestId, nodeId]
|
|
986
1058
|
);
|
|
987
1059
|
for (const tag of normalized) {
|
|
988
|
-
|
|
1060
|
+
await tx.run(insertSql, [nestId, nodeId, tag]);
|
|
989
1061
|
}
|
|
990
|
-
})
|
|
1062
|
+
});
|
|
991
1063
|
}
|
|
992
|
-
function removeNodeFromTagIndex(nestId, nodeId) {
|
|
1064
|
+
async function removeNodeFromTagIndex(nestId, nodeId) {
|
|
993
1065
|
const db = getDb();
|
|
994
|
-
db.
|
|
995
|
-
"DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?"
|
|
996
|
-
|
|
1066
|
+
await db.run(
|
|
1067
|
+
"DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?",
|
|
1068
|
+
[nestId, nodeId]
|
|
1069
|
+
);
|
|
997
1070
|
}
|
|
998
1071
|
|
|
999
1072
|
// src/governance/access-guard.ts
|
|
1000
|
-
function resolveCallerEmail(userId) {
|
|
1073
|
+
async function resolveCallerEmail(userId) {
|
|
1001
1074
|
if (!userId) return "admin@localhost";
|
|
1002
1075
|
const db = getDb();
|
|
1003
|
-
const row = db.
|
|
1076
|
+
const row = await db.get(
|
|
1077
|
+
"SELECT email FROM users WHERE id = ?",
|
|
1078
|
+
[userId]
|
|
1079
|
+
);
|
|
1004
1080
|
return row?.email || "admin@localhost";
|
|
1005
1081
|
}
|
|
1006
|
-
function canReadNode(nestId, nodeId, userId, userEmail) {
|
|
1007
|
-
if (isPublicReader(nestId, userId)) {
|
|
1008
|
-
return getApprovedVersion(nestId, nodeId) !== null;
|
|
1082
|
+
async function canReadNode(nestId, nodeId, userId, userEmail) {
|
|
1083
|
+
if (await isPublicReader(nestId, userId)) {
|
|
1084
|
+
return await getApprovedVersion(nestId, nodeId) !== null;
|
|
1009
1085
|
}
|
|
1010
|
-
if (!isStewardshipEnabled(nestId)) return true;
|
|
1011
|
-
return canUserAccess(nestId, nodeId, userEmail).allowed;
|
|
1086
|
+
if (!await isStewardshipEnabled(nestId)) return true;
|
|
1087
|
+
return (await canUserAccess(nestId, nodeId, userEmail)).allowed;
|
|
1012
1088
|
}
|
|
1013
|
-
function filterAccessible(nestId, userId, userEmail, nodes) {
|
|
1014
|
-
if (isPublicReader(nestId, userId)) {
|
|
1015
|
-
|
|
1089
|
+
async function filterAccessible(nestId, userId, userEmail, nodes) {
|
|
1090
|
+
if (await isPublicReader(nestId, userId)) {
|
|
1091
|
+
const filtered = [];
|
|
1092
|
+
for (const n of nodes) {
|
|
1093
|
+
if (await getApprovedVersion(nestId, n.id) !== null) {
|
|
1094
|
+
filtered.push(n);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
return filtered;
|
|
1016
1098
|
}
|
|
1017
|
-
if (!isStewardshipEnabled(nestId)) return nodes;
|
|
1018
|
-
|
|
1099
|
+
if (!await isStewardshipEnabled(nestId)) return nodes;
|
|
1100
|
+
const accessible = [];
|
|
1101
|
+
for (const n of nodes) {
|
|
1102
|
+
if ((await canUserAccess(nestId, n.id, userEmail)).allowed) {
|
|
1103
|
+
accessible.push(n);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
return accessible;
|
|
1019
1107
|
}
|
|
1020
1108
|
|
|
1021
1109
|
// src/governance/external-edit-service.ts
|
|
@@ -1039,7 +1127,7 @@ var communityRbac = {
|
|
|
1039
1127
|
isDocOwner: () => true
|
|
1040
1128
|
};
|
|
1041
1129
|
function docPath(nestId, documentId) {
|
|
1042
|
-
return join(
|
|
1130
|
+
return join(resolveNestPath(nestId), `${documentId}.md`);
|
|
1043
1131
|
}
|
|
1044
1132
|
async function readRaw(nestId, documentId) {
|
|
1045
1133
|
try {
|
|
@@ -1063,7 +1151,7 @@ async function loadChainHead(storage, documentId) {
|
|
|
1063
1151
|
}
|
|
1064
1152
|
}
|
|
1065
1153
|
async function loadLatestApprovedNode(nestId, documentId) {
|
|
1066
|
-
const { storage } = engineCache.get(nestId);
|
|
1154
|
+
const { storage } = await engineCache.get(nestId);
|
|
1067
1155
|
const head = await loadChainHead(storage, documentId);
|
|
1068
1156
|
if (!head) return null;
|
|
1069
1157
|
return parseDocument(docPath(nestId, documentId), head.content, documentId);
|
|
@@ -1079,7 +1167,7 @@ async function scanDocumentForDrift(nestId, documentId, actor = "system:scanner"
|
|
|
1079
1167
|
return res?.meta ?? null;
|
|
1080
1168
|
}
|
|
1081
1169
|
async function scanDocumentForDriftInternal(nestId, documentId, actor) {
|
|
1082
|
-
const { storage } = engineCache.get(nestId);
|
|
1170
|
+
const { storage } = await engineCache.get(nestId);
|
|
1083
1171
|
const node = await storage.readDocument(documentId).catch(() => null);
|
|
1084
1172
|
if (!node) return null;
|
|
1085
1173
|
const raw = await readRaw(nestId, documentId);
|
|
@@ -1106,7 +1194,7 @@ async function scanDocumentForDriftInternal(nestId, documentId, actor) {
|
|
|
1106
1194
|
return { meta: result.meta, created: true };
|
|
1107
1195
|
}
|
|
1108
1196
|
async function scanNestForDrift(nestId, actor = "system:scanner") {
|
|
1109
|
-
const { storage } = engineCache.get(nestId);
|
|
1197
|
+
const { storage } = await engineCache.get(nestId);
|
|
1110
1198
|
const docs = await storage.discoverDocuments();
|
|
1111
1199
|
const results = await Promise.all(
|
|
1112
1200
|
docs.map((doc) => scanDocumentForDriftInternal(nestId, doc.id, actor))
|
|
@@ -1115,7 +1203,7 @@ async function scanNestForDrift(nestId, actor = "system:scanner") {
|
|
|
1115
1203
|
return { scanned: docs.length, staged };
|
|
1116
1204
|
}
|
|
1117
1205
|
async function getPendingChange(nestId, documentId) {
|
|
1118
|
-
const { storage } = engineCache.get(nestId);
|
|
1206
|
+
const { storage } = await engineCache.get(nestId);
|
|
1119
1207
|
const list = await listSuggestions(storage, documentId);
|
|
1120
1208
|
for (let i = list.length - 1; i >= 0; i--) {
|
|
1121
1209
|
const meta = list[i];
|
|
@@ -1132,7 +1220,7 @@ async function getPendingChange(nestId, documentId) {
|
|
|
1132
1220
|
return null;
|
|
1133
1221
|
}
|
|
1134
1222
|
async function listNestExternalEdits(nestId) {
|
|
1135
|
-
const { storage } = engineCache.get(nestId);
|
|
1223
|
+
const { storage } = await engineCache.get(nestId);
|
|
1136
1224
|
const docs = await storage.discoverDocuments();
|
|
1137
1225
|
const lists = await Promise.all(
|
|
1138
1226
|
docs.map(async (doc) => {
|
|
@@ -1161,7 +1249,7 @@ async function listNestExternalEdits(nestId) {
|
|
|
1161
1249
|
return entries.sort((a, b) => b.detected_at.localeCompare(a.detected_at));
|
|
1162
1250
|
}
|
|
1163
1251
|
async function getExternalEditDetail(nestId, documentId, suggestionId) {
|
|
1164
|
-
const { storage } = engineCache.get(nestId);
|
|
1252
|
+
const { storage } = await engineCache.get(nestId);
|
|
1165
1253
|
const found = await readSuggestion(storage, documentId, suggestionId);
|
|
1166
1254
|
if (!found) return null;
|
|
1167
1255
|
return {
|
|
@@ -1178,7 +1266,7 @@ async function getExternalEditDetail(nestId, documentId, suggestionId) {
|
|
|
1178
1266
|
};
|
|
1179
1267
|
}
|
|
1180
1268
|
async function approveExternalEdit(input) {
|
|
1181
|
-
const { storage } = engineCache.get(input.nestId);
|
|
1269
|
+
const { storage } = await engineCache.get(input.nestId);
|
|
1182
1270
|
let result;
|
|
1183
1271
|
try {
|
|
1184
1272
|
result = await approveSuggestion({
|
|
@@ -1201,8 +1289,8 @@ async function approveExternalEdit(input) {
|
|
|
1201
1289
|
const node = await storage.readDocument(input.documentId);
|
|
1202
1290
|
const versionNum = result.versionEntry.version;
|
|
1203
1291
|
const tags = node.frontmatter.tags || [];
|
|
1204
|
-
const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-
|
|
1205
|
-
createVersion2({
|
|
1292
|
+
const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-FJWVTZKR.js");
|
|
1293
|
+
await createVersion2({
|
|
1206
1294
|
nestId: input.nestId,
|
|
1207
1295
|
nodeId: input.documentId,
|
|
1208
1296
|
version: versionNum,
|
|
@@ -1212,7 +1300,12 @@ async function approveExternalEdit(input) {
|
|
|
1212
1300
|
tags,
|
|
1213
1301
|
changeNote: input.comment || "External edit approved"
|
|
1214
1302
|
});
|
|
1215
|
-
setApprovedVersion2(
|
|
1303
|
+
await setApprovedVersion2(
|
|
1304
|
+
input.nestId,
|
|
1305
|
+
input.documentId,
|
|
1306
|
+
versionNum,
|
|
1307
|
+
input.actor
|
|
1308
|
+
);
|
|
1216
1309
|
} catch (err) {
|
|
1217
1310
|
console.error(
|
|
1218
1311
|
`[external-edit] failed to mirror approved version into node_versions for ${input.nestId}/${input.documentId}:`,
|
|
@@ -1222,7 +1315,7 @@ async function approveExternalEdit(input) {
|
|
|
1222
1315
|
return result;
|
|
1223
1316
|
}
|
|
1224
1317
|
async function rejectExternalEdit(input) {
|
|
1225
|
-
const { storage } = engineCache.get(input.nestId);
|
|
1318
|
+
const { storage } = await engineCache.get(input.nestId);
|
|
1226
1319
|
const result = await rejectSuggestion({
|
|
1227
1320
|
storage,
|
|
1228
1321
|
rbac: communityRbac,
|
|
@@ -1248,7 +1341,7 @@ async function rejectExternalEdit(input) {
|
|
|
1248
1341
|
var scannerTimer = null;
|
|
1249
1342
|
async function scanAllNests() {
|
|
1250
1343
|
const db = getDb();
|
|
1251
|
-
const rows = db.
|
|
1344
|
+
const rows = await db.all("SELECT id FROM nests");
|
|
1252
1345
|
await Promise.all(
|
|
1253
1346
|
rows.map(
|
|
1254
1347
|
({ id }) => scanNestForDrift(id).catch(
|
|
@@ -1268,9 +1361,9 @@ function startDriftScanner(intervalMs = 3e4) {
|
|
|
1268
1361
|
}
|
|
1269
1362
|
|
|
1270
1363
|
// src/nodes/service.ts
|
|
1271
|
-
function userIdFromEmail(email) {
|
|
1364
|
+
async function userIdFromEmail(email) {
|
|
1272
1365
|
const db = getDb();
|
|
1273
|
-
const row = db.
|
|
1366
|
+
const row = await db.get("SELECT id FROM users WHERE LOWER(email) = LOWER(?)", [email]);
|
|
1274
1367
|
return row?.id ?? ANON_USER_ID;
|
|
1275
1368
|
}
|
|
1276
1369
|
var normalizeTag2 = (t) => t.startsWith("#") ? t : `#${t}`;
|
|
@@ -1302,7 +1395,7 @@ function toNodeResponse(node) {
|
|
|
1302
1395
|
};
|
|
1303
1396
|
}
|
|
1304
1397
|
async function listNodesForCaller(nestId, userId, filters = {}) {
|
|
1305
|
-
const { storage, versions: versionManager } = engineCache.get(nestId);
|
|
1398
|
+
const { storage, versions: versionManager } = await engineCache.get(nestId);
|
|
1306
1399
|
let documents = await storage.discoverDocuments();
|
|
1307
1400
|
if (filters.type) {
|
|
1308
1401
|
documents = documents.filter((n) => n.frontmatter.type === filters.type);
|
|
@@ -1313,14 +1406,14 @@ async function listNodesForCaller(nestId, userId, filters = {}) {
|
|
|
1313
1406
|
(n) => (n.frontmatter.tags || []).includes(tag)
|
|
1314
1407
|
);
|
|
1315
1408
|
}
|
|
1316
|
-
const userEmail = resolveCallerEmail(userId);
|
|
1317
|
-
const accessible = filterAccessible(nestId, userId, userEmail, documents);
|
|
1318
|
-
const publicReader = isPublicReader(nestId, userId);
|
|
1409
|
+
const userEmail = await resolveCallerEmail(userId);
|
|
1410
|
+
const accessible = await filterAccessible(nestId, userId, userEmail, documents);
|
|
1411
|
+
const publicReader = await isPublicReader(nestId, userId);
|
|
1319
1412
|
const enriched = await Promise.all(
|
|
1320
1413
|
accessible.map(async (doc) => {
|
|
1321
1414
|
const r = toNodeResponse(doc);
|
|
1322
1415
|
if (publicReader) {
|
|
1323
|
-
const approved = getApprovedVersion(nestId, doc.id);
|
|
1416
|
+
const approved = await getApprovedVersion(nestId, doc.id);
|
|
1324
1417
|
if (approved != null) {
|
|
1325
1418
|
try {
|
|
1326
1419
|
const raw = await versionManager.reconstructVersion(doc.id, approved);
|
|
@@ -1344,7 +1437,7 @@ async function listNodesForCaller(nestId, userId, filters = {}) {
|
|
|
1344
1437
|
r.pendingChange = pending;
|
|
1345
1438
|
r.status = "external_edit_pending";
|
|
1346
1439
|
} else {
|
|
1347
|
-
r.status = getDisplayStatus(nestId, r.id);
|
|
1440
|
+
r.status = await getDisplayStatus(nestId, r.id);
|
|
1348
1441
|
}
|
|
1349
1442
|
return r;
|
|
1350
1443
|
})
|
|
@@ -1352,15 +1445,26 @@ async function listNodesForCaller(nestId, userId, filters = {}) {
|
|
|
1352
1445
|
return filters.limit ? enriched.slice(0, filters.limit) : enriched;
|
|
1353
1446
|
}
|
|
1354
1447
|
async function listNodesForCallerByEmail(nestId, userEmail, filters = {}) {
|
|
1355
|
-
return listNodesForCaller(nestId, userIdFromEmail(userEmail), filters);
|
|
1448
|
+
return listNodesForCaller(nestId, await userIdFromEmail(userEmail), filters);
|
|
1356
1449
|
}
|
|
1357
1450
|
async function createNode(nestId, input, userEmail) {
|
|
1358
|
-
const { storage, versions: versionManager } = engineCache.get(nestId);
|
|
1359
|
-
const
|
|
1360
|
-
const
|
|
1451
|
+
const { storage, versions: versionManager } = await engineCache.get(nestId);
|
|
1452
|
+
const slugify = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1453
|
+
const slug = slugify(input.title);
|
|
1454
|
+
let id = input.id;
|
|
1455
|
+
if (!id) {
|
|
1456
|
+
const folderSegments = (input.folder ?? "").split("/").map(slugify).filter(Boolean);
|
|
1457
|
+
if (folderSegments.length > 8) {
|
|
1458
|
+
throw new ValidationError("folder may nest at most 8 levels deep");
|
|
1459
|
+
}
|
|
1460
|
+
if ([...folderSegments, slug].some((seg) => seg.length > 100)) {
|
|
1461
|
+
throw new ValidationError("each folder or title segment must be at most 100 characters");
|
|
1462
|
+
}
|
|
1463
|
+
id = folderSegments.length > 0 ? `nodes/${folderSegments.join("/")}/${slug}` : `nodes/${slug}`;
|
|
1464
|
+
}
|
|
1361
1465
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1362
1466
|
const tags = (input.tags || []).map(normalizeTag2);
|
|
1363
|
-
const hasStewards = isStewardshipEnabled(nestId);
|
|
1467
|
+
const hasStewards = await isStewardshipEnabled(nestId);
|
|
1364
1468
|
const initialStatus = hasStewards ? "draft" : "published";
|
|
1365
1469
|
const initialVersion = hasStewards ? 1 : 0;
|
|
1366
1470
|
let node = {
|
|
@@ -1380,7 +1484,7 @@ async function createNode(nestId, input, userEmail) {
|
|
|
1380
1484
|
rawContent: ""
|
|
1381
1485
|
};
|
|
1382
1486
|
await storage.writeDocument(id, serializeDocument(node));
|
|
1383
|
-
syncNodeTags(nestId, id, tags);
|
|
1487
|
+
await syncNodeTags(nestId, id, tags);
|
|
1384
1488
|
let savedVersion = 1;
|
|
1385
1489
|
if (hasStewards) {
|
|
1386
1490
|
try {
|
|
@@ -1388,7 +1492,7 @@ async function createNode(nestId, input, userEmail) {
|
|
|
1388
1492
|
} catch (err) {
|
|
1389
1493
|
console.error("VersionManager.createVersion failed (node create)", err);
|
|
1390
1494
|
}
|
|
1391
|
-
createVersion({
|
|
1495
|
+
await createVersion({
|
|
1392
1496
|
nestId,
|
|
1393
1497
|
nodeId: id,
|
|
1394
1498
|
version: 1,
|
|
@@ -1404,7 +1508,7 @@ async function createNode(nestId, input, userEmail) {
|
|
|
1404
1508
|
note: "Auto-published on create (no stewards configured)"
|
|
1405
1509
|
});
|
|
1406
1510
|
savedVersion = result.node.frontmatter.version || 1;
|
|
1407
|
-
createVersion({
|
|
1511
|
+
await createVersion({
|
|
1408
1512
|
nestId,
|
|
1409
1513
|
nodeId: id,
|
|
1410
1514
|
version: savedVersion,
|
|
@@ -1413,11 +1517,11 @@ async function createNode(nestId, input, userEmail) {
|
|
|
1413
1517
|
status: "published",
|
|
1414
1518
|
tags
|
|
1415
1519
|
});
|
|
1416
|
-
setApprovedVersion(nestId, id, savedVersion, userEmail);
|
|
1520
|
+
await setApprovedVersion(nestId, id, savedVersion, userEmail);
|
|
1417
1521
|
node = result.node;
|
|
1418
1522
|
} catch (err) {
|
|
1419
1523
|
console.error("publishDocument failed (node create auto-publish)", err);
|
|
1420
|
-
createVersion({
|
|
1524
|
+
await createVersion({
|
|
1421
1525
|
nestId,
|
|
1422
1526
|
nodeId: id,
|
|
1423
1527
|
version: 1,
|
|
@@ -1428,11 +1532,11 @@ async function createNode(nestId, input, userEmail) {
|
|
|
1428
1532
|
});
|
|
1429
1533
|
}
|
|
1430
1534
|
}
|
|
1431
|
-
trackEvent("node.create", { nestId, nodeId: id });
|
|
1535
|
+
await trackEvent("node.create", { nestId, nodeId: id });
|
|
1432
1536
|
return { node, version: savedVersion };
|
|
1433
1537
|
}
|
|
1434
1538
|
async function registerImportedDocuments(nestId, userEmail) {
|
|
1435
|
-
const { storage } = engineCache.get(nestId);
|
|
1539
|
+
const { storage } = await engineCache.get(nestId);
|
|
1436
1540
|
let docs;
|
|
1437
1541
|
try {
|
|
1438
1542
|
docs = await storage.discoverDocuments();
|
|
@@ -1443,7 +1547,7 @@ async function registerImportedDocuments(nestId, userEmail) {
|
|
|
1443
1547
|
let registered = 0;
|
|
1444
1548
|
for (const doc of docs) {
|
|
1445
1549
|
const nodeId = doc.id;
|
|
1446
|
-
if (getCurrentVersion(nestId, nodeId) > 0) continue;
|
|
1550
|
+
if (await getCurrentVersion(nestId, nodeId) > 0) continue;
|
|
1447
1551
|
const rawTags = Array.isArray(doc.frontmatter?.tags) ? doc.frontmatter.tags : [];
|
|
1448
1552
|
const tags = rawTags.map((t) => normalizeTag2(String(t)));
|
|
1449
1553
|
const fmVersion = Number(doc.frontmatter?.version);
|
|
@@ -1459,7 +1563,7 @@ async function registerImportedDocuments(nestId, userEmail) {
|
|
|
1459
1563
|
} catch (err) {
|
|
1460
1564
|
console.error("safePublishDocument failed (import register)", nodeId, err);
|
|
1461
1565
|
}
|
|
1462
|
-
createVersion({
|
|
1566
|
+
await createVersion({
|
|
1463
1567
|
nestId,
|
|
1464
1568
|
nodeId,
|
|
1465
1569
|
version,
|
|
@@ -1469,15 +1573,15 @@ async function registerImportedDocuments(nestId, userEmail) {
|
|
|
1469
1573
|
changeNote: "Imported from existing folder",
|
|
1470
1574
|
tags
|
|
1471
1575
|
});
|
|
1472
|
-
setApprovedVersion(nestId, nodeId, version, userEmail);
|
|
1473
|
-
syncNodeTags(nestId, nodeId, tags);
|
|
1576
|
+
await setApprovedVersion(nestId, nodeId, version, userEmail);
|
|
1577
|
+
await syncNodeTags(nestId, nodeId, tags);
|
|
1474
1578
|
registered++;
|
|
1475
1579
|
}
|
|
1476
|
-
trackEvent("nest.import.documents", { nestId, registered });
|
|
1580
|
+
await trackEvent("nest.import.documents", { nestId, registered });
|
|
1477
1581
|
return registered;
|
|
1478
1582
|
}
|
|
1479
1583
|
async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
1480
|
-
const { storage, versions: versionManager } = engineCache.get(nestId);
|
|
1584
|
+
const { storage, versions: versionManager } = await engineCache.get(nestId);
|
|
1481
1585
|
let node;
|
|
1482
1586
|
try {
|
|
1483
1587
|
node = await storage.readDocument(nodeId);
|
|
@@ -1501,16 +1605,16 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
1501
1605
|
if (patch.title) {
|
|
1502
1606
|
node = { ...node, frontmatter: { ...node.frontmatter, title: patch.title } };
|
|
1503
1607
|
}
|
|
1504
|
-
const hasStewards = isStewardshipEnabled(nestId);
|
|
1608
|
+
const hasStewards = await isStewardshipEnabled(nestId);
|
|
1505
1609
|
const currentTags = node.frontmatter.tags || [];
|
|
1506
|
-
if (hasStewards && getPendingReview(nestId, nodeId)) {
|
|
1610
|
+
if (hasStewards && await getPendingReview(nestId, nodeId)) {
|
|
1507
1611
|
throw new LockedError(
|
|
1508
1612
|
"This document is awaiting steward review and is locked. Approve or reject the pending review before editing."
|
|
1509
1613
|
);
|
|
1510
1614
|
}
|
|
1511
1615
|
let responseVersion;
|
|
1512
1616
|
if (hasStewards) {
|
|
1513
|
-
const currentVersion = getCurrentVersion(nestId, nodeId);
|
|
1617
|
+
const currentVersion = await getCurrentVersion(nestId, nodeId);
|
|
1514
1618
|
const newVersion = currentVersion + 1;
|
|
1515
1619
|
node = {
|
|
1516
1620
|
...node,
|
|
@@ -1525,13 +1629,13 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
1525
1629
|
};
|
|
1526
1630
|
node = { ...node, frontmatter: stripUndefined(node.frontmatter) };
|
|
1527
1631
|
await storage.writeDocument(nodeId, serializeDocument(node));
|
|
1528
|
-
syncNodeTags(nestId, nodeId, currentTags);
|
|
1632
|
+
await syncNodeTags(nestId, nodeId, currentTags);
|
|
1529
1633
|
try {
|
|
1530
1634
|
await versionManager.createVersion(node, userEmail, { note: patch.changeNote });
|
|
1531
1635
|
} catch (err) {
|
|
1532
1636
|
console.error("VersionManager.createVersion failed (node patch)", err);
|
|
1533
1637
|
}
|
|
1534
|
-
createVersion({
|
|
1638
|
+
await createVersion({
|
|
1535
1639
|
nestId,
|
|
1536
1640
|
nodeId,
|
|
1537
1641
|
version: newVersion,
|
|
@@ -1553,7 +1657,7 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
1553
1657
|
};
|
|
1554
1658
|
node = { ...node, frontmatter: stripUndefined(node.frontmatter) };
|
|
1555
1659
|
await storage.writeDocument(nodeId, serializeDocument(node));
|
|
1556
|
-
syncNodeTags(nestId, nodeId, currentTags);
|
|
1660
|
+
await syncNodeTags(nestId, nodeId, currentTags);
|
|
1557
1661
|
let publishedVersion = (node.frontmatter.version || 0) + 1;
|
|
1558
1662
|
try {
|
|
1559
1663
|
const result = await safePublishDocument(storage, nodeId, {
|
|
@@ -1565,7 +1669,7 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
1565
1669
|
} catch (err) {
|
|
1566
1670
|
console.error("publishDocument failed (node patch auto-publish)", err);
|
|
1567
1671
|
}
|
|
1568
|
-
createVersion({
|
|
1672
|
+
await createVersion({
|
|
1569
1673
|
nestId,
|
|
1570
1674
|
nodeId,
|
|
1571
1675
|
version: publishedVersion,
|
|
@@ -1575,7 +1679,7 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
1575
1679
|
tags: currentTags,
|
|
1576
1680
|
changeNote: patch.changeNote
|
|
1577
1681
|
});
|
|
1578
|
-
setApprovedVersion(nestId, nodeId, publishedVersion, userEmail);
|
|
1682
|
+
await setApprovedVersion(nestId, nodeId, publishedVersion, userEmail);
|
|
1579
1683
|
responseVersion = publishedVersion;
|
|
1580
1684
|
}
|
|
1581
1685
|
return { node, version: responseVersion };
|
|
@@ -1583,8 +1687,11 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
|
|
|
1583
1687
|
|
|
1584
1688
|
// src/nests/unsynced-service.ts
|
|
1585
1689
|
import { readdirSync, readFileSync, rmSync, statSync } from "fs";
|
|
1586
|
-
import { join as join2, relative } from "path";
|
|
1690
|
+
import { join as join2, relative, resolve } from "path";
|
|
1587
1691
|
var RESERVED = /* @__PURE__ */ new Set(["nests"]);
|
|
1692
|
+
function isNestStorageRoot(absPath) {
|
|
1693
|
+
return resolve(absPath) === resolve(nestStorageRoot());
|
|
1694
|
+
}
|
|
1588
1695
|
function scanMarkdown(dir) {
|
|
1589
1696
|
let count = 0;
|
|
1590
1697
|
let size = 0;
|
|
@@ -1664,7 +1771,9 @@ function listUnsyncedFolders() {
|
|
|
1664
1771
|
if (!e.isDirectory()) continue;
|
|
1665
1772
|
if (e.name.startsWith(".")) continue;
|
|
1666
1773
|
if (RESERVED.has(e.name)) continue;
|
|
1667
|
-
|
|
1774
|
+
const abs = join2(root, e.name);
|
|
1775
|
+
if (isNestStorageRoot(abs)) continue;
|
|
1776
|
+
out.push(...collectLeafFolders(e.name, abs));
|
|
1668
1777
|
}
|
|
1669
1778
|
out.sort((a, b) => a.name.localeCompare(b.name));
|
|
1670
1779
|
console.log(
|
|
@@ -1718,6 +1827,9 @@ async function syncUnsyncedFolder(userId, folderName, callerEmail) {
|
|
|
1718
1827
|
);
|
|
1719
1828
|
assertSafeFolderName(folderName);
|
|
1720
1829
|
const src = join2(config.DATA_ROOT, folderName);
|
|
1830
|
+
if (isNestStorageRoot(src)) {
|
|
1831
|
+
throw new ValidationError("Folder is not eligible for sync");
|
|
1832
|
+
}
|
|
1721
1833
|
let stat;
|
|
1722
1834
|
try {
|
|
1723
1835
|
stat = statSync(src);
|
|
@@ -1737,7 +1849,7 @@ async function syncUnsyncedFolder(userId, folderName, callerEmail) {
|
|
|
1737
1849
|
}
|
|
1738
1850
|
const segments = folderName.split("/").filter(Boolean);
|
|
1739
1851
|
const baseName = segments[segments.length - 1] || folderName;
|
|
1740
|
-
const nestName = uniqueNestName(userId, baseName);
|
|
1852
|
+
const nestName = await uniqueNestName(userId, baseName);
|
|
1741
1853
|
if (nestName !== baseName) {
|
|
1742
1854
|
console.log(
|
|
1743
1855
|
`[unsynced] name "${baseName}" already in use, using "${nestName}" instead`
|
|
@@ -1761,10 +1873,13 @@ async function syncUnsyncedFolder(userId, folderName, callerEmail) {
|
|
|
1761
1873
|
}
|
|
1762
1874
|
|
|
1763
1875
|
// src/nests/routes.ts
|
|
1764
|
-
function effectivePermission(nestId, userId) {
|
|
1876
|
+
async function effectivePermission(nestId, userId) {
|
|
1765
1877
|
if (config.AUTH_MODE === "open") {
|
|
1766
1878
|
const db = getDb();
|
|
1767
|
-
const nest = db.
|
|
1879
|
+
const nest = await db.get(
|
|
1880
|
+
"SELECT user_id FROM nests WHERE id = ?",
|
|
1881
|
+
[nestId]
|
|
1882
|
+
);
|
|
1768
1883
|
if (nest && nest.user_id === ANON_USER_ID) return "owner";
|
|
1769
1884
|
}
|
|
1770
1885
|
return resolveNestPermission(nestId, userId);
|
|
@@ -1772,21 +1887,30 @@ function effectivePermission(nestId, userId) {
|
|
|
1772
1887
|
var nestRoutes = new Hono2();
|
|
1773
1888
|
nestRoutes.get("/", async (c) => {
|
|
1774
1889
|
const userId = c.get("userId");
|
|
1775
|
-
const owned = listNests(userId);
|
|
1776
|
-
const shared = listSharedNests(userId);
|
|
1777
|
-
const publicExtras = listPublicNests(userId);
|
|
1890
|
+
const owned = await listNests(userId);
|
|
1891
|
+
const shared = await listSharedNests(userId);
|
|
1892
|
+
const publicExtras = await listPublicNests(userId);
|
|
1778
1893
|
const db = getDb();
|
|
1779
|
-
const
|
|
1780
|
-
|
|
1781
|
-
)
|
|
1782
|
-
|
|
1783
|
-
const annotate = (n) => {
|
|
1784
|
-
const permission = effectivePermission(n.id, userId);
|
|
1894
|
+
const ownerEmailSql = "SELECT email FROM users WHERE id = ?";
|
|
1895
|
+
const callerEmail = await resolveCallerEmail(userId);
|
|
1896
|
+
const annotate = async (n) => {
|
|
1897
|
+
const permission = await effectivePermission(n.id, userId);
|
|
1785
1898
|
const is_owner = permission === "owner";
|
|
1786
1899
|
let owner_email = null;
|
|
1787
|
-
|
|
1900
|
+
let roles;
|
|
1901
|
+
if (is_owner) {
|
|
1902
|
+
roles = ["owner"];
|
|
1903
|
+
} else {
|
|
1904
|
+
const grantRole = collabPermToRole(
|
|
1905
|
+
await getCollaboratorRole(n.id, callerEmail)
|
|
1906
|
+
);
|
|
1907
|
+
const stewardRoles = await getStewardRolesForUser(n.id, callerEmail);
|
|
1908
|
+
roles = [
|
|
1909
|
+
...new Set([grantRole, ...stewardRoles].filter(Boolean))
|
|
1910
|
+
];
|
|
1911
|
+
}
|
|
1788
1912
|
if (!is_owner && n.user_id !== ANON_USER_ID) {
|
|
1789
|
-
const row =
|
|
1913
|
+
const row = await db.get(ownerEmailSql, [n.user_id]);
|
|
1790
1914
|
owner_email = row?.email ?? null;
|
|
1791
1915
|
}
|
|
1792
1916
|
return { ...n, permission, is_owner, owner_email, roles };
|
|
@@ -1796,9 +1920,27 @@ nestRoutes.get("/", async (c) => {
|
|
|
1796
1920
|
for (const n of [...owned, ...shared, ...publicExtras]) {
|
|
1797
1921
|
if (seen.has(n.id)) continue;
|
|
1798
1922
|
seen.add(n.id);
|
|
1799
|
-
out.push(annotate(n));
|
|
1923
|
+
out.push(await annotate(n));
|
|
1800
1924
|
}
|
|
1801
|
-
|
|
1925
|
+
const docRows = await db.all(
|
|
1926
|
+
"SELECT nest_id, COUNT(DISTINCT node_id) AS c FROM node_versions GROUP BY nest_id"
|
|
1927
|
+
);
|
|
1928
|
+
const docByNest = new Map(docRows.map((r) => [r.nest_id, r.c]));
|
|
1929
|
+
const collabRows = await db.all(
|
|
1930
|
+
"SELECT nest_id, COUNT(*) AS c FROM nest_collaborators GROUP BY nest_id"
|
|
1931
|
+
);
|
|
1932
|
+
const collabByNest = new Map(collabRows.map((r) => [r.nest_id, r.c]));
|
|
1933
|
+
const stewardRows = await db.all(
|
|
1934
|
+
"SELECT nest_id, COUNT(DISTINCT user_email) AS c FROM stewards WHERE is_active = 1 GROUP BY nest_id"
|
|
1935
|
+
);
|
|
1936
|
+
const stewardByNest = new Map(stewardRows.map((r) => [r.nest_id, r.c]));
|
|
1937
|
+
const withMeta = out.map((n) => ({
|
|
1938
|
+
...n,
|
|
1939
|
+
document_count: docByNest.get(n.id) ?? 0,
|
|
1940
|
+
collaborator_count: collabByNest.get(n.id) ?? 0,
|
|
1941
|
+
steward_count: stewardByNest.get(n.id) ?? 0
|
|
1942
|
+
}));
|
|
1943
|
+
return c.json({ nests: withMeta });
|
|
1802
1944
|
});
|
|
1803
1945
|
nestRoutes.post("/", async (c) => {
|
|
1804
1946
|
const body = await c.req.json();
|
|
@@ -1818,20 +1960,20 @@ nestRoutes.post("/import", async (c) => {
|
|
|
1818
1960
|
const nest = await importNest(userId, body.name, files);
|
|
1819
1961
|
const documents = await registerImportedDocuments(
|
|
1820
1962
|
nest.id,
|
|
1821
|
-
resolveCallerEmail(userId)
|
|
1963
|
+
await resolveCallerEmail(userId)
|
|
1822
1964
|
);
|
|
1823
1965
|
return c.json({ nest, documents }, 201);
|
|
1824
1966
|
});
|
|
1825
1967
|
nestRoutes.get("/unsynced", async (c) => {
|
|
1826
1968
|
const userId = c.get("userId");
|
|
1827
|
-
if (config.AUTH_MODE !== "open" && !isLicenseAdminUserId(userId)) {
|
|
1969
|
+
if (config.AUTH_MODE !== "open" && !await isLicenseAdminUserId(userId)) {
|
|
1828
1970
|
throw new ForbiddenError("Only the server admin can list unsynced folders");
|
|
1829
1971
|
}
|
|
1830
1972
|
return c.json({ folders: listUnsyncedFolders() });
|
|
1831
1973
|
});
|
|
1832
1974
|
nestRoutes.post("/unsynced/sync", async (c) => {
|
|
1833
1975
|
const userId = c.get("userId");
|
|
1834
|
-
if (config.AUTH_MODE !== "open" && !isLicenseAdminUserId(userId)) {
|
|
1976
|
+
if (config.AUTH_MODE !== "open" && !await isLicenseAdminUserId(userId)) {
|
|
1835
1977
|
throw new ForbiddenError("Only the server admin can sync folders");
|
|
1836
1978
|
}
|
|
1837
1979
|
const body = await c.req.json();
|
|
@@ -1841,30 +1983,30 @@ nestRoutes.post("/unsynced/sync", async (c) => {
|
|
|
1841
1983
|
const result = await syncUnsyncedFolder(
|
|
1842
1984
|
userId,
|
|
1843
1985
|
body.name,
|
|
1844
|
-
resolveCallerEmail(userId)
|
|
1986
|
+
await resolveCallerEmail(userId)
|
|
1845
1987
|
);
|
|
1846
1988
|
return c.json(result, 201);
|
|
1847
1989
|
});
|
|
1848
1990
|
nestRoutes.get("/:nestId", async (c) => {
|
|
1849
1991
|
const nestId = c.req.param("nestId");
|
|
1850
1992
|
const userId = c.get("userId");
|
|
1851
|
-
const permission = effectivePermission(nestId, userId);
|
|
1993
|
+
const permission = await effectivePermission(nestId, userId);
|
|
1852
1994
|
if (permission === "none") {
|
|
1853
1995
|
throw new NotFoundError("Nest not found");
|
|
1854
1996
|
}
|
|
1855
|
-
const email = resolveCallerEmail(userId);
|
|
1856
|
-
let roles = resolveUserRoles(nestId, email);
|
|
1997
|
+
const email = await resolveCallerEmail(userId);
|
|
1998
|
+
let roles = await resolveUserRoles(nestId, email);
|
|
1857
1999
|
if (permission === "owner" && !roles.includes("owner")) {
|
|
1858
2000
|
roles = ["owner", ...roles];
|
|
1859
2001
|
}
|
|
1860
|
-
const myStewards = getStewardsForUser(nestId, email);
|
|
1861
|
-
const nest = getNest(nestId);
|
|
2002
|
+
const myStewards = await getStewardsForUser(nestId, email);
|
|
2003
|
+
const nest = await getNest(nestId);
|
|
1862
2004
|
return c.json({ nest, permission, roles, myStewards });
|
|
1863
2005
|
});
|
|
1864
2006
|
nestRoutes.patch("/:nestId", async (c) => {
|
|
1865
2007
|
const nestId = c.req.param("nestId");
|
|
1866
2008
|
const userId = c.get("userId");
|
|
1867
|
-
const permission = effectivePermission(nestId, userId);
|
|
2009
|
+
const permission = await effectivePermission(nestId, userId);
|
|
1868
2010
|
if (permission === "none") {
|
|
1869
2011
|
throw new NotFoundError("Nest not found");
|
|
1870
2012
|
}
|
|
@@ -1874,7 +2016,7 @@ nestRoutes.patch("/:nestId", async (c) => {
|
|
|
1874
2016
|
);
|
|
1875
2017
|
}
|
|
1876
2018
|
const body = await c.req.json();
|
|
1877
|
-
const nest = renameNest(nestId, {
|
|
2019
|
+
const nest = await renameNest(nestId, {
|
|
1878
2020
|
name: body.name,
|
|
1879
2021
|
description: body.description
|
|
1880
2022
|
});
|
|
@@ -1883,13 +2025,13 @@ nestRoutes.patch("/:nestId", async (c) => {
|
|
|
1883
2025
|
nestRoutes.delete("/:nestId", async (c) => {
|
|
1884
2026
|
const nestId = c.req.param("nestId");
|
|
1885
2027
|
const userId = c.get("userId");
|
|
1886
|
-
const nest = getNest(nestId);
|
|
2028
|
+
const nest = await getNest(nestId);
|
|
1887
2029
|
if (!nest) {
|
|
1888
2030
|
throw new NotFoundError("Nest not found");
|
|
1889
2031
|
}
|
|
1890
|
-
const permission = effectivePermission(nestId, userId);
|
|
2032
|
+
const permission = await effectivePermission(nestId, userId);
|
|
1891
2033
|
const isAnonOwned = nest.user_id === ANON_USER_ID;
|
|
1892
|
-
const adminCaretaker = config.AUTH_MODE !== "open" && isAnonOwned && isLicenseAdminUserId(userId);
|
|
2034
|
+
const adminCaretaker = config.AUTH_MODE !== "open" && isAnonOwned && await isLicenseAdminUserId(userId);
|
|
1893
2035
|
if (permission !== "owner" && !adminCaretaker) {
|
|
1894
2036
|
throw new ForbiddenError(
|
|
1895
2037
|
"You don't have permission to delete this nest. Only the nest owner can delete it."
|
|
@@ -1900,24 +2042,24 @@ nestRoutes.delete("/:nestId", async (c) => {
|
|
|
1900
2042
|
});
|
|
1901
2043
|
nestRoutes.get("/:nestId/settings", async (c) => {
|
|
1902
2044
|
const nestId = c.req.param("nestId");
|
|
1903
|
-
const permission = effectivePermission(nestId, c.get("userId"));
|
|
2045
|
+
const permission = await effectivePermission(nestId, c.get("userId"));
|
|
1904
2046
|
if (permission === "none") {
|
|
1905
2047
|
throw new NotFoundError("Nest not found");
|
|
1906
2048
|
}
|
|
1907
2049
|
return c.json({
|
|
1908
|
-
stewardship_enabled: isStewardshipEnabled(nestId),
|
|
1909
|
-
allow_self_approve: nestAllowsSelfApprove(nestId)
|
|
2050
|
+
stewardship_enabled: await isStewardshipEnabled(nestId),
|
|
2051
|
+
allow_self_approve: await nestAllowsSelfApprove(nestId)
|
|
1910
2052
|
});
|
|
1911
2053
|
});
|
|
1912
2054
|
nestRoutes.patch("/:nestId/settings", async (c) => {
|
|
1913
2055
|
const nestId = c.req.param("nestId");
|
|
1914
2056
|
const userId = c.get("userId");
|
|
1915
|
-
const isServerAdmin = isLicenseAdminUserId(userId);
|
|
1916
|
-
const permission = effectivePermission(nestId, userId);
|
|
1917
|
-
if (!isServerAdmin && permission !== "owner") {
|
|
2057
|
+
const isServerAdmin = await isLicenseAdminUserId(userId);
|
|
2058
|
+
const permission = await effectivePermission(nestId, userId);
|
|
2059
|
+
if (!isServerAdmin && permission !== "owner" && permission !== "admin") {
|
|
1918
2060
|
return c.json(
|
|
1919
2061
|
{
|
|
1920
|
-
error: "Only the nest owner or the server license-admin can update nest settings."
|
|
2062
|
+
error: "Only a nest admin, the nest owner, or the server license-admin can update nest settings."
|
|
1921
2063
|
},
|
|
1922
2064
|
403
|
|
1923
2065
|
);
|
|
@@ -1926,17 +2068,25 @@ nestRoutes.patch("/:nestId/settings", async (c) => {
|
|
|
1926
2068
|
let wiped = null;
|
|
1927
2069
|
if (typeof body.stewardship_enabled === "boolean") {
|
|
1928
2070
|
if (body.stewardship_enabled) {
|
|
1929
|
-
setStewardshipEnabled(nestId, true);
|
|
2071
|
+
await setStewardshipEnabled(nestId, true);
|
|
1930
2072
|
} else {
|
|
1931
|
-
|
|
2073
|
+
if (!isServerAdmin && permission !== "owner") {
|
|
2074
|
+
return c.json(
|
|
2075
|
+
{
|
|
2076
|
+
error: "Only the nest owner or the server license-admin can disable stewardship (this permanently wipes stewards and pending reviews)."
|
|
2077
|
+
},
|
|
2078
|
+
403
|
|
2079
|
+
);
|
|
2080
|
+
}
|
|
2081
|
+
wiped = await disableStewardshipAndWipeGovernance(nestId);
|
|
1932
2082
|
}
|
|
1933
2083
|
}
|
|
1934
2084
|
if (typeof body.allow_self_approve === "boolean") {
|
|
1935
|
-
setAllowSelfApprove(nestId, body.allow_self_approve);
|
|
2085
|
+
await setAllowSelfApprove(nestId, body.allow_self_approve);
|
|
1936
2086
|
}
|
|
1937
2087
|
return c.json({
|
|
1938
|
-
stewardship_enabled: isStewardshipEnabled(nestId),
|
|
1939
|
-
allow_self_approve: nestAllowsSelfApprove(nestId),
|
|
2088
|
+
stewardship_enabled: await isStewardshipEnabled(nestId),
|
|
2089
|
+
allow_self_approve: await nestAllowsSelfApprove(nestId),
|
|
1940
2090
|
wiped
|
|
1941
2091
|
});
|
|
1942
2092
|
});
|
|
@@ -1961,21 +2111,28 @@ async function addCollaborator(params) {
|
|
|
1961
2111
|
let userId = params.userId;
|
|
1962
2112
|
if (!userId && params.email) {
|
|
1963
2113
|
const email = normalizeEmail(params.email);
|
|
1964
|
-
const existing = db.
|
|
2114
|
+
const existing = await db.get(
|
|
2115
|
+
"SELECT id FROM users WHERE LOWER(email) = ?",
|
|
2116
|
+
[email]
|
|
2117
|
+
);
|
|
1965
2118
|
if (existing) {
|
|
1966
2119
|
userId = existing.id;
|
|
1967
2120
|
} else {
|
|
1968
2121
|
const { hashPassword: hashPassword2 } = await import("./keys-73STFJJB.js");
|
|
1969
2122
|
userId = uuid2();
|
|
1970
|
-
db.
|
|
1971
|
-
"INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)"
|
|
1972
|
-
|
|
2123
|
+
await db.run(
|
|
2124
|
+
"INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
|
|
2125
|
+
[userId, email, null, await hashPassword2(uuid2())]
|
|
2126
|
+
);
|
|
1973
2127
|
}
|
|
1974
2128
|
}
|
|
1975
2129
|
if (!userId) {
|
|
1976
2130
|
throw new ValidationError("user_id or email is required");
|
|
1977
2131
|
}
|
|
1978
|
-
const ownerRow = db.
|
|
2132
|
+
const ownerRow = await db.get(
|
|
2133
|
+
"SELECT user_id FROM nests WHERE id = ?",
|
|
2134
|
+
[nestId]
|
|
2135
|
+
);
|
|
1979
2136
|
if (!ownerRow) {
|
|
1980
2137
|
throw new ValidationError("Nest not found");
|
|
1981
2138
|
}
|
|
@@ -1989,19 +2146,29 @@ async function addCollaborator(params) {
|
|
|
1989
2146
|
if (selfByEmail || selfById) {
|
|
1990
2147
|
throw new ValidationError("You can't add yourself as a collaborator.");
|
|
1991
2148
|
}
|
|
1992
|
-
const dupe = db.
|
|
2149
|
+
const dupe = await db.get(
|
|
2150
|
+
"SELECT id FROM nest_collaborators WHERE nest_id = ? AND user_id = ?",
|
|
2151
|
+
[nestId, userId]
|
|
2152
|
+
);
|
|
1993
2153
|
if (dupe) {
|
|
1994
2154
|
throw new ConflictError(
|
|
1995
2155
|
`${params.email || "This user"} already has access to this nest. Change their permission instead of adding them again.`
|
|
1996
2156
|
);
|
|
1997
2157
|
}
|
|
1998
|
-
const granterByEmail = !params.grantedByUserId && params.grantedByEmail ?
|
|
2158
|
+
const granterByEmail = !params.grantedByUserId && params.grantedByEmail ? (await db.get(
|
|
2159
|
+
"SELECT id FROM users WHERE LOWER(email) = LOWER(?)",
|
|
2160
|
+
[params.grantedByEmail]
|
|
2161
|
+
))?.id : void 0;
|
|
1999
2162
|
const granterId = params.grantedByUserId || granterByEmail || ownerRow.user_id;
|
|
2000
2163
|
const collabId = uuid2();
|
|
2001
|
-
db.
|
|
2002
|
-
"INSERT INTO nest_collaborators (id, nest_id, user_id, permission, granted_by) VALUES (?, ?, ?, ?, ?)"
|
|
2003
|
-
|
|
2004
|
-
|
|
2164
|
+
await db.run(
|
|
2165
|
+
"INSERT INTO nest_collaborators (id, nest_id, user_id, permission, granted_by) VALUES (?, ?, ?, ?, ?)",
|
|
2166
|
+
[collabId, nestId, userId, params.permission, granterId]
|
|
2167
|
+
);
|
|
2168
|
+
return await db.get(
|
|
2169
|
+
"SELECT * FROM nest_collaborators WHERE id = ?",
|
|
2170
|
+
[collabId]
|
|
2171
|
+
);
|
|
2005
2172
|
}
|
|
2006
2173
|
|
|
2007
2174
|
// src/nests/sharing-routes.ts
|
|
@@ -2009,20 +2176,25 @@ var sharingRoutes = new Hono3();
|
|
|
2009
2176
|
sharingRoutes.get("/collaborators", async (c) => {
|
|
2010
2177
|
const db = getDb();
|
|
2011
2178
|
const nestId = c.req.param("nestId");
|
|
2012
|
-
const collabs = db.
|
|
2179
|
+
const collabs = await db.all(
|
|
2013
2180
|
`SELECT nc.*, u.email FROM nest_collaborators nc
|
|
2014
2181
|
LEFT JOIN users u ON nc.user_id = u.id
|
|
2015
2182
|
WHERE nc.nest_id = ?
|
|
2016
|
-
ORDER BY nc.granted_at
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2183
|
+
ORDER BY nc.granted_at`,
|
|
2184
|
+
[nestId]
|
|
2185
|
+
);
|
|
2186
|
+
const enriched = [];
|
|
2187
|
+
for (const collab of collabs) {
|
|
2188
|
+
if (!collab.email) {
|
|
2189
|
+
enriched.push({ ...collab, stewardRoles: [], roles: [] });
|
|
2190
|
+
continue;
|
|
2191
|
+
}
|
|
2192
|
+
enriched.push({
|
|
2021
2193
|
...collab,
|
|
2022
|
-
stewardRoles: getStewardRolesForUser(nestId, collab.email),
|
|
2023
|
-
roles: resolveUserRoles(nestId, collab.email)
|
|
2024
|
-
};
|
|
2025
|
-
}
|
|
2194
|
+
stewardRoles: await getStewardRolesForUser(nestId, collab.email),
|
|
2195
|
+
roles: await resolveUserRoles(nestId, collab.email)
|
|
2196
|
+
});
|
|
2197
|
+
}
|
|
2026
2198
|
return c.json({ collaborators: enriched });
|
|
2027
2199
|
});
|
|
2028
2200
|
sharingRoutes.post("/collaborators", async (c) => {
|
|
@@ -2045,9 +2217,10 @@ sharingRoutes.patch("/collaborators/:collabId", async (c) => {
|
|
|
2045
2217
|
throw new ValidationError("permission must be read, write, or admin");
|
|
2046
2218
|
}
|
|
2047
2219
|
const db = getDb();
|
|
2048
|
-
const info = db.
|
|
2049
|
-
"UPDATE nest_collaborators SET permission = ? WHERE id = ? AND nest_id = ?"
|
|
2050
|
-
|
|
2220
|
+
const info = await db.run(
|
|
2221
|
+
"UPDATE nest_collaborators SET permission = ? WHERE id = ? AND nest_id = ?",
|
|
2222
|
+
[body.permission, c.req.param("collabId"), c.req.param("nestId")]
|
|
2223
|
+
);
|
|
2051
2224
|
if (info.changes === 0) {
|
|
2052
2225
|
throw new NotFoundError("Collaborator not found");
|
|
2053
2226
|
}
|
|
@@ -2055,9 +2228,10 @@ sharingRoutes.patch("/collaborators/:collabId", async (c) => {
|
|
|
2055
2228
|
});
|
|
2056
2229
|
sharingRoutes.delete("/collaborators/:collabId", async (c) => {
|
|
2057
2230
|
const db = getDb();
|
|
2058
|
-
const info = db.
|
|
2059
|
-
"DELETE FROM nest_collaborators WHERE id = ? AND nest_id = ?"
|
|
2060
|
-
|
|
2231
|
+
const info = await db.run(
|
|
2232
|
+
"DELETE FROM nest_collaborators WHERE id = ? AND nest_id = ?",
|
|
2233
|
+
[c.req.param("collabId"), c.req.param("nestId")]
|
|
2234
|
+
);
|
|
2061
2235
|
if (info.changes === 0) {
|
|
2062
2236
|
throw new NotFoundError("Collaborator not found");
|
|
2063
2237
|
}
|
|
@@ -2069,10 +2243,10 @@ sharingRoutes.patch("/visibility", async (c) => {
|
|
|
2069
2243
|
throw new ValidationError("visibility must be private or public");
|
|
2070
2244
|
}
|
|
2071
2245
|
const db = getDb();
|
|
2072
|
-
db.
|
|
2246
|
+
await db.run("UPDATE nests SET visibility = ? WHERE id = ?", [
|
|
2073
2247
|
body.visibility,
|
|
2074
2248
|
c.req.param("nestId")
|
|
2075
|
-
);
|
|
2249
|
+
]);
|
|
2076
2250
|
return c.json({ visibility: body.visibility });
|
|
2077
2251
|
});
|
|
2078
2252
|
|
|
@@ -2099,372 +2273,74 @@ function isMarkdownFormat(c) {
|
|
|
2099
2273
|
return c.req.query("format") === "markdown";
|
|
2100
2274
|
}
|
|
2101
2275
|
|
|
2102
|
-
// src/
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2276
|
+
// src/annotations/service.ts
|
|
2277
|
+
import { v4 as uuid3 } from "uuid";
|
|
2278
|
+
|
|
2279
|
+
// src/annotations/projection.ts
|
|
2280
|
+
var MAX_CONTEXT_CHARS = 250;
|
|
2281
|
+
var MAX_QUOTE_CHARS = 1e3;
|
|
2282
|
+
function clampAnchor(raw) {
|
|
2283
|
+
if (!raw) return null;
|
|
2284
|
+
const quote = (raw.quote ?? "").trim().slice(0, MAX_QUOTE_CHARS);
|
|
2285
|
+
if (!quote) return null;
|
|
2286
|
+
const before = (raw.before ?? "").slice(-MAX_CONTEXT_CHARS);
|
|
2287
|
+
const after = (raw.after ?? "").slice(0, MAX_CONTEXT_CHARS);
|
|
2288
|
+
const line = typeof raw.line === "number" && Number.isFinite(raw.line) && raw.line > 0 ? Math.floor(raw.line) : void 0;
|
|
2289
|
+
return { quote, before, after, ...line !== void 0 ? { line } : {} };
|
|
2112
2290
|
}
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
nodeRoutes.post("/", async (c) => {
|
|
2120
|
-
const body = await c.req.json();
|
|
2121
|
-
if (!body.title || !body.content) {
|
|
2122
|
-
throw new ValidationError("title and content are required");
|
|
2291
|
+
function oneLine(s) {
|
|
2292
|
+
return s.replace(/\s+/g, " ").replace(/--+>/g, "-\u2192").trim();
|
|
2293
|
+
}
|
|
2294
|
+
function renderAnchor(anchor) {
|
|
2295
|
+
if (!anchor) {
|
|
2296
|
+
return "<!-- anchor: whole-artifact -->";
|
|
2123
2297
|
}
|
|
2124
|
-
const
|
|
2125
|
-
const
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
}
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
}, 201);
|
|
2147
|
-
});
|
|
2148
|
-
nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
|
|
2149
|
-
const nestId = c.req.param("nestId");
|
|
2150
|
-
const nodeId = c.req.param("nodeId");
|
|
2151
|
-
const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-P4M5UEJW.js");
|
|
2152
|
-
const { stewards, fallbackToOwner, ownerEmail } = resolveStewardsWithFallback2(
|
|
2153
|
-
nestId,
|
|
2154
|
-
nodeId
|
|
2155
|
-
);
|
|
2156
|
-
return c.json({
|
|
2157
|
-
nodeId,
|
|
2158
|
-
stewards: stewards.map((r) => ({
|
|
2159
|
-
email: r.steward.userEmail,
|
|
2160
|
-
role: r.steward.role,
|
|
2161
|
-
scope: r.steward.scope,
|
|
2162
|
-
source: r.source,
|
|
2163
|
-
priority: r.priority
|
|
2164
|
-
})),
|
|
2165
|
-
fallbackToOwner,
|
|
2166
|
-
ownerEmail
|
|
2298
|
+
const linePart = anchor.line !== void 0 ? `line ${anchor.line} \xB7 ` : "";
|
|
2299
|
+
const context = `\u2026${oneLine(anchor.before)}\u3008${oneLine(anchor.quote)}\u3009${oneLine(
|
|
2300
|
+
anchor.after
|
|
2301
|
+
)}\u2026`;
|
|
2302
|
+
return `<!-- anchor: ${linePart}quote "${oneLine(anchor.quote)}" \xB7 context ${context} -->`;
|
|
2303
|
+
}
|
|
2304
|
+
function statusHeading(thread) {
|
|
2305
|
+
if (thread.status === "resolved") {
|
|
2306
|
+
const who = thread.resolvedBy ? `${thread.resolvedBy}, ` : "";
|
|
2307
|
+
const when = thread.resolvedAt ? `${thread.resolvedAt}` : "";
|
|
2308
|
+
const meta = who || when ? ` (${who}${when})` : "";
|
|
2309
|
+
return `\u2705 RESOLVED${meta}`;
|
|
2310
|
+
}
|
|
2311
|
+
return "\u{1F7E0} OPEN";
|
|
2312
|
+
}
|
|
2313
|
+
function snapshotLabel(v) {
|
|
2314
|
+
return v == null ? "Unpinned" : `Snapshot v${v}`;
|
|
2315
|
+
}
|
|
2316
|
+
function orderThreads(threads) {
|
|
2317
|
+
return [...threads].sort((a, b) => {
|
|
2318
|
+
if (a.status !== b.status) return a.status === "open" ? -1 : 1;
|
|
2319
|
+
return a.createdAt.localeCompare(b.createdAt);
|
|
2167
2320
|
});
|
|
2168
|
-
}
|
|
2169
|
-
|
|
2170
|
-
const
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
AND resolved_by IS NOT NULL
|
|
2182
|
-
ORDER BY resolved_at DESC`
|
|
2183
|
-
).all(nestId, nodeId);
|
|
2184
|
-
const byVersion = /* @__PURE__ */ new Map();
|
|
2185
|
-
for (const r of resolutions) {
|
|
2186
|
-
if (!byVersion.has(r.version)) {
|
|
2187
|
-
byVersion.set(r.version, { status: r.status, resolvedBy: r.resolved_by });
|
|
2188
|
-
}
|
|
2321
|
+
}
|
|
2322
|
+
function projectThreadsToMarkdown(artifactTitle, threads) {
|
|
2323
|
+
const lines = [];
|
|
2324
|
+
lines.push(`# ${artifactTitle} \u2014 Annotations`);
|
|
2325
|
+
lines.push("");
|
|
2326
|
+
lines.push(
|
|
2327
|
+
"> Auto-generated from review comments, grouped by the artifact snapshot each was anchored to. **Open** threads are unresolved feedback for the next iteration; **resolved** threads were already addressed (don't re-break them)."
|
|
2328
|
+
);
|
|
2329
|
+
lines.push("");
|
|
2330
|
+
if (threads.length === 0) {
|
|
2331
|
+
lines.push("_No annotations yet._");
|
|
2332
|
+
lines.push("");
|
|
2333
|
+
return lines.join("\n");
|
|
2189
2334
|
}
|
|
2190
|
-
const
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
});
|
|
2200
|
-
nodeRoutes.get("/:nodeId{.+?}/reviews", async (c) => {
|
|
2201
|
-
const nestId = c.req.param("nestId");
|
|
2202
|
-
const nodeId = c.req.param("nodeId");
|
|
2203
|
-
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-XNXHF6Q7.js");
|
|
2204
|
-
const history = getReviewHistory2(nestId, nodeId);
|
|
2205
|
-
return c.json({ reviews: history });
|
|
2206
|
-
});
|
|
2207
|
-
nodeRoutes.post("/:nodeId{.+}/revert", async (c) => {
|
|
2208
|
-
const nestId = c.req.param("nestId");
|
|
2209
|
-
const nodeId = c.req.param("nodeId");
|
|
2210
|
-
const { versions: versionManager } = engineCache.get(nestId);
|
|
2211
|
-
const userId = c.get("userId");
|
|
2212
|
-
const userEmail = resolveCallerEmail(userId);
|
|
2213
|
-
if (!canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
2214
|
-
return c.json(
|
|
2215
|
-
{ error: "Access denied \u2014 no steward assignment for this node" },
|
|
2216
|
-
403
|
|
2217
|
-
);
|
|
2218
|
-
}
|
|
2219
|
-
const body = await c.req.json().catch(() => ({}));
|
|
2220
|
-
const targetVersion = Number(body.targetVersion);
|
|
2221
|
-
if (!Number.isInteger(targetVersion) || targetVersion < 1) {
|
|
2222
|
-
throw new ValidationError("targetVersion (a positive integer) is required");
|
|
2223
|
-
}
|
|
2224
|
-
let raw;
|
|
2225
|
-
try {
|
|
2226
|
-
raw = await versionManager.reconstructVersion(nodeId, targetVersion);
|
|
2227
|
-
} catch {
|
|
2228
|
-
throw new NotFoundError(
|
|
2229
|
-
`Version ${targetVersion} not found for ${nodeId}`
|
|
2230
|
-
);
|
|
2231
|
-
}
|
|
2232
|
-
const content = bodyOnly(nodeId, raw);
|
|
2233
|
-
const { node, version } = await updateNode(
|
|
2234
|
-
nestId,
|
|
2235
|
-
nodeId,
|
|
2236
|
-
{ content, changeNote: `Restored from version ${targetVersion}` },
|
|
2237
|
-
userEmail
|
|
2238
|
-
);
|
|
2239
|
-
trackEvent("node.revert", { nestId, nodeId, targetVersion });
|
|
2240
|
-
return c.json({ ok: true, version, node: toNodeResponse(node) });
|
|
2241
|
-
});
|
|
2242
|
-
nodeRoutes.get("/:nodeId{.+}", async (c) => {
|
|
2243
|
-
const nestId = c.req.param("nestId");
|
|
2244
|
-
const nodeId = c.req.param("nodeId");
|
|
2245
|
-
const { storage, versions: versionManager } = engineCache.get(nestId);
|
|
2246
|
-
const userId = c.get("userId");
|
|
2247
|
-
const userEmail = resolveCallerEmail(userId);
|
|
2248
|
-
if (!canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
2249
|
-
return c.json(
|
|
2250
|
-
{ error: "Access denied \u2014 no steward assignment for this node" },
|
|
2251
|
-
403
|
|
2252
|
-
);
|
|
2253
|
-
}
|
|
2254
|
-
let node;
|
|
2255
|
-
try {
|
|
2256
|
-
node = await storage.readDocument(nodeId, { verifyChecksum: true });
|
|
2257
|
-
} catch {
|
|
2258
|
-
throw new NotFoundError(`Node not found: ${nodeId}`);
|
|
2259
|
-
}
|
|
2260
|
-
if (node.pendingChange) {
|
|
2261
|
-
try {
|
|
2262
|
-
await scanDocumentForDrift(nestId, nodeId, userEmail || "system:read");
|
|
2263
|
-
const refreshed = await getPendingChange(nestId, nodeId);
|
|
2264
|
-
if (refreshed) node.pendingChange = refreshed;
|
|
2265
|
-
} catch (err) {
|
|
2266
|
-
console.error("[external-edit] stage-on-read failed:", err);
|
|
2267
|
-
}
|
|
2268
|
-
try {
|
|
2269
|
-
const latest = await loadLatestApprovedNode(nestId, nodeId);
|
|
2270
|
-
if (latest) {
|
|
2271
|
-
node = { ...latest, pendingChange: node.pendingChange };
|
|
2272
|
-
}
|
|
2273
|
-
} catch (err) {
|
|
2274
|
-
console.error("[external-edit] reconstruct-latest failed:", err);
|
|
2275
|
-
}
|
|
2276
|
-
}
|
|
2277
|
-
const response = toNodeResponse(node);
|
|
2278
|
-
if (isPublicReader(nestId, userId)) {
|
|
2279
|
-
const approved = getApprovedVersion(nestId, nodeId);
|
|
2280
|
-
if (approved != null) {
|
|
2281
|
-
try {
|
|
2282
|
-
const raw = await versionManager.reconstructVersion(
|
|
2283
|
-
nodeId,
|
|
2284
|
-
approved
|
|
2285
|
-
);
|
|
2286
|
-
response.content = bodyOnly(nodeId, raw);
|
|
2287
|
-
} catch (err) {
|
|
2288
|
-
console.error(
|
|
2289
|
-
"reconstructVersion failed (public single)",
|
|
2290
|
-
nodeId,
|
|
2291
|
-
approved,
|
|
2292
|
-
err
|
|
2293
|
-
);
|
|
2294
|
-
response.content = "";
|
|
2295
|
-
}
|
|
2296
|
-
response.version = approved;
|
|
2297
|
-
response.status = "published";
|
|
2298
|
-
if (isMarkdownFormat(c)) {
|
|
2299
|
-
return c.body(nodeAsMarkdown(response, nodeId), 200, {
|
|
2300
|
-
"Content-Type": "text/markdown; charset=utf-8"
|
|
2301
|
-
});
|
|
2302
|
-
}
|
|
2303
|
-
return c.json({ node: response });
|
|
2304
|
-
}
|
|
2305
|
-
}
|
|
2306
|
-
response.status = node.pendingChange ? "external_edit_pending" : getDisplayStatus(nestId, nodeId);
|
|
2307
|
-
if (response.status === "pending_review") {
|
|
2308
|
-
const pending = getPendingReview(nestId, nodeId);
|
|
2309
|
-
response.pendingReviewBy = pending?.requestedBy ?? null;
|
|
2310
|
-
}
|
|
2311
|
-
if (isMarkdownFormat(c)) {
|
|
2312
|
-
return c.body(nodeAsMarkdown(response, nodeId), 200, {
|
|
2313
|
-
"Content-Type": "text/markdown; charset=utf-8"
|
|
2314
|
-
});
|
|
2315
|
-
}
|
|
2316
|
-
return c.json({ node: response });
|
|
2317
|
-
});
|
|
2318
|
-
nodeRoutes.patch("/:nodeId{.+}", async (c) => {
|
|
2319
|
-
const nestId = c.req.param("nestId");
|
|
2320
|
-
const nodeId = c.req.param("nodeId");
|
|
2321
|
-
const body = await c.req.json();
|
|
2322
|
-
const baseVersionHeader = c.req.header("X-Base-Version");
|
|
2323
|
-
if (baseVersionHeader) {
|
|
2324
|
-
const baseVersion = parseInt(baseVersionHeader, 10);
|
|
2325
|
-
const conflict = checkConflict(nestId, nodeId, baseVersion);
|
|
2326
|
-
if (conflict.conflict) {
|
|
2327
|
-
return c.json(
|
|
2328
|
-
{
|
|
2329
|
-
error: "Version conflict",
|
|
2330
|
-
your_version: baseVersion,
|
|
2331
|
-
current_version: conflict.currentVersion,
|
|
2332
|
-
updated_by: conflict.updatedBy,
|
|
2333
|
-
updated_at: conflict.updatedAt,
|
|
2334
|
-
rejected_content: body.content || body.append || null
|
|
2335
|
-
},
|
|
2336
|
-
409
|
|
2337
|
-
);
|
|
2338
|
-
}
|
|
2339
|
-
}
|
|
2340
|
-
const authorEmail = getUserEmail(c);
|
|
2341
|
-
const { node, version: responseVersion } = await updateNode(
|
|
2342
|
-
nestId,
|
|
2343
|
-
nodeId,
|
|
2344
|
-
{
|
|
2345
|
-
content: body.content,
|
|
2346
|
-
append: body.append,
|
|
2347
|
-
tags: body.tags,
|
|
2348
|
-
title: body.title,
|
|
2349
|
-
status: body.status,
|
|
2350
|
-
changeNote: body.changeNote
|
|
2351
|
-
},
|
|
2352
|
-
authorEmail
|
|
2353
|
-
);
|
|
2354
|
-
return c.json({ node: toNodeResponse(node), version: responseVersion });
|
|
2355
|
-
});
|
|
2356
|
-
nodeRoutes.delete("/:nodeId{.+}", async (c) => {
|
|
2357
|
-
const nestId = c.req.param("nestId");
|
|
2358
|
-
const nodeId = c.req.param("nodeId");
|
|
2359
|
-
const { storage } = engineCache.get(nestId);
|
|
2360
|
-
if (isStewardshipEnabled(nestId) && getPendingReview(nestId, nodeId)) {
|
|
2361
|
-
throw new LockedError(
|
|
2362
|
-
"This document is awaiting steward review and is locked. Approve or reject the pending review before deleting."
|
|
2363
|
-
);
|
|
2364
|
-
}
|
|
2365
|
-
try {
|
|
2366
|
-
await storage.deleteDocument(nodeId);
|
|
2367
|
-
} catch {
|
|
2368
|
-
throw new NotFoundError(`Node not found: ${nodeId}`);
|
|
2369
|
-
}
|
|
2370
|
-
removeNodeFromTagIndex(nestId, nodeId);
|
|
2371
|
-
const db = getDb();
|
|
2372
|
-
db.transaction(() => {
|
|
2373
|
-
db.prepare(
|
|
2374
|
-
"DELETE FROM node_versions WHERE nest_id = ? AND node_id = ?"
|
|
2375
|
-
).run(nestId, nodeId);
|
|
2376
|
-
db.prepare(
|
|
2377
|
-
"DELETE FROM review_requests WHERE nest_id = ? AND node_id = ?"
|
|
2378
|
-
).run(nestId, nodeId);
|
|
2379
|
-
db.prepare(
|
|
2380
|
-
"DELETE FROM approved_versions WHERE nest_id = ? AND node_id = ?"
|
|
2381
|
-
).run(nestId, nodeId);
|
|
2382
|
-
db.prepare(
|
|
2383
|
-
`DELETE FROM stewards
|
|
2384
|
-
WHERE nest_id = ? AND scope = 'document' AND node_pattern = ?`
|
|
2385
|
-
).run(nestId, nodeId);
|
|
2386
|
-
})();
|
|
2387
|
-
trackEvent("node.delete", { nestId, nodeId });
|
|
2388
|
-
return c.json({ deleted: true });
|
|
2389
|
-
});
|
|
2390
|
-
function getUserEmail(c) {
|
|
2391
|
-
const userId = c.get("userId");
|
|
2392
|
-
const db = getDb();
|
|
2393
|
-
const user = db.prepare("SELECT email FROM users WHERE id = ?").get(userId);
|
|
2394
|
-
return user?.email || "anonymous@localhost";
|
|
2395
|
-
}
|
|
2396
|
-
|
|
2397
|
-
// src/annotations/routes.ts
|
|
2398
|
-
import { Hono as Hono5 } from "hono";
|
|
2399
|
-
|
|
2400
|
-
// src/annotations/service.ts
|
|
2401
|
-
import { v4 as uuid3 } from "uuid";
|
|
2402
|
-
|
|
2403
|
-
// src/annotations/projection.ts
|
|
2404
|
-
var MAX_CONTEXT_CHARS = 250;
|
|
2405
|
-
var MAX_QUOTE_CHARS = 1e3;
|
|
2406
|
-
function clampAnchor(raw) {
|
|
2407
|
-
if (!raw) return null;
|
|
2408
|
-
const quote = (raw.quote ?? "").trim().slice(0, MAX_QUOTE_CHARS);
|
|
2409
|
-
if (!quote) return null;
|
|
2410
|
-
const before = (raw.before ?? "").slice(-MAX_CONTEXT_CHARS);
|
|
2411
|
-
const after = (raw.after ?? "").slice(0, MAX_CONTEXT_CHARS);
|
|
2412
|
-
const line = typeof raw.line === "number" && Number.isFinite(raw.line) && raw.line > 0 ? Math.floor(raw.line) : void 0;
|
|
2413
|
-
return { quote, before, after, ...line !== void 0 ? { line } : {} };
|
|
2414
|
-
}
|
|
2415
|
-
function oneLine(s) {
|
|
2416
|
-
return s.replace(/\s+/g, " ").replace(/--+>/g, "-\u2192").trim();
|
|
2417
|
-
}
|
|
2418
|
-
function renderAnchor(anchor) {
|
|
2419
|
-
if (!anchor) {
|
|
2420
|
-
return "<!-- anchor: whole-artifact -->";
|
|
2421
|
-
}
|
|
2422
|
-
const linePart = anchor.line !== void 0 ? `line ${anchor.line} \xB7 ` : "";
|
|
2423
|
-
const context = `\u2026${oneLine(anchor.before)}\u3008${oneLine(anchor.quote)}\u3009${oneLine(
|
|
2424
|
-
anchor.after
|
|
2425
|
-
)}\u2026`;
|
|
2426
|
-
return `<!-- anchor: ${linePart}quote "${oneLine(anchor.quote)}" \xB7 context ${context} -->`;
|
|
2427
|
-
}
|
|
2428
|
-
function statusHeading(thread) {
|
|
2429
|
-
if (thread.status === "resolved") {
|
|
2430
|
-
const who = thread.resolvedBy ? `${thread.resolvedBy}, ` : "";
|
|
2431
|
-
const when = thread.resolvedAt ? `${thread.resolvedAt}` : "";
|
|
2432
|
-
const meta = who || when ? ` (${who}${when})` : "";
|
|
2433
|
-
return `\u2705 RESOLVED${meta}`;
|
|
2434
|
-
}
|
|
2435
|
-
return "\u{1F7E0} OPEN";
|
|
2436
|
-
}
|
|
2437
|
-
function snapshotLabel(v) {
|
|
2438
|
-
return v == null ? "Unpinned" : `Snapshot v${v}`;
|
|
2439
|
-
}
|
|
2440
|
-
function orderThreads(threads) {
|
|
2441
|
-
return [...threads].sort((a, b) => {
|
|
2442
|
-
if (a.status !== b.status) return a.status === "open" ? -1 : 1;
|
|
2443
|
-
return a.createdAt.localeCompare(b.createdAt);
|
|
2444
|
-
});
|
|
2445
|
-
}
|
|
2446
|
-
function projectThreadsToMarkdown(artifactTitle, threads) {
|
|
2447
|
-
const lines = [];
|
|
2448
|
-
lines.push(`# ${artifactTitle} \u2014 Annotations`);
|
|
2449
|
-
lines.push("");
|
|
2450
|
-
lines.push(
|
|
2451
|
-
"> Auto-generated from review comments, grouped by the artifact snapshot each was anchored to. **Open** threads are unresolved feedback for the next iteration; **resolved** threads were already addressed (don't re-break them)."
|
|
2452
|
-
);
|
|
2453
|
-
lines.push("");
|
|
2454
|
-
if (threads.length === 0) {
|
|
2455
|
-
lines.push("_No annotations yet._");
|
|
2456
|
-
lines.push("");
|
|
2457
|
-
return lines.join("\n");
|
|
2458
|
-
}
|
|
2459
|
-
const buckets2 = /* @__PURE__ */ new Map();
|
|
2460
|
-
for (const t of threads) {
|
|
2461
|
-
const key = t.snapshotVersion ?? null;
|
|
2462
|
-
(buckets2.get(key) ?? buckets2.set(key, []).get(key)).push(t);
|
|
2463
|
-
}
|
|
2464
|
-
const keys = [...buckets2.keys()].sort((a, b) => {
|
|
2465
|
-
if (a == null) return 1;
|
|
2466
|
-
if (b == null) return -1;
|
|
2467
|
-
return b - a;
|
|
2335
|
+
const buckets2 = /* @__PURE__ */ new Map();
|
|
2336
|
+
for (const t of threads) {
|
|
2337
|
+
const key = t.snapshotVersion ?? null;
|
|
2338
|
+
(buckets2.get(key) ?? buckets2.set(key, []).get(key)).push(t);
|
|
2339
|
+
}
|
|
2340
|
+
const keys = [...buckets2.keys()].sort((a, b) => {
|
|
2341
|
+
if (a == null) return 1;
|
|
2342
|
+
if (b == null) return -1;
|
|
2343
|
+
return b - a;
|
|
2468
2344
|
});
|
|
2469
2345
|
for (const key of keys) {
|
|
2470
2346
|
lines.push(`## ${snapshotLabel(key)}`);
|
|
@@ -2484,11 +2360,12 @@ function projectThreadsToMarkdown(artifactTitle, threads) {
|
|
|
2484
2360
|
}
|
|
2485
2361
|
|
|
2486
2362
|
// src/annotations/service.ts
|
|
2487
|
-
function loadComments(threadId) {
|
|
2363
|
+
async function loadComments(threadId) {
|
|
2488
2364
|
const db = getDb();
|
|
2489
|
-
const rows = db.
|
|
2490
|
-
"SELECT id, author, body, created_at FROM annotation_comments WHERE thread_id = ? ORDER BY created_at ASC,
|
|
2491
|
-
|
|
2365
|
+
const rows = await db.all(
|
|
2366
|
+
"SELECT id, author, body, created_at FROM annotation_comments WHERE thread_id = ? ORDER BY created_at ASC, id ASC",
|
|
2367
|
+
[threadId]
|
|
2368
|
+
);
|
|
2492
2369
|
return rows.map((r) => ({
|
|
2493
2370
|
id: r.id,
|
|
2494
2371
|
author: r.author,
|
|
@@ -2496,7 +2373,7 @@ function loadComments(threadId) {
|
|
|
2496
2373
|
createdAt: r.created_at
|
|
2497
2374
|
}));
|
|
2498
2375
|
}
|
|
2499
|
-
function rowToThread(row) {
|
|
2376
|
+
async function rowToThread(row) {
|
|
2500
2377
|
let anchor = null;
|
|
2501
2378
|
if (row.anchor_json) {
|
|
2502
2379
|
try {
|
|
@@ -2516,19 +2393,32 @@ function rowToThread(row) {
|
|
|
2516
2393
|
createdAt: row.created_at,
|
|
2517
2394
|
resolvedBy: row.resolved_by,
|
|
2518
2395
|
resolvedAt: row.resolved_at,
|
|
2519
|
-
comments: loadComments(row.id)
|
|
2396
|
+
comments: await loadComments(row.id)
|
|
2520
2397
|
};
|
|
2521
2398
|
}
|
|
2522
|
-
function getThreadRow(threadId) {
|
|
2523
|
-
return getDb().
|
|
2399
|
+
async function getThreadRow(threadId) {
|
|
2400
|
+
return await getDb().get("SELECT * FROM annotation_threads WHERE id = ?", [threadId]);
|
|
2401
|
+
}
|
|
2402
|
+
async function countThreadsByNode(nestId) {
|
|
2403
|
+
const rows = await getDb().all(
|
|
2404
|
+
`SELECT node_id, CAST(COUNT(*) AS INTEGER) AS count, MAX(created_at) AS last_at
|
|
2405
|
+
FROM annotation_threads
|
|
2406
|
+
WHERE nest_id = ?
|
|
2407
|
+
GROUP BY node_id`,
|
|
2408
|
+
[nestId]
|
|
2409
|
+
);
|
|
2410
|
+
const out = {};
|
|
2411
|
+
for (const r of rows) out[r.node_id] = { count: r.count, lastAt: r.last_at };
|
|
2412
|
+
return out;
|
|
2524
2413
|
}
|
|
2525
|
-
function listThreads(nestId, nodeId) {
|
|
2526
|
-
const rows = getDb().
|
|
2527
|
-
"SELECT * FROM annotation_threads WHERE nest_id = ? AND node_id = ? ORDER BY created_at ASC,
|
|
2528
|
-
|
|
2529
|
-
|
|
2414
|
+
async function listThreads(nestId, nodeId) {
|
|
2415
|
+
const rows = await getDb().all(
|
|
2416
|
+
"SELECT * FROM annotation_threads WHERE nest_id = ? AND node_id = ? ORDER BY created_at ASC, id ASC",
|
|
2417
|
+
[nestId, nodeId]
|
|
2418
|
+
);
|
|
2419
|
+
return Promise.all(rows.map(rowToThread));
|
|
2530
2420
|
}
|
|
2531
|
-
function createThread(nestId, nodeId, input, authorEmail) {
|
|
2421
|
+
async function createThread(nestId, nodeId, input, authorEmail) {
|
|
2532
2422
|
const body = (input.body ?? "").trim();
|
|
2533
2423
|
if (!body) {
|
|
2534
2424
|
throw new Error("comment body is required");
|
|
@@ -2536,61 +2426,66 @@ function createThread(nestId, nodeId, input, authorEmail) {
|
|
|
2536
2426
|
const db = getDb();
|
|
2537
2427
|
const id = uuid3();
|
|
2538
2428
|
const anchor = clampAnchor(input.anchor);
|
|
2539
|
-
const snapshot = input.snapshotVersion ?? getApprovedVersion(nestId, nodeId) ?? null;
|
|
2540
|
-
|
|
2541
|
-
|
|
2429
|
+
const snapshot = input.snapshotVersion ?? await getApprovedVersion(nestId, nodeId) ?? null;
|
|
2430
|
+
await db.transaction(async (tx) => {
|
|
2431
|
+
await tx.run(
|
|
2542
2432
|
`INSERT INTO annotation_threads
|
|
2543
2433
|
(id, nest_id, node_id, snapshot_version, anchor_json, status, created_by)
|
|
2544
|
-
VALUES (?, ?, ?, ?, ?, 'open', ?)
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2434
|
+
VALUES (?, ?, ?, ?, ?, 'open', ?)`,
|
|
2435
|
+
[
|
|
2436
|
+
id,
|
|
2437
|
+
nestId,
|
|
2438
|
+
nodeId,
|
|
2439
|
+
snapshot,
|
|
2440
|
+
anchor ? JSON.stringify(anchor) : null,
|
|
2441
|
+
authorEmail
|
|
2442
|
+
]
|
|
2443
|
+
);
|
|
2444
|
+
await tx.run(
|
|
2445
|
+
"INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
|
|
2446
|
+
[uuid3(), id, authorEmail, body]
|
|
2552
2447
|
);
|
|
2553
|
-
db.prepare(
|
|
2554
|
-
"INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)"
|
|
2555
|
-
).run(uuid3(), id, authorEmail, body);
|
|
2556
2448
|
});
|
|
2557
|
-
|
|
2558
|
-
return rowToThread(getThreadRow(id));
|
|
2449
|
+
return rowToThread(await getThreadRow(id));
|
|
2559
2450
|
}
|
|
2560
|
-
function getScopedThreadRow(threadId, nestId, nodeId) {
|
|
2561
|
-
const row = getThreadRow(threadId);
|
|
2451
|
+
async function getScopedThreadRow(threadId, nestId, nodeId) {
|
|
2452
|
+
const row = await getThreadRow(threadId);
|
|
2562
2453
|
if (!row || row.nest_id !== nestId || row.node_id !== nodeId) {
|
|
2563
2454
|
throw new NotFoundError(`Thread not found: ${threadId}`);
|
|
2564
2455
|
}
|
|
2565
2456
|
return row;
|
|
2566
2457
|
}
|
|
2567
|
-
function addComment(nestId, nodeId, threadId, authorEmail, body) {
|
|
2458
|
+
async function addComment(nestId, nodeId, threadId, authorEmail, body) {
|
|
2568
2459
|
const trimmed = (body ?? "").trim();
|
|
2569
2460
|
if (!trimmed) {
|
|
2570
2461
|
throw new Error("comment body is required");
|
|
2571
2462
|
}
|
|
2572
|
-
getScopedThreadRow(threadId, nestId, nodeId);
|
|
2573
|
-
getDb().
|
|
2574
|
-
"INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)"
|
|
2575
|
-
|
|
2576
|
-
|
|
2463
|
+
await getScopedThreadRow(threadId, nestId, nodeId);
|
|
2464
|
+
await getDb().run(
|
|
2465
|
+
"INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
|
|
2466
|
+
[uuid3(), threadId, authorEmail, trimmed]
|
|
2467
|
+
);
|
|
2468
|
+
return rowToThread(await getThreadRow(threadId));
|
|
2577
2469
|
}
|
|
2578
|
-
function setThreadStatus(nestId, nodeId, threadId, status, byEmail) {
|
|
2579
|
-
getScopedThreadRow(threadId, nestId, nodeId);
|
|
2470
|
+
async function setThreadStatus(nestId, nodeId, threadId, status, byEmail) {
|
|
2471
|
+
await getScopedThreadRow(threadId, nestId, nodeId);
|
|
2472
|
+
const db = getDb();
|
|
2580
2473
|
if (status === "resolved") {
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2474
|
+
await db.run(
|
|
2475
|
+
`UPDATE annotation_threads SET status = 'resolved', resolved_by = ?, resolved_at = ${nowExpr(db)} WHERE id = ?`,
|
|
2476
|
+
[byEmail, threadId]
|
|
2477
|
+
);
|
|
2584
2478
|
} else {
|
|
2585
|
-
|
|
2586
|
-
"UPDATE annotation_threads SET status = 'open', resolved_by = NULL, resolved_at = NULL WHERE id = ?"
|
|
2587
|
-
|
|
2479
|
+
await db.run(
|
|
2480
|
+
"UPDATE annotation_threads SET status = 'open', resolved_by = NULL, resolved_at = NULL WHERE id = ?",
|
|
2481
|
+
[threadId]
|
|
2482
|
+
);
|
|
2588
2483
|
}
|
|
2589
|
-
return rowToThread(getThreadRow(threadId));
|
|
2484
|
+
return rowToThread(await getThreadRow(threadId));
|
|
2590
2485
|
}
|
|
2591
2486
|
async function readArtifact(nestId, nodeId, version) {
|
|
2592
|
-
const { storage, versions: versionManager } = engineCache.get(nestId);
|
|
2593
|
-
const target = version ?? getApprovedVersion(nestId, nodeId) ?? null;
|
|
2487
|
+
const { storage, versions: versionManager } = await engineCache.get(nestId);
|
|
2488
|
+
const target = version ?? await getApprovedVersion(nestId, nodeId) ?? null;
|
|
2594
2489
|
let title = nodeId;
|
|
2595
2490
|
let type = null;
|
|
2596
2491
|
let liveHtml = "";
|
|
@@ -2617,52 +2512,388 @@ async function readArtifact(nestId, nodeId, version) {
|
|
|
2617
2512
|
if (!liveExists) {
|
|
2618
2513
|
throw new NotFoundError(`Artifact not found: ${nodeId}`);
|
|
2619
2514
|
}
|
|
2620
|
-
return { title, html: liveHtml, version: target, type };
|
|
2621
|
-
}
|
|
2622
|
-
function derivedAnnotationsId(sourceNodeId) {
|
|
2623
|
-
const base = sourceNodeId.replace(/^nodes\//, "");
|
|
2624
|
-
return `nodes/${base}--annotations`;
|
|
2625
|
-
}
|
|
2626
|
-
async function syncAnnotationsNode(nestId, nodeId, userEmail) {
|
|
2515
|
+
return { title, html: liveHtml, version: target, type };
|
|
2516
|
+
}
|
|
2517
|
+
function derivedAnnotationsId(sourceNodeId) {
|
|
2518
|
+
const base = sourceNodeId.replace(/^nodes\//, "");
|
|
2519
|
+
return `nodes/${base}--annotations`;
|
|
2520
|
+
}
|
|
2521
|
+
async function syncAnnotationsNode(nestId, nodeId, userEmail) {
|
|
2522
|
+
try {
|
|
2523
|
+
let title = nodeId;
|
|
2524
|
+
let type = null;
|
|
2525
|
+
try {
|
|
2526
|
+
const { storage } = await engineCache.get(nestId);
|
|
2527
|
+
const node = await storage.readDocument(nodeId);
|
|
2528
|
+
title = node.frontmatter?.title || nodeId;
|
|
2529
|
+
type = node.frontmatter?.type || "document";
|
|
2530
|
+
} catch {
|
|
2531
|
+
}
|
|
2532
|
+
if (type !== null && type !== "artifact") return;
|
|
2533
|
+
const threads = await listThreads(nestId, nodeId);
|
|
2534
|
+
const derivedTitle = `${title} \u2014 Annotations`;
|
|
2535
|
+
const markdown = projectThreadsToMarkdown(title, threads);
|
|
2536
|
+
const derivedId = derivedAnnotationsId(nodeId);
|
|
2537
|
+
try {
|
|
2538
|
+
await updateNode(nestId, derivedId, { content: markdown }, userEmail);
|
|
2539
|
+
} catch (err) {
|
|
2540
|
+
if (err instanceof NotFoundError) {
|
|
2541
|
+
await createNode(
|
|
2542
|
+
nestId,
|
|
2543
|
+
{
|
|
2544
|
+
id: derivedId,
|
|
2545
|
+
title: derivedTitle,
|
|
2546
|
+
content: markdown,
|
|
2547
|
+
type: "document",
|
|
2548
|
+
tags: ["annotations"]
|
|
2549
|
+
},
|
|
2550
|
+
userEmail
|
|
2551
|
+
);
|
|
2552
|
+
} else {
|
|
2553
|
+
throw err;
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
2556
|
+
} catch (err) {
|
|
2557
|
+
console.warn(
|
|
2558
|
+
`[annotations] failed to sync derived node for ${nestId}/${nodeId}:`,
|
|
2559
|
+
err
|
|
2560
|
+
);
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
// src/nodes/routes.ts
|
|
2565
|
+
var nodeRoutes = new Hono4();
|
|
2566
|
+
function nodeAsMarkdown(response, nodeId) {
|
|
2567
|
+
return nodeToMarkdown({
|
|
2568
|
+
id: nodeId,
|
|
2569
|
+
title: response.title,
|
|
2570
|
+
tags: response.tags,
|
|
2571
|
+
status: response.status,
|
|
2572
|
+
body: response.content
|
|
2573
|
+
});
|
|
2574
|
+
}
|
|
2575
|
+
nodeRoutes.get("/", async (c) => {
|
|
2576
|
+
const nestId = c.req.param("nestId");
|
|
2577
|
+
const userId = c.get("userId");
|
|
2578
|
+
const nodes = await listNodesForCaller(nestId, userId);
|
|
2579
|
+
return c.json({ count: nodes.length, nodes });
|
|
2580
|
+
});
|
|
2581
|
+
nodeRoutes.post("/", async (c) => {
|
|
2582
|
+
const body = await c.req.json();
|
|
2583
|
+
if (!body.title || !body.content) {
|
|
2584
|
+
throw new ValidationError("title and content are required");
|
|
2585
|
+
}
|
|
2586
|
+
if (body.folder !== void 0 && typeof body.folder !== "string") {
|
|
2587
|
+
throw new ValidationError('folder must be a string path like "gtm/deals"');
|
|
2588
|
+
}
|
|
2589
|
+
const nestId = c.req.param("nestId");
|
|
2590
|
+
const authorEmail = await getUserEmail(c);
|
|
2591
|
+
const { node } = await createNode(
|
|
2592
|
+
nestId,
|
|
2593
|
+
{
|
|
2594
|
+
title: body.title,
|
|
2595
|
+
content: body.content,
|
|
2596
|
+
type: body.type,
|
|
2597
|
+
tags: body.tags,
|
|
2598
|
+
scope: body.scope,
|
|
2599
|
+
status: body.status,
|
|
2600
|
+
folder: body.folder
|
|
2601
|
+
},
|
|
2602
|
+
authorEmail
|
|
2603
|
+
);
|
|
2604
|
+
const resolved = await resolveStewardsForNode(nestId, node.id);
|
|
2605
|
+
return c.json({
|
|
2606
|
+
node: toNodeResponse(node),
|
|
2607
|
+
stewards: resolved.length > 0 ? resolved.map((r) => ({
|
|
2608
|
+
email: r.steward.userEmail,
|
|
2609
|
+
role: r.steward.role,
|
|
2610
|
+
source: r.source
|
|
2611
|
+
})) : void 0
|
|
2612
|
+
}, 201);
|
|
2613
|
+
});
|
|
2614
|
+
nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
|
|
2615
|
+
const nestId = c.req.param("nestId");
|
|
2616
|
+
const nodeId = c.req.param("nodeId");
|
|
2617
|
+
const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-I2JAFYJU.js");
|
|
2618
|
+
const { stewards, fallbackToOwner, ownerEmail } = await resolveStewardsWithFallback2(
|
|
2619
|
+
nestId,
|
|
2620
|
+
nodeId
|
|
2621
|
+
);
|
|
2622
|
+
return c.json({
|
|
2623
|
+
nodeId,
|
|
2624
|
+
stewards: stewards.map((r) => ({
|
|
2625
|
+
email: r.steward.userEmail,
|
|
2626
|
+
role: r.steward.role,
|
|
2627
|
+
scope: r.steward.scope,
|
|
2628
|
+
source: r.source,
|
|
2629
|
+
priority: r.priority
|
|
2630
|
+
})),
|
|
2631
|
+
fallbackToOwner,
|
|
2632
|
+
ownerEmail
|
|
2633
|
+
});
|
|
2634
|
+
});
|
|
2635
|
+
nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
|
|
2636
|
+
const nestId = c.req.param("nestId");
|
|
2637
|
+
const nodeId = c.req.param("nodeId");
|
|
2638
|
+
const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-FJWVTZKR.js");
|
|
2639
|
+
const allVersions = await getVersions2(nestId, nodeId);
|
|
2640
|
+
const approved = await getApprovedVersion2(nestId, nodeId);
|
|
2641
|
+
const db = getDb();
|
|
2642
|
+
const resolutions = await db.all(
|
|
2643
|
+
`SELECT version, status, resolved_by, resolved_at
|
|
2644
|
+
FROM review_requests
|
|
2645
|
+
WHERE nest_id = ? AND node_id = ?
|
|
2646
|
+
AND status IN ('approved', 'rejected')
|
|
2647
|
+
AND resolved_by IS NOT NULL
|
|
2648
|
+
ORDER BY resolved_at DESC`,
|
|
2649
|
+
[nestId, nodeId]
|
|
2650
|
+
);
|
|
2651
|
+
const byVersion = /* @__PURE__ */ new Map();
|
|
2652
|
+
for (const r of resolutions) {
|
|
2653
|
+
if (!byVersion.has(r.version)) {
|
|
2654
|
+
byVersion.set(r.version, { status: r.status, resolvedBy: r.resolved_by });
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
const enriched = allVersions.map((v) => {
|
|
2658
|
+
const r = byVersion.get(v.version);
|
|
2659
|
+
return r ? { ...v, resolvedBy: r.resolvedBy, resolutionStatus: r.status } : v;
|
|
2660
|
+
});
|
|
2661
|
+
return c.json({
|
|
2662
|
+
versions: enriched,
|
|
2663
|
+
approvedVersion: approved,
|
|
2664
|
+
currentVersion: allVersions[0]?.version || 0
|
|
2665
|
+
});
|
|
2666
|
+
});
|
|
2667
|
+
nodeRoutes.get("/:nodeId{.+?}/reviews", async (c) => {
|
|
2668
|
+
const nestId = c.req.param("nestId");
|
|
2669
|
+
const nodeId = c.req.param("nodeId");
|
|
2670
|
+
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-2FSKW425.js");
|
|
2671
|
+
const history = await getReviewHistory2(nestId, nodeId);
|
|
2672
|
+
return c.json({ reviews: history });
|
|
2673
|
+
});
|
|
2674
|
+
nodeRoutes.post("/:nodeId{.+}/revert", async (c) => {
|
|
2675
|
+
const nestId = c.req.param("nestId");
|
|
2676
|
+
const nodeId = c.req.param("nodeId");
|
|
2677
|
+
const { versions: versionManager } = await engineCache.get(nestId);
|
|
2678
|
+
const userId = c.get("userId");
|
|
2679
|
+
const userEmail = await resolveCallerEmail(userId);
|
|
2680
|
+
if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
2681
|
+
return c.json(
|
|
2682
|
+
{ error: "Access denied \u2014 no steward assignment for this node" },
|
|
2683
|
+
403
|
|
2684
|
+
);
|
|
2685
|
+
}
|
|
2686
|
+
const body = await c.req.json().catch(() => ({}));
|
|
2687
|
+
const targetVersion = Number(body.targetVersion);
|
|
2688
|
+
if (!Number.isInteger(targetVersion) || targetVersion < 1) {
|
|
2689
|
+
throw new ValidationError("targetVersion (a positive integer) is required");
|
|
2690
|
+
}
|
|
2691
|
+
let raw;
|
|
2692
|
+
try {
|
|
2693
|
+
raw = await versionManager.reconstructVersion(nodeId, targetVersion);
|
|
2694
|
+
} catch {
|
|
2695
|
+
throw new NotFoundError(
|
|
2696
|
+
`Version ${targetVersion} not found for ${nodeId}`
|
|
2697
|
+
);
|
|
2698
|
+
}
|
|
2699
|
+
const content = bodyOnly(nodeId, raw);
|
|
2700
|
+
const { node, version } = await updateNode(
|
|
2701
|
+
nestId,
|
|
2702
|
+
nodeId,
|
|
2703
|
+
{ content, changeNote: `Restored from version ${targetVersion}` },
|
|
2704
|
+
userEmail
|
|
2705
|
+
);
|
|
2706
|
+
await trackEvent("node.revert", { nestId, nodeId, targetVersion });
|
|
2707
|
+
return c.json({ ok: true, version, node: toNodeResponse(node) });
|
|
2708
|
+
});
|
|
2709
|
+
nodeRoutes.get("/:nodeId{.+}", async (c) => {
|
|
2710
|
+
const nestId = c.req.param("nestId");
|
|
2711
|
+
const nodeId = c.req.param("nodeId");
|
|
2712
|
+
const { storage, versions: versionManager } = await engineCache.get(nestId);
|
|
2713
|
+
const userId = c.get("userId");
|
|
2714
|
+
const userEmail = await resolveCallerEmail(userId);
|
|
2715
|
+
if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
2716
|
+
return c.json(
|
|
2717
|
+
{ error: "Access denied \u2014 no steward assignment for this node" },
|
|
2718
|
+
403
|
|
2719
|
+
);
|
|
2720
|
+
}
|
|
2721
|
+
let node;
|
|
2627
2722
|
try {
|
|
2628
|
-
|
|
2723
|
+
node = await storage.readDocument(nodeId, { verifyChecksum: true });
|
|
2724
|
+
} catch {
|
|
2725
|
+
throw new NotFoundError(`Node not found: ${nodeId}`);
|
|
2726
|
+
}
|
|
2727
|
+
if (node.pendingChange) {
|
|
2629
2728
|
try {
|
|
2630
|
-
|
|
2631
|
-
const
|
|
2632
|
-
|
|
2633
|
-
} catch {
|
|
2729
|
+
await scanDocumentForDrift(nestId, nodeId, userEmail || "system:read");
|
|
2730
|
+
const refreshed = await getPendingChange(nestId, nodeId);
|
|
2731
|
+
if (refreshed) node.pendingChange = refreshed;
|
|
2732
|
+
} catch (err) {
|
|
2733
|
+
console.error("[external-edit] stage-on-read failed:", err);
|
|
2634
2734
|
}
|
|
2635
|
-
const threads = listThreads(nestId, nodeId);
|
|
2636
|
-
const derivedTitle = `${title} \u2014 Annotations`;
|
|
2637
|
-
const markdown = projectThreadsToMarkdown(title, threads);
|
|
2638
|
-
const derivedId = derivedAnnotationsId(nodeId);
|
|
2639
2735
|
try {
|
|
2640
|
-
await
|
|
2736
|
+
const latest = await loadLatestApprovedNode(nestId, nodeId);
|
|
2737
|
+
if (latest) {
|
|
2738
|
+
node = { ...latest, pendingChange: node.pendingChange };
|
|
2739
|
+
}
|
|
2641
2740
|
} catch (err) {
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2741
|
+
console.error("[external-edit] reconstruct-latest failed:", err);
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
2744
|
+
const response = toNodeResponse(node);
|
|
2745
|
+
if (await isPublicReader(nestId, userId)) {
|
|
2746
|
+
const approved = await getApprovedVersion(nestId, nodeId);
|
|
2747
|
+
if (approved != null) {
|
|
2748
|
+
try {
|
|
2749
|
+
const raw = await versionManager.reconstructVersion(
|
|
2750
|
+
nodeId,
|
|
2751
|
+
approved
|
|
2653
2752
|
);
|
|
2654
|
-
|
|
2655
|
-
|
|
2753
|
+
response.content = bodyOnly(nodeId, raw);
|
|
2754
|
+
} catch (err) {
|
|
2755
|
+
console.error(
|
|
2756
|
+
"reconstructVersion failed (public single)",
|
|
2757
|
+
nodeId,
|
|
2758
|
+
approved,
|
|
2759
|
+
err
|
|
2760
|
+
);
|
|
2761
|
+
response.content = "";
|
|
2656
2762
|
}
|
|
2763
|
+
response.version = approved;
|
|
2764
|
+
response.status = "published";
|
|
2765
|
+
if (isMarkdownFormat(c)) {
|
|
2766
|
+
return c.body(nodeAsMarkdown(response, nodeId), 200, {
|
|
2767
|
+
"Content-Type": "text/markdown; charset=utf-8"
|
|
2768
|
+
});
|
|
2769
|
+
}
|
|
2770
|
+
return c.json({ node: response });
|
|
2657
2771
|
}
|
|
2658
|
-
}
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2772
|
+
}
|
|
2773
|
+
response.status = node.pendingChange ? "external_edit_pending" : await getDisplayStatus(nestId, nodeId);
|
|
2774
|
+
if (response.status === "pending_review") {
|
|
2775
|
+
const pending = await getPendingReview(nestId, nodeId);
|
|
2776
|
+
response.pendingReviewBy = pending?.requestedBy ?? null;
|
|
2777
|
+
}
|
|
2778
|
+
if (isMarkdownFormat(c)) {
|
|
2779
|
+
return c.body(nodeAsMarkdown(response, nodeId), 200, {
|
|
2780
|
+
"Content-Type": "text/markdown; charset=utf-8"
|
|
2781
|
+
});
|
|
2782
|
+
}
|
|
2783
|
+
return c.json({ node: response });
|
|
2784
|
+
});
|
|
2785
|
+
nodeRoutes.patch("/:nodeId{.+}", async (c) => {
|
|
2786
|
+
const nestId = c.req.param("nestId");
|
|
2787
|
+
const nodeId = c.req.param("nodeId");
|
|
2788
|
+
const body = await c.req.json();
|
|
2789
|
+
const baseVersionHeader = c.req.header("X-Base-Version");
|
|
2790
|
+
if (baseVersionHeader) {
|
|
2791
|
+
const baseVersion = parseInt(baseVersionHeader, 10);
|
|
2792
|
+
const conflict = await checkConflict(nestId, nodeId, baseVersion);
|
|
2793
|
+
if (conflict.conflict) {
|
|
2794
|
+
return c.json(
|
|
2795
|
+
{
|
|
2796
|
+
error: "Version conflict",
|
|
2797
|
+
your_version: baseVersion,
|
|
2798
|
+
current_version: conflict.currentVersion,
|
|
2799
|
+
updated_by: conflict.updatedBy,
|
|
2800
|
+
updated_at: conflict.updatedAt,
|
|
2801
|
+
rejected_content: body.content || body.append || null
|
|
2802
|
+
},
|
|
2803
|
+
409
|
|
2804
|
+
);
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
const authorEmail = await getUserEmail(c);
|
|
2808
|
+
const { node, version: responseVersion } = await updateNode(
|
|
2809
|
+
nestId,
|
|
2810
|
+
nodeId,
|
|
2811
|
+
{
|
|
2812
|
+
content: body.content,
|
|
2813
|
+
append: body.append,
|
|
2814
|
+
tags: body.tags,
|
|
2815
|
+
title: body.title,
|
|
2816
|
+
status: body.status,
|
|
2817
|
+
changeNote: body.changeNote
|
|
2818
|
+
},
|
|
2819
|
+
authorEmail
|
|
2820
|
+
);
|
|
2821
|
+
return c.json({ node: toNodeResponse(node), version: responseVersion });
|
|
2822
|
+
});
|
|
2823
|
+
nodeRoutes.delete("/:nodeId{.+}", async (c) => {
|
|
2824
|
+
const nestId = c.req.param("nestId");
|
|
2825
|
+
const nodeId = c.req.param("nodeId");
|
|
2826
|
+
const { storage } = await engineCache.get(nestId);
|
|
2827
|
+
if (await isStewardshipEnabled(nestId) && await getPendingReview(nestId, nodeId)) {
|
|
2828
|
+
throw new LockedError(
|
|
2829
|
+
"This document is awaiting steward review and is locked. Approve or reject the pending review before deleting."
|
|
2662
2830
|
);
|
|
2663
2831
|
}
|
|
2832
|
+
try {
|
|
2833
|
+
await storage.deleteDocument(nodeId);
|
|
2834
|
+
} catch {
|
|
2835
|
+
throw new NotFoundError(`Node not found: ${nodeId}`);
|
|
2836
|
+
}
|
|
2837
|
+
await removeNodeFromTagIndex(nestId, nodeId);
|
|
2838
|
+
const derivedId = derivedAnnotationsId(nodeId);
|
|
2839
|
+
try {
|
|
2840
|
+
await storage.deleteDocument(derivedId);
|
|
2841
|
+
await removeNodeFromTagIndex(nestId, derivedId);
|
|
2842
|
+
} catch {
|
|
2843
|
+
}
|
|
2844
|
+
const db = getDb();
|
|
2845
|
+
await db.transaction(async (tx) => {
|
|
2846
|
+
await tx.run(
|
|
2847
|
+
"DELETE FROM node_versions WHERE nest_id = ? AND node_id = ?",
|
|
2848
|
+
[nestId, nodeId]
|
|
2849
|
+
);
|
|
2850
|
+
await tx.run(
|
|
2851
|
+
"DELETE FROM review_requests WHERE nest_id = ? AND node_id = ?",
|
|
2852
|
+
[nestId, nodeId]
|
|
2853
|
+
);
|
|
2854
|
+
await tx.run(
|
|
2855
|
+
"DELETE FROM approved_versions WHERE nest_id = ? AND node_id = ?",
|
|
2856
|
+
[nestId, nodeId]
|
|
2857
|
+
);
|
|
2858
|
+
await tx.run(
|
|
2859
|
+
`DELETE FROM stewards
|
|
2860
|
+
WHERE nest_id = ? AND scope = 'document' AND node_pattern = ?`,
|
|
2861
|
+
[nestId, nodeId]
|
|
2862
|
+
);
|
|
2863
|
+
await tx.run(
|
|
2864
|
+
"DELETE FROM annotation_threads WHERE nest_id = ? AND node_id = ?",
|
|
2865
|
+
[nestId, nodeId]
|
|
2866
|
+
);
|
|
2867
|
+
await tx.run(
|
|
2868
|
+
"DELETE FROM node_versions WHERE nest_id = ? AND node_id = ?",
|
|
2869
|
+
[nestId, derivedId]
|
|
2870
|
+
);
|
|
2871
|
+
await tx.run(
|
|
2872
|
+
"DELETE FROM review_requests WHERE nest_id = ? AND node_id = ?",
|
|
2873
|
+
[nestId, derivedId]
|
|
2874
|
+
);
|
|
2875
|
+
await tx.run(
|
|
2876
|
+
"DELETE FROM approved_versions WHERE nest_id = ? AND node_id = ?",
|
|
2877
|
+
[nestId, derivedId]
|
|
2878
|
+
);
|
|
2879
|
+
await tx.run(
|
|
2880
|
+
"DELETE FROM annotation_threads WHERE nest_id = ? AND node_id = ?",
|
|
2881
|
+
[nestId, derivedId]
|
|
2882
|
+
);
|
|
2883
|
+
});
|
|
2884
|
+
await trackEvent("node.delete", { nestId, nodeId });
|
|
2885
|
+
return c.json({ deleted: true });
|
|
2886
|
+
});
|
|
2887
|
+
async function getUserEmail(c) {
|
|
2888
|
+
const userId = c.get("userId");
|
|
2889
|
+
const db = getDb();
|
|
2890
|
+
const user = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
|
|
2891
|
+
return user?.email || "anonymous@localhost";
|
|
2664
2892
|
}
|
|
2665
2893
|
|
|
2894
|
+
// src/annotations/routes.ts
|
|
2895
|
+
import { Hono as Hono5 } from "hono";
|
|
2896
|
+
|
|
2666
2897
|
// src/annotations/types.ts
|
|
2667
2898
|
var ARTIFACT_NODE_TYPE = "artifact";
|
|
2668
2899
|
|
|
@@ -2680,25 +2911,25 @@ annotationRoutes.get("/:nodeId{.+}/annotations", async (c) => {
|
|
|
2680
2911
|
const nestId = c.req.param("nestId");
|
|
2681
2912
|
const nodeId = getNodeId(c);
|
|
2682
2913
|
const userId = c.get("userId");
|
|
2683
|
-
const userEmail = resolveCallerEmail(userId);
|
|
2684
|
-
if (!canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
2914
|
+
const userEmail = await resolveCallerEmail(userId);
|
|
2915
|
+
if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
2685
2916
|
return c.json({ error: "Access denied" }, 403);
|
|
2686
2917
|
}
|
|
2687
|
-
return c.json({ threads: listThreads(nestId, nodeId) });
|
|
2918
|
+
return c.json({ threads: await listThreads(nestId, nodeId) });
|
|
2688
2919
|
});
|
|
2689
2920
|
annotationRoutes.post("/:nodeId{.+}/annotations", async (c) => {
|
|
2690
2921
|
const nestId = c.req.param("nestId");
|
|
2691
2922
|
const nodeId = getNodeId(c);
|
|
2692
2923
|
const userId = c.get("userId");
|
|
2693
|
-
const userEmail = resolveCallerEmail(userId);
|
|
2694
|
-
if (!canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
2924
|
+
const userEmail = await resolveCallerEmail(userId);
|
|
2925
|
+
if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
2695
2926
|
return c.json({ error: "Access denied" }, 403);
|
|
2696
2927
|
}
|
|
2697
2928
|
const input = await c.req.json();
|
|
2698
2929
|
if (!input.body || !input.body.trim()) {
|
|
2699
2930
|
throw new ValidationError("body is required");
|
|
2700
2931
|
}
|
|
2701
|
-
const thread = createThread(
|
|
2932
|
+
const thread = await createThread(
|
|
2702
2933
|
nestId,
|
|
2703
2934
|
nodeId,
|
|
2704
2935
|
{
|
|
@@ -2716,15 +2947,15 @@ annotationRoutes.post("/:nodeId{.+}/annotations/:threadId/comments", async (c) =
|
|
|
2716
2947
|
const nodeId = getNodeId(c);
|
|
2717
2948
|
const threadId = c.req.param("threadId");
|
|
2718
2949
|
const userId = c.get("userId");
|
|
2719
|
-
const userEmail = resolveCallerEmail(userId);
|
|
2720
|
-
if (!canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
2950
|
+
const userEmail = await resolveCallerEmail(userId);
|
|
2951
|
+
if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
2721
2952
|
return c.json({ error: "Access denied" }, 403);
|
|
2722
2953
|
}
|
|
2723
2954
|
const input = await c.req.json();
|
|
2724
2955
|
if (!input.body || !input.body.trim()) {
|
|
2725
2956
|
throw new ValidationError("body is required");
|
|
2726
2957
|
}
|
|
2727
|
-
const thread = addComment(nestId, nodeId, threadId, userEmail, input.body);
|
|
2958
|
+
const thread = await addComment(nestId, nodeId, threadId, userEmail, input.body);
|
|
2728
2959
|
await syncAnnotationsNode(nestId, nodeId, userEmail);
|
|
2729
2960
|
return c.json({ thread });
|
|
2730
2961
|
});
|
|
@@ -2733,11 +2964,11 @@ annotationRoutes.post("/:nodeId{.+}/annotations/:threadId/resolve", async (c) =>
|
|
|
2733
2964
|
const nodeId = getNodeId(c);
|
|
2734
2965
|
const threadId = c.req.param("threadId");
|
|
2735
2966
|
const userId = c.get("userId");
|
|
2736
|
-
const userEmail = resolveCallerEmail(userId);
|
|
2737
|
-
if (!canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
2967
|
+
const userEmail = await resolveCallerEmail(userId);
|
|
2968
|
+
if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
2738
2969
|
return c.json({ error: "Access denied" }, 403);
|
|
2739
2970
|
}
|
|
2740
|
-
const thread = setThreadStatus(nestId, nodeId, threadId, "resolved", userEmail);
|
|
2971
|
+
const thread = await setThreadStatus(nestId, nodeId, threadId, "resolved", userEmail);
|
|
2741
2972
|
await syncAnnotationsNode(nestId, nodeId, userEmail);
|
|
2742
2973
|
return c.json({ thread });
|
|
2743
2974
|
});
|
|
@@ -2746,11 +2977,11 @@ annotationRoutes.post("/:nodeId{.+}/annotations/:threadId/reopen", async (c) =>
|
|
|
2746
2977
|
const nodeId = getNodeId(c);
|
|
2747
2978
|
const threadId = c.req.param("threadId");
|
|
2748
2979
|
const userId = c.get("userId");
|
|
2749
|
-
const userEmail = resolveCallerEmail(userId);
|
|
2750
|
-
if (!canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
2980
|
+
const userEmail = await resolveCallerEmail(userId);
|
|
2981
|
+
if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
2751
2982
|
return c.json({ error: "Access denied" }, 403);
|
|
2752
2983
|
}
|
|
2753
|
-
const thread = setThreadStatus(nestId, nodeId, threadId, "open", userEmail);
|
|
2984
|
+
const thread = await setThreadStatus(nestId, nodeId, threadId, "open", userEmail);
|
|
2754
2985
|
await syncAnnotationsNode(nestId, nodeId, userEmail);
|
|
2755
2986
|
return c.json({ thread });
|
|
2756
2987
|
});
|
|
@@ -2758,8 +2989,8 @@ annotationRoutes.get("/:nodeId{.+}/hosted", async (c) => {
|
|
|
2758
2989
|
const nestId = c.req.param("nestId");
|
|
2759
2990
|
const nodeId = getNodeId(c);
|
|
2760
2991
|
const userId = c.get("userId");
|
|
2761
|
-
const userEmail = resolveCallerEmail(userId);
|
|
2762
|
-
if (!canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
2992
|
+
const userEmail = await resolveCallerEmail(userId);
|
|
2993
|
+
if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
|
|
2763
2994
|
return c.json({ error: "Access denied" }, 403);
|
|
2764
2995
|
}
|
|
2765
2996
|
const vRaw = c.req.query("v");
|
|
@@ -2950,15 +3181,16 @@ function tokenizePrompt(prompt) {
|
|
|
2950
3181
|
const words = prompt.toLowerCase().split(/[^a-z0-9_-]+/).filter(Boolean).filter((w) => w.length >= 3 && !STOPWORDS.has(w));
|
|
2951
3182
|
return Array.from(new Set(words));
|
|
2952
3183
|
}
|
|
2953
|
-
function compilePrompt(prompt, nestId, titles) {
|
|
3184
|
+
async function compilePrompt(prompt, nestId, titles) {
|
|
2954
3185
|
const tokens = tokenizePrompt(prompt);
|
|
2955
3186
|
if (tokens.length === 0) {
|
|
2956
3187
|
return { selector: null, matchedTags: [], matchedTitles: [], unmatched: [] };
|
|
2957
3188
|
}
|
|
2958
3189
|
const db = getDb();
|
|
2959
|
-
const tagRows = db.
|
|
2960
|
-
"SELECT DISTINCT tag_name FROM node_tag_index WHERE nest_id = ?"
|
|
2961
|
-
|
|
3190
|
+
const tagRows = await db.all(
|
|
3191
|
+
"SELECT DISTINCT tag_name FROM node_tag_index WHERE nest_id = ?",
|
|
3192
|
+
[nestId]
|
|
3193
|
+
);
|
|
2962
3194
|
const knownTags = new Set(tagRows.map((r) => r.tag_name));
|
|
2963
3195
|
const matchedTags = /* @__PURE__ */ new Set();
|
|
2964
3196
|
for (const token of tokens) {
|
|
@@ -2988,11 +3220,11 @@ function compilePrompt(prompt, nestId, titles) {
|
|
|
2988
3220
|
|
|
2989
3221
|
// src/nodes/readable-body.ts
|
|
2990
3222
|
async function resolveReadableBody(nestId, nodeId, userId, workingBody) {
|
|
2991
|
-
if (!isPublicReader(nestId, userId)) return workingBody;
|
|
2992
|
-
const approved = getApprovedVersion(nestId, nodeId);
|
|
3223
|
+
if (!await isPublicReader(nestId, userId)) return workingBody;
|
|
3224
|
+
const approved = await getApprovedVersion(nestId, nodeId);
|
|
2993
3225
|
if (approved == null) return "";
|
|
2994
3226
|
try {
|
|
2995
|
-
const { versions } = engineCache.get(nestId);
|
|
3227
|
+
const { versions } = await engineCache.get(nestId);
|
|
2996
3228
|
const raw = await versions.reconstructVersion(nodeId, approved);
|
|
2997
3229
|
return bodyOnly(nodeId, raw);
|
|
2998
3230
|
} catch {
|
|
@@ -3000,11 +3232,11 @@ async function resolveReadableBody(nestId, nodeId, userId, workingBody) {
|
|
|
3000
3232
|
}
|
|
3001
3233
|
}
|
|
3002
3234
|
async function resolveExportBody(nestId, nodeId, workingBody) {
|
|
3003
|
-
if (!isStewardshipEnabled(nestId)) return workingBody;
|
|
3004
|
-
const approved = getApprovedVersion(nestId, nodeId);
|
|
3235
|
+
if (!await isStewardshipEnabled(nestId)) return workingBody;
|
|
3236
|
+
const approved = await getApprovedVersion(nestId, nodeId);
|
|
3005
3237
|
if (approved == null) return null;
|
|
3006
3238
|
try {
|
|
3007
|
-
const { versions } = engineCache.get(nestId);
|
|
3239
|
+
const { versions } = await engineCache.get(nestId);
|
|
3008
3240
|
const raw = await versions.reconstructVersion(nodeId, approved);
|
|
3009
3241
|
return bodyOnly(nodeId, raw);
|
|
3010
3242
|
} catch {
|
|
@@ -3027,9 +3259,9 @@ function extractWikiTargets(body) {
|
|
|
3027
3259
|
return out;
|
|
3028
3260
|
}
|
|
3029
3261
|
async function buildNestGraph(nestId, userId, userEmail, canSeeIdentities) {
|
|
3030
|
-
const { storage } = engineCache.get(nestId);
|
|
3262
|
+
const { storage } = await engineCache.get(nestId);
|
|
3031
3263
|
const docs = await storage.discoverDocuments();
|
|
3032
|
-
const accessible = filterAccessible(nestId, userId, userEmail, docs);
|
|
3264
|
+
const accessible = await filterAccessible(nestId, userId, userEmail, docs);
|
|
3033
3265
|
const idSet = new Set(accessible.map((d) => d.id));
|
|
3034
3266
|
const titleToId = /* @__PURE__ */ new Map();
|
|
3035
3267
|
for (const d of accessible) {
|
|
@@ -3065,7 +3297,7 @@ async function buildNestGraph(nestId, userId, userEmail, canSeeIdentities) {
|
|
|
3065
3297
|
}
|
|
3066
3298
|
}
|
|
3067
3299
|
const tags = [...tagCounts.entries()].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count);
|
|
3068
|
-
const exposeStewards = isStewardshipEnabled(nestId) && !isPublicReader(nestId, userId);
|
|
3300
|
+
const exposeStewards = await isStewardshipEnabled(nestId) && !await isPublicReader(nestId, userId);
|
|
3069
3301
|
const stewardRows = exposeStewards ? (await listStewards({ nestId })).filter(
|
|
3070
3302
|
// Drop document-scoped rows whose node the caller can't see.
|
|
3071
3303
|
(s) => s.scope !== "document" || idSet.has(s.nodePattern || "")
|
|
@@ -3159,7 +3391,7 @@ var queryRoutes = new Hono6();
|
|
|
3159
3391
|
queryRoutes.get("/graph", async (c) => {
|
|
3160
3392
|
const nestId = c.req.param("nestId");
|
|
3161
3393
|
const userId = c.get("userId");
|
|
3162
|
-
const userEmail = resolveCallerEmail(userId);
|
|
3394
|
+
const userEmail = await resolveCallerEmail(userId);
|
|
3163
3395
|
const canSeeIdentities = permissionLevel(c.get("nestPermission")) >= permissionLevel("write");
|
|
3164
3396
|
const graph = await buildNestGraph(nestId, userId, userEmail, canSeeIdentities);
|
|
3165
3397
|
return c.json(graph);
|
|
@@ -3184,17 +3416,36 @@ queryRoutes.post("/context", async (c) => {
|
|
|
3184
3416
|
throw new ValidationError("prompt or selector is required");
|
|
3185
3417
|
}
|
|
3186
3418
|
const nestId = c.req.param("nestId");
|
|
3187
|
-
const { query: queryEngine, storage } = engineCache.get(nestId);
|
|
3419
|
+
const { query: queryEngine, storage } = await engineCache.get(nestId);
|
|
3188
3420
|
const maxTokens = Math.max(50, body.max_tokens ?? 4e3);
|
|
3189
3421
|
const hops = body.hops ?? 2;
|
|
3190
3422
|
const includeDrafts = body.include_drafts === true;
|
|
3423
|
+
const isVisible = (d) => includeDrafts || d.frontmatter.status === "published";
|
|
3191
3424
|
let selector = body.selector?.trim() || null;
|
|
3192
3425
|
let compileDetail = null;
|
|
3193
3426
|
let titleMatches = [];
|
|
3427
|
+
let resolvedTitleSelector = false;
|
|
3194
3428
|
const allDocs = await storage.discoverDocuments();
|
|
3195
|
-
if (
|
|
3429
|
+
if (selector && selector.includes("[[")) {
|
|
3430
|
+
const parts = selector.split("|").map((p) => p.trim()).filter(Boolean);
|
|
3431
|
+
const wantedTitles = /* @__PURE__ */ new Set();
|
|
3432
|
+
const rest = [];
|
|
3433
|
+
for (const p of parts) {
|
|
3434
|
+
const m = /^\[\[(.+)\]\]$/.exec(p);
|
|
3435
|
+
if (m) wantedTitles.add(m[1].trim().toLowerCase());
|
|
3436
|
+
else rest.push(p);
|
|
3437
|
+
}
|
|
3438
|
+
if (wantedTitles.size > 0) {
|
|
3439
|
+
selector = rest.join("|") || null;
|
|
3440
|
+
resolvedTitleSelector = true;
|
|
3441
|
+
titleMatches = allDocs.filter(
|
|
3442
|
+
(d) => wantedTitles.has(String(d.frontmatter.title || "").toLowerCase()) && isVisible(d)
|
|
3443
|
+
);
|
|
3444
|
+
}
|
|
3445
|
+
}
|
|
3446
|
+
if (!selector && !resolvedTitleSelector && body.prompt) {
|
|
3196
3447
|
const titles = allDocs.map((d) => d.frontmatter.title);
|
|
3197
|
-
compileDetail = compilePrompt(body.prompt, nestId, titles);
|
|
3448
|
+
compileDetail = await compilePrompt(body.prompt, nestId, titles);
|
|
3198
3449
|
selector = compileDetail.selector;
|
|
3199
3450
|
if (compileDetail.matchedTitles.length > 0) {
|
|
3200
3451
|
const matchedLower = new Set(
|
|
@@ -3218,6 +3469,44 @@ queryRoutes.post("/context", async (c) => {
|
|
|
3218
3469
|
hopsUsed = result.hopsUsed;
|
|
3219
3470
|
nodesTraversed = result.nodesTraversed;
|
|
3220
3471
|
}
|
|
3472
|
+
if (titleMatches.length > 0 && hops > 0) {
|
|
3473
|
+
const byTitle = new Map(
|
|
3474
|
+
allDocs.map((d) => [
|
|
3475
|
+
String(d.frontmatter.title || "").toLowerCase(),
|
|
3476
|
+
d
|
|
3477
|
+
])
|
|
3478
|
+
);
|
|
3479
|
+
const byId = new Map(allDocs.map((d) => [d.id, d]));
|
|
3480
|
+
const adj = /* @__PURE__ */ new Map();
|
|
3481
|
+
const link = (a, b) => {
|
|
3482
|
+
(adj.get(a) ?? adj.set(a, /* @__PURE__ */ new Set()).get(a)).add(b);
|
|
3483
|
+
(adj.get(b) ?? adj.set(b, /* @__PURE__ */ new Set()).get(b)).add(a);
|
|
3484
|
+
};
|
|
3485
|
+
for (const d of allDocs) {
|
|
3486
|
+
for (const target of extractWikiTargets(d.body || "")) {
|
|
3487
|
+
const t = byId.get(target) ?? byTitle.get(target.toLowerCase());
|
|
3488
|
+
if (t && t.id !== d.id) link(d.id, t.id);
|
|
3489
|
+
}
|
|
3490
|
+
}
|
|
3491
|
+
const seen = new Set(titleMatches.map((d) => d.id));
|
|
3492
|
+
let frontier = titleMatches.map((d) => d.id);
|
|
3493
|
+
for (let depth = 0; depth < hops && frontier.length; depth++) {
|
|
3494
|
+
const next = [];
|
|
3495
|
+
for (const id of frontier) {
|
|
3496
|
+
for (const nb of adj.get(id) ?? []) {
|
|
3497
|
+
if (seen.has(nb)) continue;
|
|
3498
|
+
seen.add(nb);
|
|
3499
|
+
const doc = byId.get(nb);
|
|
3500
|
+
if (!doc || !isVisible(doc)) continue;
|
|
3501
|
+
next.push(nb);
|
|
3502
|
+
titleMatches.push(doc);
|
|
3503
|
+
}
|
|
3504
|
+
}
|
|
3505
|
+
frontier = next;
|
|
3506
|
+
hopsUsed = Math.max(hopsUsed, depth + 1);
|
|
3507
|
+
}
|
|
3508
|
+
nodesTraversed += seen.size;
|
|
3509
|
+
}
|
|
3221
3510
|
if (titleMatches.length > 0) {
|
|
3222
3511
|
const seen = new Set(documents.map((d) => d.id));
|
|
3223
3512
|
for (const t of titleMatches) {
|
|
@@ -3228,9 +3517,9 @@ queryRoutes.post("/context", async (c) => {
|
|
|
3228
3517
|
}
|
|
3229
3518
|
}
|
|
3230
3519
|
const userId = c.get("userId");
|
|
3231
|
-
const userEmail = resolveCallerEmail(userId);
|
|
3520
|
+
const userEmail = await resolveCallerEmail(userId);
|
|
3232
3521
|
const beforePermission = documents.length;
|
|
3233
|
-
const accessible = filterAccessible(nestId, userId, userEmail, documents);
|
|
3522
|
+
const accessible = await filterAccessible(nestId, userId, userEmail, documents);
|
|
3234
3523
|
const permissionFiltered = beforePermission - accessible.length;
|
|
3235
3524
|
const readable = await Promise.all(
|
|
3236
3525
|
accessible.map(async (doc) => ({
|
|
@@ -3289,13 +3578,13 @@ queryRoutes.post("/query", async (c) => {
|
|
|
3289
3578
|
throw new ValidationError("query is required");
|
|
3290
3579
|
}
|
|
3291
3580
|
const nestId = c.req.param("nestId");
|
|
3292
|
-
const { query: queryEngine } = engineCache.get(nestId);
|
|
3581
|
+
const { query: queryEngine } = await engineCache.get(nestId);
|
|
3293
3582
|
const result = await queryEngine.query(body.query, {
|
|
3294
3583
|
hops: body.hops ?? 2
|
|
3295
3584
|
});
|
|
3296
3585
|
const userId = c.get("userId");
|
|
3297
|
-
const userEmail = resolveCallerEmail(userId);
|
|
3298
|
-
const accessible = filterAccessible(nestId, userId, userEmail, result.documents);
|
|
3586
|
+
const userEmail = await resolveCallerEmail(userId);
|
|
3587
|
+
const accessible = await filterAccessible(nestId, userId, userEmail, result.documents);
|
|
3299
3588
|
return c.json({
|
|
3300
3589
|
query: body.query,
|
|
3301
3590
|
count: accessible.length,
|
|
@@ -3314,7 +3603,7 @@ queryRoutes.get("/search", async (c) => {
|
|
|
3314
3603
|
throw new ValidationError("q query parameter is required");
|
|
3315
3604
|
}
|
|
3316
3605
|
const nestId = c.req.param("nestId");
|
|
3317
|
-
const { storage } = engineCache.get(nestId);
|
|
3606
|
+
const { storage } = await engineCache.get(nestId);
|
|
3318
3607
|
const documents = await storage.discoverDocuments();
|
|
3319
3608
|
const terms = q.toLowerCase().split(/\s+/).filter(Boolean);
|
|
3320
3609
|
const matches = documents.filter((node) => {
|
|
@@ -3327,8 +3616,8 @@ queryRoutes.get("/search", async (c) => {
|
|
|
3327
3616
|
return terms.every((term) => haystack.includes(term));
|
|
3328
3617
|
});
|
|
3329
3618
|
const userId = c.get("userId");
|
|
3330
|
-
const userEmail = resolveCallerEmail(userId);
|
|
3331
|
-
const accessible = filterAccessible(nestId, userId, userEmail, matches);
|
|
3619
|
+
const userEmail = await resolveCallerEmail(userId);
|
|
3620
|
+
const accessible = await filterAccessible(nestId, userId, userEmail, matches);
|
|
3332
3621
|
return c.json({
|
|
3333
3622
|
query: q,
|
|
3334
3623
|
count: accessible.length,
|
|
@@ -3342,7 +3631,7 @@ queryRoutes.get("/search", async (c) => {
|
|
|
3342
3631
|
});
|
|
3343
3632
|
});
|
|
3344
3633
|
queryRoutes.get("/overview", async (c) => {
|
|
3345
|
-
const { storage } = engineCache.get(c.req.param("nestId"));
|
|
3634
|
+
const { storage } = await engineCache.get(c.req.param("nestId"));
|
|
3346
3635
|
const documents = await storage.discoverDocuments();
|
|
3347
3636
|
const types = {};
|
|
3348
3637
|
const tags = {};
|
|
@@ -3366,8 +3655,12 @@ queryRoutes.get("/overview", async (c) => {
|
|
|
3366
3655
|
}))
|
|
3367
3656
|
});
|
|
3368
3657
|
});
|
|
3658
|
+
queryRoutes.get("/comment-counts", async (c) => {
|
|
3659
|
+
const counts = await countThreadsByNode(c.req.param("nestId"));
|
|
3660
|
+
return c.json({ counts });
|
|
3661
|
+
});
|
|
3369
3662
|
queryRoutes.get("/context", async (c) => {
|
|
3370
|
-
const { storage } = engineCache.get(c.req.param("nestId"));
|
|
3663
|
+
const { storage } = await engineCache.get(c.req.param("nestId"));
|
|
3371
3664
|
const content = await storage.readContextMd();
|
|
3372
3665
|
return c.json({ content: content || "" });
|
|
3373
3666
|
});
|
|
@@ -3376,7 +3669,7 @@ queryRoutes.get("/export", async (c) => {
|
|
|
3376
3669
|
throw new ValidationError("format=markdown is required");
|
|
3377
3670
|
}
|
|
3378
3671
|
const nestId = c.req.param("nestId");
|
|
3379
|
-
const { storage, query: queryEngine } = engineCache.get(nestId);
|
|
3672
|
+
const { storage, query: queryEngine } = await engineCache.get(nestId);
|
|
3380
3673
|
const selector = c.req.query("selector")?.trim() || null;
|
|
3381
3674
|
let documents;
|
|
3382
3675
|
if (selector) {
|
|
@@ -3386,9 +3679,9 @@ queryRoutes.get("/export", async (c) => {
|
|
|
3386
3679
|
documents = await storage.discoverDocuments();
|
|
3387
3680
|
}
|
|
3388
3681
|
const userId = c.get("userId");
|
|
3389
|
-
const userEmail = resolveCallerEmail(userId);
|
|
3390
|
-
const accessible = filterAccessible(nestId, userId, userEmail, documents);
|
|
3391
|
-
const governed = isStewardshipEnabled(nestId);
|
|
3682
|
+
const userEmail = await resolveCallerEmail(userId);
|
|
3683
|
+
const accessible = await filterAccessible(nestId, userId, userEmail, documents);
|
|
3684
|
+
const governed = await isStewardshipEnabled(nestId);
|
|
3392
3685
|
const resolved = await Promise.all(
|
|
3393
3686
|
accessible.map(async (n) => {
|
|
3394
3687
|
const body = await resolveExportBody(nestId, n.id, n.body || "");
|
|
@@ -3435,7 +3728,7 @@ queryRoutes.post("/publish", async (c) => {
|
|
|
3435
3728
|
throw new ValidationError("documents array or context_md is required");
|
|
3436
3729
|
}
|
|
3437
3730
|
const nestId = c.req.param("nestId");
|
|
3438
|
-
const { storage } = engineCache.get(nestId);
|
|
3731
|
+
const { storage } = await engineCache.get(nestId);
|
|
3439
3732
|
const created = [];
|
|
3440
3733
|
if (body.context_md) {
|
|
3441
3734
|
await storage.writeContextMd(body.context_md);
|
|
@@ -3467,7 +3760,7 @@ queryRoutes.post("/publish", async (c) => {
|
|
|
3467
3760
|
};
|
|
3468
3761
|
const serialized = serializeDocument2(node);
|
|
3469
3762
|
await storage.writeDocument(id, serialized);
|
|
3470
|
-
syncNodeTags(nestId, id, tags);
|
|
3763
|
+
await syncNodeTags(nestId, id, tags);
|
|
3471
3764
|
created.push(id);
|
|
3472
3765
|
}
|
|
3473
3766
|
trackEvent("nest.publish", { nestId, count: created.length });
|
|
@@ -3487,6 +3780,13 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
3487
3780
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
3488
3781
|
|
|
3489
3782
|
// src/mcp/tools.ts
|
|
3783
|
+
var MAX_HOPS = 10;
|
|
3784
|
+
function normalizeHops(raw) {
|
|
3785
|
+
if (raw == null) return 2;
|
|
3786
|
+
const n = Number(raw);
|
|
3787
|
+
if (!Number.isFinite(n)) return 2;
|
|
3788
|
+
return Math.max(0, Math.min(MAX_HOPS, Math.floor(n)));
|
|
3789
|
+
}
|
|
3490
3790
|
var TOOL_DEFINITIONS = [
|
|
3491
3791
|
{
|
|
3492
3792
|
name: "context_init",
|
|
@@ -3515,7 +3815,11 @@ var TOOL_DEFINITIONS = [
|
|
|
3515
3815
|
inputSchema: {
|
|
3516
3816
|
type: "object",
|
|
3517
3817
|
properties: {
|
|
3518
|
-
query: { type: "string", description: "Selector query" }
|
|
3818
|
+
query: { type: "string", description: "Selector query" },
|
|
3819
|
+
hops: {
|
|
3820
|
+
type: "number",
|
|
3821
|
+
description: "Graph traversal depth from the matched nodes (default: 2). Use 1 for just the matches + direct links, higher to pull in more of the neighborhood."
|
|
3822
|
+
}
|
|
3519
3823
|
},
|
|
3520
3824
|
required: ["query"]
|
|
3521
3825
|
}
|
|
@@ -3556,11 +3860,39 @@ var TOOL_DEFINITIONS = [
|
|
|
3556
3860
|
max_tokens: {
|
|
3557
3861
|
type: "number",
|
|
3558
3862
|
description: "Approximate token budget (default: 8000)"
|
|
3863
|
+
},
|
|
3864
|
+
hops: {
|
|
3865
|
+
type: "number",
|
|
3866
|
+
description: "Graph traversal depth from the matched nodes (default: 2)"
|
|
3559
3867
|
}
|
|
3560
3868
|
},
|
|
3561
3869
|
required: ["selector"]
|
|
3562
3870
|
}
|
|
3563
3871
|
},
|
|
3872
|
+
{
|
|
3873
|
+
name: "context_export",
|
|
3874
|
+
description: "Export the ENTIRE nest as one markdown bundle \u2014 every accessible document's FULL content. Use when you need the whole nest as context rather than a targeted query. Respects an optional token budget.",
|
|
3875
|
+
inputSchema: {
|
|
3876
|
+
type: "object",
|
|
3877
|
+
properties: {
|
|
3878
|
+
max_tokens: {
|
|
3879
|
+
type: "number",
|
|
3880
|
+
description: "Approximate token budget; documents are included until it's reached (omit for no cap)."
|
|
3881
|
+
}
|
|
3882
|
+
}
|
|
3883
|
+
}
|
|
3884
|
+
},
|
|
3885
|
+
{
|
|
3886
|
+
name: "context_comments",
|
|
3887
|
+
description: "Get the review comments/annotations on a document \u2014 anchored quotes, threaded replies, and open/resolved status \u2014 by title or ID. Use to pull human feedback into context.",
|
|
3888
|
+
inputSchema: {
|
|
3889
|
+
type: "object",
|
|
3890
|
+
properties: {
|
|
3891
|
+
title: { type: "string", description: "Title of the node" },
|
|
3892
|
+
id: { type: "string", description: "ID of the node" }
|
|
3893
|
+
}
|
|
3894
|
+
}
|
|
3895
|
+
},
|
|
3564
3896
|
{
|
|
3565
3897
|
name: "context_create",
|
|
3566
3898
|
description: "Create a new knowledge node in the vault.",
|
|
@@ -3581,7 +3913,11 @@ var TOOL_DEFINITIONS = [
|
|
|
3581
3913
|
items: { type: "string" },
|
|
3582
3914
|
description: "Tags"
|
|
3583
3915
|
},
|
|
3584
|
-
scope: { type: "string", description: "Visibility scope" }
|
|
3916
|
+
scope: { type: "string", description: "Visibility scope" },
|
|
3917
|
+
folder: {
|
|
3918
|
+
type: "string",
|
|
3919
|
+
description: 'Folder path under nodes/ (e.g. "gtm/deals"); segments are slugified'
|
|
3920
|
+
}
|
|
3585
3921
|
},
|
|
3586
3922
|
required: ["title", "content"]
|
|
3587
3923
|
}
|
|
@@ -3737,8 +4073,8 @@ var TOOL_DEFINITIONS = [
|
|
|
3737
4073
|
}
|
|
3738
4074
|
];
|
|
3739
4075
|
async function resolveLlmBody(ctx, node) {
|
|
3740
|
-
if (!isStewardshipEnabled(ctx.nestId)) return node.body || "";
|
|
3741
|
-
const approved = getApprovedVersion(ctx.nestId, node.id);
|
|
4076
|
+
if (!await isStewardshipEnabled(ctx.nestId)) return node.body || "";
|
|
4077
|
+
const approved = await getApprovedVersion(ctx.nestId, node.id);
|
|
3742
4078
|
if (approved == null) return null;
|
|
3743
4079
|
try {
|
|
3744
4080
|
return await ctx.versionManager.reconstructVersion(node.id, approved);
|
|
@@ -3812,8 +4148,16 @@ ${nodeList}`;
|
|
|
3812
4148
|
${results}`;
|
|
3813
4149
|
}
|
|
3814
4150
|
case "context_query": {
|
|
3815
|
-
const result = await queryEngine.query(args.query, {
|
|
3816
|
-
|
|
4151
|
+
const result = await queryEngine.query(args.query, {
|
|
4152
|
+
hops: normalizeHops(args.hops)
|
|
4153
|
+
});
|
|
4154
|
+
const visibility = await Promise.all(
|
|
4155
|
+
result.documents.map(async (n) => ({
|
|
4156
|
+
node: n,
|
|
4157
|
+
visible: await resolveLlmBody(ctx, n) !== null
|
|
4158
|
+
}))
|
|
4159
|
+
);
|
|
4160
|
+
const nodes = visibility.filter((e) => e.visible).map((e) => e.node);
|
|
3817
4161
|
if (!nodes.length) return `No nodes matched: ${args.query}`;
|
|
3818
4162
|
const list = nodes.map(
|
|
3819
4163
|
(n, i) => `${i + 1}. **${n.frontmatter.title}** [${n.frontmatter.type || "document"}] ${(n.frontmatter.tags || []).join(" ")}`
|
|
@@ -3861,23 +4205,101 @@ ${body || "(no content)"}`;
|
|
|
3861
4205
|
${list}`;
|
|
3862
4206
|
}
|
|
3863
4207
|
case "context_resolve": {
|
|
3864
|
-
const result = await queryEngine.query(args.selector, {
|
|
4208
|
+
const result = await queryEngine.query(args.selector, {
|
|
4209
|
+
hops: normalizeHops(args.hops)
|
|
4210
|
+
});
|
|
3865
4211
|
const maxTokens = args.max_tokens || 8e3;
|
|
3866
4212
|
const approxChars = maxTokens * 4;
|
|
4213
|
+
const resolvedBodies = await Promise.all(
|
|
4214
|
+
result.documents.map(async (n) => ({
|
|
4215
|
+
node: n,
|
|
4216
|
+
body: await resolveLlmBody(ctx, n)
|
|
4217
|
+
}))
|
|
4218
|
+
);
|
|
3867
4219
|
let total = 0;
|
|
3868
4220
|
const resolved = [];
|
|
3869
|
-
for (const n of
|
|
4221
|
+
for (const { node: n, body } of resolvedBodies) {
|
|
4222
|
+
if (body === null) continue;
|
|
3870
4223
|
const entry = `## ${n.frontmatter.title}
|
|
3871
4224
|
|
|
3872
|
-
${
|
|
4225
|
+
${body}`;
|
|
3873
4226
|
if (total + entry.length > approxChars) break;
|
|
3874
4227
|
resolved.push(entry);
|
|
3875
4228
|
total += entry.length;
|
|
3876
4229
|
}
|
|
3877
4230
|
return resolved.join("\n\n---\n\n") || "No nodes resolved.";
|
|
3878
4231
|
}
|
|
4232
|
+
case "context_export": {
|
|
4233
|
+
const docs = await storage.discoverDocuments();
|
|
4234
|
+
const approxChars = args.max_tokens ? args.max_tokens * 4 : Infinity;
|
|
4235
|
+
const resolved = await Promise.all(
|
|
4236
|
+
docs.map(async (n) => ({ node: n, body: await resolveLlmBody(ctx, n) }))
|
|
4237
|
+
);
|
|
4238
|
+
let total = 0;
|
|
4239
|
+
let budgetHit = false;
|
|
4240
|
+
const parts = [];
|
|
4241
|
+
for (const { node: n, body } of resolved) {
|
|
4242
|
+
if (body === null) continue;
|
|
4243
|
+
const meta = [
|
|
4244
|
+
`**Title:** ${n.frontmatter.title}`,
|
|
4245
|
+
`**Type:** ${n.frontmatter.type || "document"}`,
|
|
4246
|
+
n.frontmatter.tags?.length ? `**Tags:** ${n.frontmatter.tags.join(" ")}` : null
|
|
4247
|
+
].filter(Boolean).join("\n");
|
|
4248
|
+
const entry = `${meta}
|
|
4249
|
+
|
|
4250
|
+
${body || "(no content)"}`;
|
|
4251
|
+
if (total + entry.length > approxChars) {
|
|
4252
|
+
budgetHit = true;
|
|
4253
|
+
break;
|
|
4254
|
+
}
|
|
4255
|
+
parts.push(entry);
|
|
4256
|
+
total += entry.length;
|
|
4257
|
+
}
|
|
4258
|
+
if (!parts.length) {
|
|
4259
|
+
return budgetHit ? "Token budget reached before any document fit \u2014 raise max_tokens, or use context_query/context_resolve to target." : "No documents available to export.";
|
|
4260
|
+
}
|
|
4261
|
+
const note = budgetHit ? `
|
|
4262
|
+
|
|
4263
|
+
_(Token budget reached \u2014 ${parts.length} of ${docs.length} documents included. Raise max_tokens, or use context_query/context_resolve to target.)_` : "";
|
|
4264
|
+
return `# Nest export \u2014 ${parts.length} document(s)
|
|
4265
|
+
|
|
4266
|
+
${parts.join(
|
|
4267
|
+
"\n\n---\n\n"
|
|
4268
|
+
)}${note}`;
|
|
4269
|
+
}
|
|
4270
|
+
case "context_comments": {
|
|
4271
|
+
const docs = await storage.discoverDocuments();
|
|
4272
|
+
let node;
|
|
4273
|
+
if (args.title) {
|
|
4274
|
+
node = docs.find(
|
|
4275
|
+
(n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
|
|
4276
|
+
);
|
|
4277
|
+
} else if (args.id) {
|
|
4278
|
+
node = docs.find((n) => n.id === args.id);
|
|
4279
|
+
}
|
|
4280
|
+
if (!node) return `Node not found: ${args.title || args.id}`;
|
|
4281
|
+
if (await resolveLlmBody(ctx, node) === null) {
|
|
4282
|
+
return `Node "${node.frontmatter.title}" has no approved version yet \u2014 not available to AI.`;
|
|
4283
|
+
}
|
|
4284
|
+
const threads = await listThreads(nestId, node.id);
|
|
4285
|
+
if (!threads.length)
|
|
4286
|
+
return `No comments on "${node.frontmatter.title}".`;
|
|
4287
|
+
const openCount = threads.filter((t) => t.status === "open").length;
|
|
4288
|
+
const sections = threads.map((t, i) => {
|
|
4289
|
+
const quote = t.anchor?.quote ? `> ${t.anchor.quote.replace(/\n/g, " ")}
|
|
4290
|
+
|
|
4291
|
+
` : "";
|
|
4292
|
+
const head = `### ${i + 1}. [${t.status}]${t.anchor?.quote ? "" : " (whole-document)"}`;
|
|
4293
|
+
const comments = t.comments.map((c) => `- **${c.author}** (${c.createdAt}): ${c.body}`).join("\n");
|
|
4294
|
+
return `${head}
|
|
4295
|
+
${quote}${comments}`;
|
|
4296
|
+
}).join("\n\n");
|
|
4297
|
+
return `# Comments on "${node.frontmatter.title}" \u2014 ${threads.length} thread(s), ${openCount} open
|
|
4298
|
+
|
|
4299
|
+
${sections}`;
|
|
4300
|
+
}
|
|
3879
4301
|
case "context_create": {
|
|
3880
|
-
if (!canCreateInNest(nestId, userEmail)) {
|
|
4302
|
+
if (!await canCreateInNest(nestId, userEmail)) {
|
|
3881
4303
|
return "You don't have permission to create documents in this nest.";
|
|
3882
4304
|
}
|
|
3883
4305
|
const { node } = await createNode(
|
|
@@ -3887,7 +4309,8 @@ ${n.body || ""}`;
|
|
|
3887
4309
|
content: args.content,
|
|
3888
4310
|
type: args.type,
|
|
3889
4311
|
tags: args.tags,
|
|
3890
|
-
scope: args.scope
|
|
4312
|
+
scope: args.scope,
|
|
4313
|
+
folder: args.folder
|
|
3891
4314
|
},
|
|
3892
4315
|
userEmail
|
|
3893
4316
|
);
|
|
@@ -3899,7 +4322,7 @@ ${n.body || ""}`;
|
|
|
3899
4322
|
(n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
|
|
3900
4323
|
);
|
|
3901
4324
|
if (!node) return `Node not found: ${args.title}`;
|
|
3902
|
-
const editCheck = canUserEdit(nestId, node.id, userEmail);
|
|
4325
|
+
const editCheck = await canUserEdit(nestId, node.id, userEmail);
|
|
3903
4326
|
if (!editCheck.allowed) {
|
|
3904
4327
|
return `You don't have permission to edit "${args.title}": ${editCheck.reason}`;
|
|
3905
4328
|
}
|
|
@@ -3923,7 +4346,7 @@ ${n.body || ""}`;
|
|
|
3923
4346
|
(n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
|
|
3924
4347
|
);
|
|
3925
4348
|
const nodeId = node?.id || "";
|
|
3926
|
-
const resolved = resolveStewardsForNode(ctx.nestId, nodeId);
|
|
4349
|
+
const resolved = await resolveStewardsForNode(ctx.nestId, nodeId);
|
|
3927
4350
|
if (resolved.length === 0) {
|
|
3928
4351
|
return `No stewards configured for "${args.title}". Changes are auto-approved.`;
|
|
3929
4352
|
}
|
|
@@ -3934,8 +4357,8 @@ ${n.body || ""}`;
|
|
|
3934
4357
|
|
|
3935
4358
|
${list}`;
|
|
3936
4359
|
}
|
|
3937
|
-
if (!canManageStewards(ctx.
|
|
3938
|
-
return "You don't have permission to list stewards. Only the
|
|
4360
|
+
if (!await canManageStewards(ctx.nestId, ctx.userId)) {
|
|
4361
|
+
return "You don't have permission to list stewards. Only a nest admin, the nest owner, or the server admin can do this.";
|
|
3939
4362
|
}
|
|
3940
4363
|
const allStewards = await getStewardsForNest(ctx.nestId);
|
|
3941
4364
|
if (allStewards.length === 0) {
|
|
@@ -3979,14 +4402,14 @@ ${list}`;
|
|
|
3979
4402
|
(n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
|
|
3980
4403
|
);
|
|
3981
4404
|
if (!node) return `Node not found: ${args.title}`;
|
|
3982
|
-
const submitCheck = canUserEdit(ctx.nestId, node.id, userEmail);
|
|
4405
|
+
const submitCheck = await canUserEdit(ctx.nestId, node.id, userEmail);
|
|
3983
4406
|
if (!submitCheck.allowed) {
|
|
3984
4407
|
return `You don't have permission to submit "${args.title}" for review: ${submitCheck.reason}`;
|
|
3985
4408
|
}
|
|
3986
|
-
const currentVersion = getCurrentVersion(ctx.nestId, node.id);
|
|
4409
|
+
const currentVersion = await getCurrentVersion(ctx.nestId, node.id);
|
|
3987
4410
|
if (currentVersion === 0) return `No versions found for "${args.title}"`;
|
|
3988
4411
|
try {
|
|
3989
|
-
const request = submitForReview({
|
|
4412
|
+
const request = await submitForReview({
|
|
3990
4413
|
nestId: ctx.nestId,
|
|
3991
4414
|
nodeId: node.id,
|
|
3992
4415
|
version: currentVersion,
|
|
@@ -3994,7 +4417,7 @@ ${list}`;
|
|
|
3994
4417
|
note: args.note,
|
|
3995
4418
|
priority: args.priority
|
|
3996
4419
|
});
|
|
3997
|
-
const resolved = resolveStewardsForNode(
|
|
4420
|
+
const resolved = await resolveStewardsForNode(
|
|
3998
4421
|
ctx.nestId,
|
|
3999
4422
|
node.id
|
|
4000
4423
|
);
|
|
@@ -4013,7 +4436,7 @@ ${resolved.map((r) => `- ${r.steward.userEmail} (${r.source})`).join("\n")}` : "
|
|
|
4013
4436
|
(n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
|
|
4014
4437
|
);
|
|
4015
4438
|
if (!node) return `Node not found: ${args.title}`;
|
|
4016
|
-
const currentVersion = getCurrentVersion(ctx.nestId, node.id);
|
|
4439
|
+
const currentVersion = await getCurrentVersion(ctx.nestId, node.id);
|
|
4017
4440
|
try {
|
|
4018
4441
|
const request = await approve({
|
|
4019
4442
|
nestId: ctx.nestId,
|
|
@@ -4034,9 +4457,9 @@ Note: ${args.note}` : ""}`;
|
|
|
4034
4457
|
(n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
|
|
4035
4458
|
);
|
|
4036
4459
|
if (!node) return `Node not found: ${args.title}`;
|
|
4037
|
-
const currentVersion = getCurrentVersion(ctx.nestId, node.id);
|
|
4460
|
+
const currentVersion = await getCurrentVersion(ctx.nestId, node.id);
|
|
4038
4461
|
try {
|
|
4039
|
-
const request = reject({
|
|
4462
|
+
const request = await reject({
|
|
4040
4463
|
nestId: ctx.nestId,
|
|
4041
4464
|
nodeId: node.id,
|
|
4042
4465
|
version: currentVersion,
|
|
@@ -4055,8 +4478,8 @@ Reason: ${args.note}`;
|
|
|
4055
4478
|
(n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
|
|
4056
4479
|
);
|
|
4057
4480
|
if (!node) return `Node not found: ${args.title}`;
|
|
4058
|
-
const allVersions = getVersions(ctx.nestId, node.id);
|
|
4059
|
-
const approved = getApprovedVersion(ctx.nestId, node.id);
|
|
4481
|
+
const allVersions = await getVersions(ctx.nestId, node.id);
|
|
4482
|
+
const approved = await getApprovedVersion(ctx.nestId, node.id);
|
|
4060
4483
|
if (allVersions.length === 0) {
|
|
4061
4484
|
return `No version history for "${args.title}".`;
|
|
4062
4485
|
}
|
|
@@ -4073,8 +4496,8 @@ ${list}`;
|
|
|
4073
4496
|
if (!["nest", "tag", "document"].includes(scope)) {
|
|
4074
4497
|
return `Invalid scope "${args.scope}". Use: nest, tag, or document.`;
|
|
4075
4498
|
}
|
|
4076
|
-
if (!canManageStewards(ctx.
|
|
4077
|
-
return "You don't have permission to manage stewards. Only the
|
|
4499
|
+
if (!await canManageStewards(ctx.nestId, ctx.userId)) {
|
|
4500
|
+
return "You don't have permission to manage stewards. Only a nest admin, the nest owner, or the server admin can do this.";
|
|
4078
4501
|
}
|
|
4079
4502
|
try {
|
|
4080
4503
|
await createStewardRecord({
|
|
@@ -4092,9 +4515,9 @@ ${list}`;
|
|
|
4092
4515
|
}
|
|
4093
4516
|
}
|
|
4094
4517
|
case "context_share_nest": {
|
|
4095
|
-
const
|
|
4096
|
-
if (
|
|
4097
|
-
return "You don't have permission to share this nest
|
|
4518
|
+
const callerPermission = config.AUTH_MODE === "open" ? "owner" : await resolveNestPermission(ctx.nestId, ctx.userId);
|
|
4519
|
+
if (permissionLevel(callerPermission) < permissionLevel("write")) {
|
|
4520
|
+
return "You don't have permission to share this nest. Sharing needs write access \u2014 ask a nest admin or the owner.";
|
|
4098
4521
|
}
|
|
4099
4522
|
const permission = args.permission || "read";
|
|
4100
4523
|
try {
|
|
@@ -4102,7 +4525,8 @@ ${list}`;
|
|
|
4102
4525
|
nestId: ctx.nestId,
|
|
4103
4526
|
email: args.email,
|
|
4104
4527
|
permission,
|
|
4105
|
-
grantedByEmail: ctx.userEmail
|
|
4528
|
+
grantedByEmail: ctx.userEmail,
|
|
4529
|
+
callerPermission
|
|
4106
4530
|
});
|
|
4107
4531
|
const label = permission === "admin" ? "admin" : permission === "write" ? "editor" : "viewer";
|
|
4108
4532
|
return `Shared this nest with **${args.email}** as ${label}.`;
|
|
@@ -4111,7 +4535,7 @@ ${list}`;
|
|
|
4111
4535
|
}
|
|
4112
4536
|
}
|
|
4113
4537
|
case "context_unsynced_list": {
|
|
4114
|
-
if (config.AUTH_MODE !== "open" && !isLicenseAdminUserId(userId)) {
|
|
4538
|
+
if (config.AUTH_MODE !== "open" && !await isLicenseAdminUserId(userId)) {
|
|
4115
4539
|
return "You don't have permission to list unsynced folders. Server admin only.";
|
|
4116
4540
|
}
|
|
4117
4541
|
const folders = listUnsyncedFolders();
|
|
@@ -4124,7 +4548,7 @@ ${list}`;
|
|
|
4124
4548
|
${list}`;
|
|
4125
4549
|
}
|
|
4126
4550
|
case "context_sync_folder": {
|
|
4127
|
-
if (config.AUTH_MODE !== "open" && !isLicenseAdminUserId(userId)) {
|
|
4551
|
+
if (config.AUTH_MODE !== "open" && !await isLicenseAdminUserId(userId)) {
|
|
4128
4552
|
return "You don't have permission to sync folders. Server admin only.";
|
|
4129
4553
|
}
|
|
4130
4554
|
try {
|
|
@@ -4146,9 +4570,9 @@ ${list}`;
|
|
|
4146
4570
|
// src/mcp/routes.ts
|
|
4147
4571
|
import { z } from "zod";
|
|
4148
4572
|
var mcpRoutes = new Hono7();
|
|
4149
|
-
function getUserEmail2(userId) {
|
|
4573
|
+
async function getUserEmail2(userId) {
|
|
4150
4574
|
const db = getDb();
|
|
4151
|
-
const user = db.
|
|
4575
|
+
const user = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
|
|
4152
4576
|
return user?.email || "anonymous@localhost";
|
|
4153
4577
|
}
|
|
4154
4578
|
function createMcpServerForNest(nestId, userId, userEmail) {
|
|
@@ -4156,7 +4580,6 @@ function createMcpServerForNest(nestId, userId, userEmail) {
|
|
|
4156
4580
|
{ name: `contextnest-${nestId}`, version: "1.0.0" },
|
|
4157
4581
|
{ capabilities: { tools: {} } }
|
|
4158
4582
|
);
|
|
4159
|
-
const engine = engineCache.get(nestId);
|
|
4160
4583
|
for (const tool of TOOL_DEFINITIONS) {
|
|
4161
4584
|
const props = tool.inputSchema.properties || {};
|
|
4162
4585
|
const required = tool.inputSchema.required || [];
|
|
@@ -4171,6 +4594,7 @@ function createMcpServerForNest(nestId, userId, userEmail) {
|
|
|
4171
4594
|
shape[key] = field;
|
|
4172
4595
|
}
|
|
4173
4596
|
server.tool(tool.name, tool.description, shape, async (args) => {
|
|
4597
|
+
const engine = await engineCache.get(nestId);
|
|
4174
4598
|
const text = await handleToolCall(tool.name, args, {
|
|
4175
4599
|
storage: engine.storage,
|
|
4176
4600
|
queryEngine: engine.query,
|
|
@@ -4187,7 +4611,7 @@ function createMcpServerForNest(nestId, userId, userEmail) {
|
|
|
4187
4611
|
mcpRoutes.all("/", async (c) => {
|
|
4188
4612
|
const nestId = c.req.param("nestId");
|
|
4189
4613
|
const userId = c.get("userId");
|
|
4190
|
-
const userEmail = getUserEmail2(userId);
|
|
4614
|
+
const userEmail = await getUserEmail2(userId);
|
|
4191
4615
|
const server = createMcpServerForNest(nestId, userId, userEmail);
|
|
4192
4616
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
4193
4617
|
sessionIdGenerator: void 0,
|
|
@@ -4208,41 +4632,43 @@ import { Hono as Hono8 } from "hono";
|
|
|
4208
4632
|
|
|
4209
4633
|
// src/governance/comment-service.ts
|
|
4210
4634
|
import { v4 as uuid4 } from "uuid";
|
|
4211
|
-
function createComment(params) {
|
|
4635
|
+
async function createComment(params) {
|
|
4212
4636
|
const db = getDb();
|
|
4213
4637
|
const body = (params.body ?? "").trim();
|
|
4214
4638
|
if (!body) {
|
|
4215
4639
|
throw new Error("Comment body is required");
|
|
4216
4640
|
}
|
|
4217
4641
|
if (params.parentId) {
|
|
4218
|
-
const parent = db.
|
|
4219
|
-
"SELECT id FROM comments WHERE id = ? AND nest_id = ? AND node_id = ?"
|
|
4220
|
-
|
|
4642
|
+
const parent = await db.get(
|
|
4643
|
+
"SELECT id FROM comments WHERE id = ? AND nest_id = ? AND node_id = ?",
|
|
4644
|
+
[params.parentId, params.nestId, params.nodeId]
|
|
4645
|
+
);
|
|
4221
4646
|
if (!parent) {
|
|
4222
4647
|
throw new Error("Parent comment not found on this node");
|
|
4223
4648
|
}
|
|
4224
4649
|
}
|
|
4225
4650
|
const id = uuid4();
|
|
4226
|
-
db.
|
|
4651
|
+
await db.run(
|
|
4227
4652
|
`INSERT INTO comments
|
|
4228
4653
|
(id, nest_id, node_id, version, anchor_start, anchor_end, anchor_text,
|
|
4229
4654
|
parent_id, author, body)
|
|
4230
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
4231
|
-
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4655
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
4656
|
+
[
|
|
4657
|
+
id,
|
|
4658
|
+
params.nestId,
|
|
4659
|
+
params.nodeId,
|
|
4660
|
+
params.version ?? null,
|
|
4661
|
+
params.anchor?.start ?? null,
|
|
4662
|
+
params.anchor?.end ?? null,
|
|
4663
|
+
params.anchor?.text ?? null,
|
|
4664
|
+
params.parentId ?? null,
|
|
4665
|
+
params.author,
|
|
4666
|
+
body
|
|
4667
|
+
]
|
|
4242
4668
|
);
|
|
4243
|
-
return getComment(id);
|
|
4669
|
+
return await getComment(id);
|
|
4244
4670
|
}
|
|
4245
|
-
function listComments(nestId, nodeId, opts = {}) {
|
|
4671
|
+
async function listComments(nestId, nodeId, opts = {}) {
|
|
4246
4672
|
const db = getDb();
|
|
4247
4673
|
const args = [nestId, nodeId];
|
|
4248
4674
|
let statusClause = "";
|
|
@@ -4250,35 +4676,38 @@ function listComments(nestId, nodeId, opts = {}) {
|
|
|
4250
4676
|
statusClause = " AND status = ?";
|
|
4251
4677
|
args.push(opts.status);
|
|
4252
4678
|
}
|
|
4253
|
-
const rows = db.
|
|
4679
|
+
const rows = await db.all(
|
|
4254
4680
|
`SELECT * FROM comments
|
|
4255
4681
|
WHERE nest_id = ? AND node_id = ?${statusClause}
|
|
4256
|
-
ORDER BY created_at ASC
|
|
4257
|
-
|
|
4682
|
+
ORDER BY created_at ASC`,
|
|
4683
|
+
args
|
|
4684
|
+
);
|
|
4258
4685
|
return rows.map(rowToComment);
|
|
4259
4686
|
}
|
|
4260
|
-
function getComment(id) {
|
|
4687
|
+
async function getComment(id) {
|
|
4261
4688
|
const db = getDb();
|
|
4262
|
-
const row = db.
|
|
4689
|
+
const row = await db.get("SELECT * FROM comments WHERE id = ?", [id]);
|
|
4263
4690
|
return row ? rowToComment(row) : null;
|
|
4264
4691
|
}
|
|
4265
|
-
function resolveComment(params) {
|
|
4692
|
+
async function resolveComment(params) {
|
|
4266
4693
|
const db = getDb();
|
|
4267
|
-
const existing = db.
|
|
4268
|
-
"SELECT id, status FROM comments WHERE id = ? AND nest_id = ? AND node_id = ?"
|
|
4269
|
-
|
|
4694
|
+
const existing = await db.get(
|
|
4695
|
+
"SELECT id, status FROM comments WHERE id = ? AND nest_id = ? AND node_id = ?",
|
|
4696
|
+
[params.commentId, params.nestId, params.nodeId]
|
|
4697
|
+
);
|
|
4270
4698
|
if (!existing) {
|
|
4271
4699
|
throw new Error("Comment not found");
|
|
4272
4700
|
}
|
|
4273
4701
|
if (existing.status === "resolved") {
|
|
4274
4702
|
throw new Error("Comment is already resolved");
|
|
4275
4703
|
}
|
|
4276
|
-
db.
|
|
4704
|
+
await db.run(
|
|
4277
4705
|
`UPDATE comments
|
|
4278
|
-
SET status = 'resolved', resolved_by = ?, resolved_at =
|
|
4279
|
-
WHERE id =
|
|
4280
|
-
|
|
4281
|
-
|
|
4706
|
+
SET status = 'resolved', resolved_by = ?, resolved_at = ${nowExpr(db)}
|
|
4707
|
+
WHERE id = ?`,
|
|
4708
|
+
[params.resolvedBy, params.commentId]
|
|
4709
|
+
);
|
|
4710
|
+
return await getComment(params.commentId);
|
|
4282
4711
|
}
|
|
4283
4712
|
async function getActivity(params) {
|
|
4284
4713
|
const db = getDb();
|
|
@@ -4286,13 +4715,14 @@ async function getActivity(params) {
|
|
|
4286
4715
|
const entries = [];
|
|
4287
4716
|
const nodeFilter = params.nodeId ? " AND node_id = ?" : "";
|
|
4288
4717
|
const baseArgs = params.nodeId ? [params.nestId, params.nodeId] : [params.nestId];
|
|
4289
|
-
const commentRows = db.
|
|
4718
|
+
const commentRows = await db.all(
|
|
4290
4719
|
`SELECT id, node_id, author, body, status, created_at, resolved_by, resolved_at
|
|
4291
4720
|
FROM comments
|
|
4292
4721
|
WHERE nest_id = ?${nodeFilter}
|
|
4293
4722
|
ORDER BY COALESCE(resolved_at, created_at) DESC
|
|
4294
|
-
LIMIT
|
|
4295
|
-
|
|
4723
|
+
LIMIT ?`,
|
|
4724
|
+
[...baseArgs, limit]
|
|
4725
|
+
);
|
|
4296
4726
|
for (const r of commentRows) {
|
|
4297
4727
|
entries.push({
|
|
4298
4728
|
type: "comment",
|
|
@@ -4313,13 +4743,14 @@ async function getActivity(params) {
|
|
|
4313
4743
|
});
|
|
4314
4744
|
}
|
|
4315
4745
|
}
|
|
4316
|
-
const versionRows = db.
|
|
4746
|
+
const versionRows = await db.all(
|
|
4317
4747
|
`SELECT node_id, version, author, change_note, created_at
|
|
4318
4748
|
FROM node_versions
|
|
4319
4749
|
WHERE nest_id = ?${nodeFilter}
|
|
4320
4750
|
ORDER BY created_at DESC
|
|
4321
|
-
LIMIT
|
|
4322
|
-
|
|
4751
|
+
LIMIT ?`,
|
|
4752
|
+
[...baseArgs, limit]
|
|
4753
|
+
);
|
|
4323
4754
|
for (const r of versionRows) {
|
|
4324
4755
|
entries.push({
|
|
4325
4756
|
type: "edit",
|
|
@@ -4330,13 +4761,14 @@ async function getActivity(params) {
|
|
|
4330
4761
|
refId: String(r.version)
|
|
4331
4762
|
});
|
|
4332
4763
|
}
|
|
4333
|
-
const reviewRows = db.
|
|
4764
|
+
const reviewRows = await db.all(
|
|
4334
4765
|
`SELECT id, node_id, requested_by, requested_at, status, resolved_by, resolved_at
|
|
4335
4766
|
FROM review_requests
|
|
4336
4767
|
WHERE nest_id = ?${nodeFilter}
|
|
4337
4768
|
ORDER BY COALESCE(resolved_at, requested_at) DESC
|
|
4338
|
-
LIMIT
|
|
4339
|
-
|
|
4769
|
+
LIMIT ?`,
|
|
4770
|
+
[...baseArgs, limit]
|
|
4771
|
+
);
|
|
4340
4772
|
for (const r of reviewRows) {
|
|
4341
4773
|
entries.push({
|
|
4342
4774
|
type: "review_requested",
|
|
@@ -4479,8 +4911,7 @@ function parseEntry(str) {
|
|
|
4479
4911
|
return entry;
|
|
4480
4912
|
}
|
|
4481
4913
|
function loadStewardsConfig(nestId) {
|
|
4482
|
-
const
|
|
4483
|
-
const nestPath = join3(dataRoot, "nests", nestId);
|
|
4914
|
+
const nestPath = resolveNestPath(nestId);
|
|
4484
4915
|
const candidates = [
|
|
4485
4916
|
join3(nestPath, "stewards.yaml"),
|
|
4486
4917
|
join3(nestPath, "stewards.yml"),
|
|
@@ -4512,24 +4943,25 @@ governanceRoutes.get("/stewards", async (c) => {
|
|
|
4512
4943
|
search: search || void 0
|
|
4513
4944
|
});
|
|
4514
4945
|
const cache = /* @__PURE__ */ new Map();
|
|
4515
|
-
const enriched =
|
|
4946
|
+
const enriched = [];
|
|
4947
|
+
for (const s of stewards) {
|
|
4516
4948
|
const key = s.userEmail.toLowerCase();
|
|
4517
4949
|
let merged = cache.get(key);
|
|
4518
4950
|
if (!merged) {
|
|
4519
4951
|
merged = {
|
|
4520
|
-
collaboratorRole: getCollaboratorRole(nestId, s.userEmail),
|
|
4521
|
-
roles: resolveUserRoles(nestId, s.userEmail)
|
|
4952
|
+
collaboratorRole: await getCollaboratorRole(nestId, s.userEmail),
|
|
4953
|
+
roles: await resolveUserRoles(nestId, s.userEmail)
|
|
4522
4954
|
};
|
|
4523
4955
|
cache.set(key, merged);
|
|
4524
4956
|
}
|
|
4525
|
-
|
|
4526
|
-
}
|
|
4957
|
+
enriched.push({ ...s, ...merged });
|
|
4958
|
+
}
|
|
4527
4959
|
return c.json({ stewards: enriched });
|
|
4528
4960
|
});
|
|
4529
4961
|
governanceRoutes.post("/stewards", async (c) => {
|
|
4530
4962
|
const nestId = c.req.param("nestId");
|
|
4531
4963
|
const body = await c.req.json();
|
|
4532
|
-
const assignedBy = getUserEmail3(c);
|
|
4964
|
+
const assignedBy = await getUserEmail3(c);
|
|
4533
4965
|
if (!body.scope) throw new ValidationError("scope is required");
|
|
4534
4966
|
if (body.scope === "folder") {
|
|
4535
4967
|
throw new ValidationError(
|
|
@@ -4571,7 +5003,7 @@ governanceRoutes.patch("/stewards/:stewardId", async (c) => {
|
|
|
4571
5003
|
if (!body.role && !body.scope) {
|
|
4572
5004
|
throw new ValidationError("role or scope is required");
|
|
4573
5005
|
}
|
|
4574
|
-
const steward = updateSteward(stewardId, {
|
|
5006
|
+
const steward = await updateSteward(stewardId, {
|
|
4575
5007
|
role: body.role,
|
|
4576
5008
|
scope: body.scope,
|
|
4577
5009
|
documentId: body.nodePattern,
|
|
@@ -4581,7 +5013,7 @@ governanceRoutes.patch("/stewards/:stewardId", async (c) => {
|
|
|
4581
5013
|
});
|
|
4582
5014
|
governanceRoutes.delete("/stewards/:stewardId", async (c) => {
|
|
4583
5015
|
const stewardId = c.req.param("stewardId");
|
|
4584
|
-
removeSteward(stewardId);
|
|
5016
|
+
await removeSteward(stewardId);
|
|
4585
5017
|
return c.json({ removed: true });
|
|
4586
5018
|
});
|
|
4587
5019
|
governanceRoutes.post("/stewards/sync", async (c) => {
|
|
@@ -4590,7 +5022,7 @@ governanceRoutes.post("/stewards/sync", async (c) => {
|
|
|
4590
5022
|
if (!stewardsConfig) {
|
|
4591
5023
|
return c.json({ synced: 0, message: "No stewards.yaml found" });
|
|
4592
5024
|
}
|
|
4593
|
-
const count = syncFromConfig(nestId, stewardsConfig);
|
|
5025
|
+
const count = await syncFromConfig(nestId, stewardsConfig);
|
|
4594
5026
|
return c.json({ synced: count });
|
|
4595
5027
|
});
|
|
4596
5028
|
governanceRoutes.get("/review-queue", async (c) => {
|
|
@@ -4604,16 +5036,17 @@ governanceRoutes.get("/review-queue", async (c) => {
|
|
|
4604
5036
|
limit,
|
|
4605
5037
|
offset
|
|
4606
5038
|
});
|
|
4607
|
-
const email = getUserEmail3(c);
|
|
5039
|
+
const email = await getUserEmail3(c);
|
|
4608
5040
|
const canReviewCache = /* @__PURE__ */ new Map();
|
|
4609
|
-
const requests =
|
|
5041
|
+
const requests = [];
|
|
5042
|
+
for (const r of result.requests) {
|
|
4610
5043
|
let canReview = canReviewCache.get(r.nodeId);
|
|
4611
5044
|
if (canReview === void 0) {
|
|
4612
|
-
canReview = canUserApprove(nestId, r.nodeId, email).allowed;
|
|
5045
|
+
canReview = (await canUserApprove(nestId, r.nodeId, email)).allowed;
|
|
4613
5046
|
canReviewCache.set(r.nodeId, canReview);
|
|
4614
5047
|
}
|
|
4615
|
-
|
|
4616
|
-
}
|
|
5048
|
+
requests.push({ ...r, canReview });
|
|
5049
|
+
}
|
|
4617
5050
|
return c.json({ ...result, requests });
|
|
4618
5051
|
});
|
|
4619
5052
|
governanceRoutes.get("/external-edits", async (c) => {
|
|
@@ -4627,7 +5060,7 @@ governanceRoutes.get("/external-edits", async (c) => {
|
|
|
4627
5060
|
});
|
|
4628
5061
|
governanceRoutes.post("/external-edits/scan", async (c) => {
|
|
4629
5062
|
const nestId = c.req.param("nestId");
|
|
4630
|
-
const actor = getUserEmail3(c);
|
|
5063
|
+
const actor = await getUserEmail3(c);
|
|
4631
5064
|
const result = await scanNestForDrift(nestId, actor);
|
|
4632
5065
|
return c.json(result);
|
|
4633
5066
|
});
|
|
@@ -4641,7 +5074,7 @@ var governanceNodeRoutes = new Hono8();
|
|
|
4641
5074
|
governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
|
|
4642
5075
|
const nestId = c.req.param("nestId");
|
|
4643
5076
|
const nodeId = c.req.param("nodeId");
|
|
4644
|
-
const { stewards: resolved, fallbackToOwner, ownerEmail } = resolveStewardsWithFallback(nestId, nodeId);
|
|
5077
|
+
const { stewards: resolved, fallbackToOwner, ownerEmail } = await resolveStewardsWithFallback(nestId, nodeId);
|
|
4645
5078
|
return c.json({
|
|
4646
5079
|
nodeId,
|
|
4647
5080
|
stewards: resolved.map((r) => ({
|
|
@@ -4658,9 +5091,9 @@ governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
|
|
|
4658
5091
|
governanceNodeRoutes.get("/:nodeId{.+}/versions", async (c) => {
|
|
4659
5092
|
const nestId = c.req.param("nestId");
|
|
4660
5093
|
const nodeId = c.req.param("nodeId");
|
|
4661
|
-
const allVersions = getVersions(nestId, nodeId);
|
|
4662
|
-
const approved = getApprovedVersion(nestId, nodeId);
|
|
4663
|
-
const { versions: versionManager } = engineCache.get(nestId);
|
|
5094
|
+
const allVersions = await getVersions(nestId, nodeId);
|
|
5095
|
+
const approved = await getApprovedVersion(nestId, nodeId);
|
|
5096
|
+
const { versions: versionManager } = await engineCache.get(nestId);
|
|
4664
5097
|
const withContent = await Promise.all(
|
|
4665
5098
|
allVersions.map(async (v) => {
|
|
4666
5099
|
try {
|
|
@@ -4680,14 +5113,14 @@ governanceNodeRoutes.get("/:nodeId{.+}/versions", async (c) => {
|
|
|
4680
5113
|
governanceNodeRoutes.get("/:nodeId{.+}/reviews", async (c) => {
|
|
4681
5114
|
const nestId = c.req.param("nestId");
|
|
4682
5115
|
const nodeId = c.req.param("nodeId");
|
|
4683
|
-
const history = getReviewHistory(nestId, nodeId);
|
|
5116
|
+
const history = await getReviewHistory(nestId, nodeId);
|
|
4684
5117
|
return c.json({ reviews: history });
|
|
4685
5118
|
});
|
|
4686
5119
|
governanceNodeRoutes.get("/:nodeId{.+}/comments", async (c) => {
|
|
4687
5120
|
const nestId = c.req.param("nestId");
|
|
4688
5121
|
const nodeId = c.req.param("nodeId");
|
|
4689
5122
|
const status = c.req.query("status");
|
|
4690
|
-
const list = listComments(nestId, nodeId, {
|
|
5123
|
+
const list = await listComments(nestId, nodeId, {
|
|
4691
5124
|
status: status === "open" || status === "resolved" ? status : void 0
|
|
4692
5125
|
});
|
|
4693
5126
|
return c.json({ comments: list });
|
|
@@ -4696,9 +5129,9 @@ governanceNodeRoutes.post("/:nodeId{.+}/comments", async (c) => {
|
|
|
4696
5129
|
const nestId = c.req.param("nestId");
|
|
4697
5130
|
const nodeId = c.req.param("nodeId");
|
|
4698
5131
|
const body = await c.req.json();
|
|
4699
|
-
const author = getUserEmail3(c);
|
|
5132
|
+
const author = await getUserEmail3(c);
|
|
4700
5133
|
try {
|
|
4701
|
-
const comment = createComment({
|
|
5134
|
+
const comment = await createComment({
|
|
4702
5135
|
nestId,
|
|
4703
5136
|
nodeId,
|
|
4704
5137
|
author,
|
|
@@ -4720,9 +5153,9 @@ governanceNodeRoutes.post(
|
|
|
4720
5153
|
const nestId = c.req.param("nestId");
|
|
4721
5154
|
const nodeId = c.req.param("nodeId");
|
|
4722
5155
|
const commentId = c.req.param("commentId");
|
|
4723
|
-
const resolvedBy = getUserEmail3(c);
|
|
5156
|
+
const resolvedBy = await getUserEmail3(c);
|
|
4724
5157
|
try {
|
|
4725
|
-
const comment = resolveComment({
|
|
5158
|
+
const comment = await resolveComment({
|
|
4726
5159
|
nestId,
|
|
4727
5160
|
nodeId,
|
|
4728
5161
|
commentId,
|
|
@@ -4749,14 +5182,14 @@ governanceNodeRoutes.post("/:nodeId{.+}/submit-review", async (c) => {
|
|
|
4749
5182
|
const nestId = c.req.param("nestId");
|
|
4750
5183
|
const nodeId = c.req.param("nodeId");
|
|
4751
5184
|
const body = await c.req.json();
|
|
4752
|
-
const currentVersion = getCurrentVersion(nestId, nodeId);
|
|
5185
|
+
const currentVersion = await getCurrentVersion(nestId, nodeId);
|
|
4753
5186
|
if (currentVersion === 0) {
|
|
4754
5187
|
throw new ValidationError("Node has no versions to review");
|
|
4755
5188
|
}
|
|
4756
|
-
const userEmail = getUserEmail3(c);
|
|
5189
|
+
const userEmail = await getUserEmail3(c);
|
|
4757
5190
|
let request;
|
|
4758
5191
|
try {
|
|
4759
|
-
request = submitForReview({
|
|
5192
|
+
request = await submitForReview({
|
|
4760
5193
|
nestId,
|
|
4761
5194
|
nodeId,
|
|
4762
5195
|
version: currentVersion,
|
|
@@ -4771,7 +5204,7 @@ governanceNodeRoutes.post("/:nodeId{.+}/submit-review", async (c) => {
|
|
|
4771
5204
|
}
|
|
4772
5205
|
throw err;
|
|
4773
5206
|
}
|
|
4774
|
-
const resolved = resolveStewardsForNode(nestId, nodeId);
|
|
5207
|
+
const resolved = await resolveStewardsForNode(nestId, nodeId);
|
|
4775
5208
|
return c.json(
|
|
4776
5209
|
{
|
|
4777
5210
|
review: request,
|
|
@@ -4788,13 +5221,13 @@ governanceNodeRoutes.post("/:nodeId{.+}/approve", async (c) => {
|
|
|
4788
5221
|
const nestId = c.req.param("nestId");
|
|
4789
5222
|
const nodeId = c.req.param("nodeId");
|
|
4790
5223
|
const body = await c.req.json();
|
|
4791
|
-
const userEmail = getUserEmail3(c);
|
|
5224
|
+
const userEmail = await getUserEmail3(c);
|
|
4792
5225
|
const isAdmin = isSuperAdmin(userEmail);
|
|
4793
5226
|
try {
|
|
4794
5227
|
const request = await approve({
|
|
4795
5228
|
nestId,
|
|
4796
5229
|
nodeId,
|
|
4797
|
-
version: getCurrentVersion(nestId, nodeId),
|
|
5230
|
+
version: await getCurrentVersion(nestId, nodeId),
|
|
4798
5231
|
approvedBy: userEmail,
|
|
4799
5232
|
note: body.note,
|
|
4800
5233
|
override: body.override && isAdmin
|
|
@@ -4811,12 +5244,12 @@ governanceNodeRoutes.post("/:nodeId{.+}/reject", async (c) => {
|
|
|
4811
5244
|
if (!body.note) {
|
|
4812
5245
|
throw new ValidationError("Rejection note is required");
|
|
4813
5246
|
}
|
|
4814
|
-
const userEmail = getUserEmail3(c);
|
|
5247
|
+
const userEmail = await getUserEmail3(c);
|
|
4815
5248
|
try {
|
|
4816
|
-
const request = reject({
|
|
5249
|
+
const request = await reject({
|
|
4817
5250
|
nestId,
|
|
4818
5251
|
nodeId,
|
|
4819
|
-
version: getCurrentVersion(nestId, nodeId),
|
|
5252
|
+
version: await getCurrentVersion(nestId, nodeId),
|
|
4820
5253
|
rejectedBy: userEmail,
|
|
4821
5254
|
note: body.note
|
|
4822
5255
|
});
|
|
@@ -4828,20 +5261,20 @@ governanceNodeRoutes.post("/:nodeId{.+}/reject", async (c) => {
|
|
|
4828
5261
|
governanceNodeRoutes.get("/:nodeId{.+}/can-access", async (c) => {
|
|
4829
5262
|
const nestId = c.req.param("nestId");
|
|
4830
5263
|
const nodeId = c.req.param("nodeId");
|
|
4831
|
-
const userEmail = getUserEmail3(c);
|
|
4832
|
-
return c.json(canUserAccess(nestId, nodeId, userEmail));
|
|
5264
|
+
const userEmail = await getUserEmail3(c);
|
|
5265
|
+
return c.json(await canUserAccess(nestId, nodeId, userEmail));
|
|
4833
5266
|
});
|
|
4834
5267
|
governanceNodeRoutes.get("/:nodeId{.+}/can-approve", async (c) => {
|
|
4835
5268
|
const nestId = c.req.param("nestId");
|
|
4836
5269
|
const nodeId = c.req.param("nodeId");
|
|
4837
|
-
const userEmail = getUserEmail3(c);
|
|
4838
|
-
return c.json(canUserApprove(nestId, nodeId, userEmail));
|
|
5270
|
+
const userEmail = await getUserEmail3(c);
|
|
5271
|
+
return c.json(await canUserApprove(nestId, nodeId, userEmail));
|
|
4839
5272
|
});
|
|
4840
5273
|
governanceNodeRoutes.get("/:nodeId{.+}/can-edit", async (c) => {
|
|
4841
5274
|
const nestId = c.req.param("nestId");
|
|
4842
5275
|
const nodeId = c.req.param("nodeId");
|
|
4843
|
-
const userEmail = getUserEmail3(c);
|
|
4844
|
-
return c.json(canUserEdit(nestId, nodeId, userEmail));
|
|
5276
|
+
const userEmail = await getUserEmail3(c);
|
|
5277
|
+
return c.json(await canUserEdit(nestId, nodeId, userEmail));
|
|
4845
5278
|
});
|
|
4846
5279
|
governanceNodeRoutes.get("/:nodeId{.+?}/external-edits", async (c) => {
|
|
4847
5280
|
const nestId = c.req.param("nestId");
|
|
@@ -4873,7 +5306,7 @@ governanceNodeRoutes.post(
|
|
|
4873
5306
|
const nodeId = c.req.param("nodeId");
|
|
4874
5307
|
const suggestionId = c.req.param("suggestionId");
|
|
4875
5308
|
const body = await c.req.json().catch(() => ({}));
|
|
4876
|
-
const actor = getUserEmail3(c);
|
|
5309
|
+
const actor = await getUserEmail3(c);
|
|
4877
5310
|
try {
|
|
4878
5311
|
const result = await approveExternalEdit({
|
|
4879
5312
|
nestId,
|
|
@@ -4909,7 +5342,7 @@ governanceNodeRoutes.post(
|
|
|
4909
5342
|
if (!body.reason) {
|
|
4910
5343
|
throw new ValidationError("Rejection reason is required");
|
|
4911
5344
|
}
|
|
4912
|
-
const actor = getUserEmail3(c);
|
|
5345
|
+
const actor = await getUserEmail3(c);
|
|
4913
5346
|
try {
|
|
4914
5347
|
const result = await rejectExternalEdit({
|
|
4915
5348
|
nestId,
|
|
@@ -4930,31 +5363,38 @@ governanceNodeRoutes.post(
|
|
|
4930
5363
|
governanceNodeRoutes.post("/:nodeId{.+}/cancel-review", async (c) => {
|
|
4931
5364
|
const nestId = c.req.param("nestId");
|
|
4932
5365
|
const nodeId = c.req.param("nodeId");
|
|
4933
|
-
const userEmail = getUserEmail3(c);
|
|
4934
|
-
const request = cancelReview({
|
|
5366
|
+
const userEmail = await getUserEmail3(c);
|
|
5367
|
+
const request = await cancelReview({
|
|
4935
5368
|
nestId,
|
|
4936
5369
|
nodeId,
|
|
4937
5370
|
cancelledBy: userEmail
|
|
4938
5371
|
});
|
|
4939
5372
|
return c.json({ review: request });
|
|
4940
5373
|
});
|
|
4941
|
-
function getUserEmail3(c) {
|
|
5374
|
+
async function getUserEmail3(c) {
|
|
4942
5375
|
const userId = c.get("userId");
|
|
4943
5376
|
const db = getDb();
|
|
4944
|
-
const user = db.
|
|
5377
|
+
const user = await db.get(
|
|
5378
|
+
"SELECT email FROM users WHERE id = ?",
|
|
5379
|
+
[userId]
|
|
5380
|
+
);
|
|
4945
5381
|
return user?.email || "anonymous@localhost";
|
|
4946
5382
|
}
|
|
4947
5383
|
|
|
4948
5384
|
// src/auth/anonymous.ts
|
|
4949
5385
|
import bcrypt from "bcryptjs";
|
|
4950
|
-
function ensureAnonymousUser() {
|
|
5386
|
+
async function ensureAnonymousUser() {
|
|
4951
5387
|
const db = getDb();
|
|
4952
|
-
const exists = db.
|
|
5388
|
+
const exists = await db.get(
|
|
5389
|
+
"SELECT id FROM users WHERE id = ?",
|
|
5390
|
+
[ANON_USER_ID]
|
|
5391
|
+
);
|
|
4953
5392
|
if (!exists) {
|
|
4954
5393
|
const placeholder = bcrypt.hashSync("anon-no-login", 4);
|
|
4955
|
-
db.
|
|
4956
|
-
"INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)"
|
|
4957
|
-
|
|
5394
|
+
await db.run(
|
|
5395
|
+
"INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)",
|
|
5396
|
+
[ANON_USER_ID, ANON_EMAIL, "Admin", placeholder]
|
|
5397
|
+
);
|
|
4958
5398
|
}
|
|
4959
5399
|
return ANON_USER_ID;
|
|
4960
5400
|
}
|
|
@@ -4972,7 +5412,7 @@ var UI_DIR_CANDIDATES = [
|
|
|
4972
5412
|
var UI_DIR_ABS = UI_DIR_CANDIDATES.find((p) => existsSync2(p)) || UI_DIR_CANDIDATES[0];
|
|
4973
5413
|
var UI_DIR_REL = relative2(process.cwd(), UI_DIR_ABS) || ".";
|
|
4974
5414
|
var openModeMiddleware = createMiddleware2(async (c, next) => {
|
|
4975
|
-
const anonId = ensureAnonymousUser();
|
|
5415
|
+
const anonId = await ensureAnonymousUser();
|
|
4976
5416
|
c.set("userId", anonId);
|
|
4977
5417
|
c.set("nestScope", null);
|
|
4978
5418
|
await next();
|
|
@@ -4980,6 +5420,7 @@ var openModeMiddleware = createMiddleware2(async (c, next) => {
|
|
|
4980
5420
|
function isPublicReadEligiblePath(method, path) {
|
|
4981
5421
|
if (method !== "GET") return false;
|
|
4982
5422
|
if (!/^\/nests\/[^/]+(\/.*)?$/.test(path)) return false;
|
|
5423
|
+
if (/^\/nests\/[^/]+\/stewards/.test(path)) return false;
|
|
4983
5424
|
return !/\/(collaborators|visibility|settings|mcp)/.test(path);
|
|
4984
5425
|
}
|
|
4985
5426
|
var flexAuthMiddleware = createMiddleware2(async (c, next) => {
|
|
@@ -4989,13 +5430,13 @@ var flexAuthMiddleware = createMiddleware2(async (c, next) => {
|
|
|
4989
5430
|
return authMiddleware(c, next);
|
|
4990
5431
|
}
|
|
4991
5432
|
if (config.AUTH_MODE === "open") {
|
|
4992
|
-
const anonId = ensureAnonymousUser();
|
|
5433
|
+
const anonId = await ensureAnonymousUser();
|
|
4993
5434
|
c.set("userId", anonId);
|
|
4994
5435
|
c.set("nestScope", null);
|
|
4995
5436
|
return next();
|
|
4996
5437
|
}
|
|
4997
5438
|
if (isPublicReadEligiblePath(c.req.method, c.req.path)) {
|
|
4998
|
-
const anonId = ensureAnonymousUser();
|
|
5439
|
+
const anonId = await ensureAnonymousUser();
|
|
4999
5440
|
c.set("userId", anonId);
|
|
5000
5441
|
c.set("nestScope", null);
|
|
5001
5442
|
return next();
|
|
@@ -5117,7 +5558,7 @@ function createApp() {
|
|
|
5117
5558
|
}
|
|
5118
5559
|
});
|
|
5119
5560
|
app.use("/admin/settings", flexAuthMiddleware);
|
|
5120
|
-
const adminSettingsAllowed = (c) => config.AUTH_MODE === "open" || isLicenseAdminUserId(c.get("userId"));
|
|
5561
|
+
const adminSettingsAllowed = async (c) => config.AUTH_MODE === "open" || await isLicenseAdminUserId(c.get("userId"));
|
|
5121
5562
|
const currentServerSettings = () => ({
|
|
5122
5563
|
promptowl_sign_in_gate: config.PROMPTOWL_SIGN_IN_GATE,
|
|
5123
5564
|
logo_url: config.LOGO_URL,
|
|
@@ -5125,13 +5566,13 @@ function createApp() {
|
|
|
5125
5566
|
public_base_url: config.PUBLIC_BASE_URL,
|
|
5126
5567
|
max_body_bytes: config.MAX_BODY_BYTES
|
|
5127
5568
|
});
|
|
5128
|
-
app.get("/admin/settings", (c) => {
|
|
5129
|
-
if (!adminSettingsAllowed(c))
|
|
5569
|
+
app.get("/admin/settings", async (c) => {
|
|
5570
|
+
if (!await adminSettingsAllowed(c))
|
|
5130
5571
|
return c.json({ error: "Only the server admin can view this." }, 403);
|
|
5131
5572
|
return c.json(currentServerSettings());
|
|
5132
5573
|
});
|
|
5133
5574
|
app.patch("/admin/settings", async (c) => {
|
|
5134
|
-
if (!adminSettingsAllowed(c))
|
|
5575
|
+
if (!await adminSettingsAllowed(c))
|
|
5135
5576
|
return c.json({ error: "Only the server admin can change this." }, 403);
|
|
5136
5577
|
let body;
|
|
5137
5578
|
try {
|
|
@@ -5187,18 +5628,22 @@ function createApp() {
|
|
|
5187
5628
|
app.get("/stats", async (c) => {
|
|
5188
5629
|
const db = getDb();
|
|
5189
5630
|
const userId = c.get("userId");
|
|
5190
|
-
const userEmail = resolveCallerEmail(userId);
|
|
5191
|
-
const
|
|
5631
|
+
const userEmail = await resolveCallerEmail(userId);
|
|
5632
|
+
const owned = await listNests(userId);
|
|
5633
|
+
const sharedNests = await listSharedNests(userId);
|
|
5634
|
+
const visibleNests = [...owned, ...sharedNests];
|
|
5192
5635
|
let documents = 0;
|
|
5193
5636
|
for (const nest of visibleNests) {
|
|
5194
5637
|
try {
|
|
5195
|
-
const { storage } = engineCache.get(nest.id);
|
|
5638
|
+
const { storage } = await engineCache.get(nest.id);
|
|
5196
5639
|
const docs = await storage.discoverDocuments();
|
|
5197
|
-
documents += filterAccessible(nest.id, userId, userEmail, docs).length;
|
|
5640
|
+
documents += (await filterAccessible(nest.id, userId, userEmail, docs)).length;
|
|
5198
5641
|
} catch {
|
|
5199
5642
|
}
|
|
5200
5643
|
}
|
|
5201
|
-
const usersRow = db.
|
|
5644
|
+
const usersRow = await db.get(
|
|
5645
|
+
"SELECT COUNT(*) as c FROM users"
|
|
5646
|
+
);
|
|
5202
5647
|
return c.json({
|
|
5203
5648
|
nests: visibleNests.length,
|
|
5204
5649
|
documents,
|
|
@@ -5252,7 +5697,7 @@ function createApp() {
|
|
|
5252
5697
|
c.set("nestPermission", "owner");
|
|
5253
5698
|
return next();
|
|
5254
5699
|
}
|
|
5255
|
-
const permission = resolveNestPermission(nestId, userId);
|
|
5700
|
+
const permission = await resolveNestPermission(nestId, userId);
|
|
5256
5701
|
if (permission === "none") {
|
|
5257
5702
|
return c.json({ error: "Nest not found" }, 404);
|
|
5258
5703
|
}
|
|
@@ -5262,10 +5707,10 @@ function createApp() {
|
|
|
5262
5707
|
const isAnnotationAction = /\/annotations$/.test(path) || /\/annotations\/[^/]+\/(comments|resolve|reopen)$/.test(path);
|
|
5263
5708
|
const isCommentAction = /\/comments$/.test(path) || /\/comments\/[^/]+\/resolve$/.test(path);
|
|
5264
5709
|
const isStewardRoster = path.includes("/stewards") && !path.includes("/nodes/");
|
|
5265
|
-
if (isStewardRoster &&
|
|
5710
|
+
if (isStewardRoster && permission !== "owner" && permission !== "admin") {
|
|
5266
5711
|
return c.json(
|
|
5267
5712
|
{
|
|
5268
|
-
error: "You don't have permission to manage stewards. Only the
|
|
5713
|
+
error: "You don't have permission to manage stewards. Only a nest admin, the nest owner, or the server admin can do this."
|
|
5269
5714
|
},
|
|
5270
5715
|
403
|
|
5271
5716
|
);
|
|
@@ -5281,7 +5726,7 @@ function createApp() {
|
|
|
5281
5726
|
const isNodeRevert = c.req.method === "POST" && parts.length >= 4 && parts[parts.length - 1] === "revert";
|
|
5282
5727
|
let stewardEditorBypass = false;
|
|
5283
5728
|
if (required === "write" && permission === "read" && parts[1] === "nodes") {
|
|
5284
|
-
const userEmail = resolveCallerEmail(userId);
|
|
5729
|
+
const userEmail = await resolveCallerEmail(userId);
|
|
5285
5730
|
if (parts.length >= 3 && (c.req.method === "PATCH" || c.req.method === "DELETE" || isNodeRevert)) {
|
|
5286
5731
|
const idParts = isNodeRevert ? parts.slice(2, -1) : parts.slice(2);
|
|
5287
5732
|
const rawNodeId = idParts.join("/");
|
|
@@ -5290,12 +5735,12 @@ function createApp() {
|
|
|
5290
5735
|
nodeId = decodeURIComponent(rawNodeId);
|
|
5291
5736
|
} catch {
|
|
5292
5737
|
}
|
|
5293
|
-
const resolved = resolveStewardsForNode(nestId, nodeId);
|
|
5738
|
+
const resolved = await resolveStewardsForNode(nestId, nodeId);
|
|
5294
5739
|
stewardEditorBypass = resolved.some(
|
|
5295
5740
|
(r) => r.steward.userEmail.toLowerCase() === userEmail.toLowerCase() && r.steward.role === "editor"
|
|
5296
5741
|
);
|
|
5297
5742
|
} else if (parts.length === 2 && c.req.method === "POST") {
|
|
5298
|
-
const resolved = resolveStewardsForNode(nestId, "");
|
|
5743
|
+
const resolved = await resolveStewardsForNode(nestId, "");
|
|
5299
5744
|
stewardEditorBypass = resolved.some(
|
|
5300
5745
|
(r) => r.steward.userEmail.toLowerCase() === userEmail.toLowerCase() && r.steward.role === "editor" && r.steward.scope === "nest"
|
|
5301
5746
|
);
|
|
@@ -5403,28 +5848,30 @@ function createApp() {
|
|
|
5403
5848
|
|
|
5404
5849
|
// src/db/backfill.ts
|
|
5405
5850
|
import { NestStorage } from "@promptowl/contextnest-engine";
|
|
5406
|
-
import { join as join5 } from "path";
|
|
5407
5851
|
var MIGRATION_ID = "005_backfill_node_versions_from_history";
|
|
5408
5852
|
async function backfillNodeVersionsFromHistory(db) {
|
|
5409
|
-
const already = db.
|
|
5853
|
+
const already = await db.get(
|
|
5854
|
+
"SELECT id FROM schema_migrations WHERE id = ?",
|
|
5855
|
+
[MIGRATION_ID]
|
|
5856
|
+
);
|
|
5410
5857
|
if (already) return;
|
|
5411
|
-
const nests = db.
|
|
5412
|
-
const
|
|
5413
|
-
|
|
5858
|
+
const nests = await db.all("SELECT id FROM nests");
|
|
5859
|
+
const insertSql = insertOrIgnore(
|
|
5860
|
+
db,
|
|
5861
|
+
`INSERT INTO node_versions
|
|
5414
5862
|
(nest_id, node_id, version, content_hash, author, status, change_note, created_at)
|
|
5415
5863
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
5416
5864
|
);
|
|
5417
|
-
const
|
|
5418
|
-
`INSERT OR REPLACE INTO approved_versions
|
|
5865
|
+
const approvedPinSql = `INSERT INTO approved_versions
|
|
5419
5866
|
(nest_id, node_id, approved_version, approved_by, approved_at)
|
|
5420
|
-
VALUES (?, ?, ?, ?,
|
|
5421
|
-
|
|
5422
|
-
|
|
5423
|
-
|
|
5867
|
+
VALUES (?, ?, ?, ?, ${nowExpr(db)})
|
|
5868
|
+
ON CONFLICT (nest_id, node_id) DO UPDATE SET
|
|
5869
|
+
approved_version = excluded.approved_version,
|
|
5870
|
+
approved_by = excluded.approved_by`;
|
|
5424
5871
|
let totalInserted = 0;
|
|
5425
5872
|
let totalDocs = 0;
|
|
5426
5873
|
for (const { id: nestId } of nests) {
|
|
5427
|
-
const nestPath =
|
|
5874
|
+
const nestPath = resolveNestPath(nestId);
|
|
5428
5875
|
const storage = new NestStorage(nestPath);
|
|
5429
5876
|
let docs;
|
|
5430
5877
|
try {
|
|
@@ -5445,15 +5892,16 @@ async function backfillNodeVersionsFromHistory(db) {
|
|
|
5445
5892
|
history = null;
|
|
5446
5893
|
}
|
|
5447
5894
|
if (!history || history.versions.length === 0) continue;
|
|
5448
|
-
const existing = db.
|
|
5449
|
-
`SELECT version FROM node_versions WHERE nest_id = ? AND node_id =
|
|
5450
|
-
|
|
5895
|
+
const existing = await db.all(
|
|
5896
|
+
`SELECT version FROM node_versions WHERE nest_id = ? AND node_id = ?`,
|
|
5897
|
+
[nestId, doc.id]
|
|
5898
|
+
);
|
|
5451
5899
|
const known = new Set(existing.map((r) => r.version));
|
|
5452
5900
|
const tagsJson = doc.frontmatter.tags ? JSON.stringify(doc.frontmatter.tags) : null;
|
|
5453
5901
|
const latestVersion = history.versions[history.versions.length - 1].version;
|
|
5454
5902
|
for (const entry of history.versions) {
|
|
5455
5903
|
if (known.has(entry.version)) continue;
|
|
5456
|
-
|
|
5904
|
+
await db.run(insertSql, [
|
|
5457
5905
|
nestId,
|
|
5458
5906
|
doc.id,
|
|
5459
5907
|
entry.version,
|
|
@@ -5462,33 +5910,33 @@ async function backfillNodeVersionsFromHistory(db) {
|
|
|
5462
5910
|
"approved",
|
|
5463
5911
|
entry.note || null,
|
|
5464
5912
|
entry.edited_at || (/* @__PURE__ */ new Date()).toISOString()
|
|
5465
|
-
);
|
|
5913
|
+
]);
|
|
5466
5914
|
totalInserted += 1;
|
|
5467
5915
|
}
|
|
5468
|
-
const pin = db.
|
|
5469
|
-
`SELECT approved_version FROM approved_versions WHERE nest_id = ? AND node_id =
|
|
5470
|
-
|
|
5916
|
+
const pin = await db.get(
|
|
5917
|
+
`SELECT approved_version FROM approved_versions WHERE nest_id = ? AND node_id = ?`,
|
|
5918
|
+
[nestId, doc.id]
|
|
5919
|
+
);
|
|
5471
5920
|
if (!pin || pin.approved_version < latestVersion) {
|
|
5472
|
-
|
|
5921
|
+
await db.run(approvedPinSql, [
|
|
5473
5922
|
nestId,
|
|
5474
5923
|
doc.id,
|
|
5475
5924
|
latestVersion,
|
|
5476
|
-
history.versions[history.versions.length - 1].edited_by || "system:backfill"
|
|
5477
|
-
|
|
5478
|
-
doc.id
|
|
5479
|
-
);
|
|
5925
|
+
history.versions[history.versions.length - 1].edited_by || "system:backfill"
|
|
5926
|
+
]);
|
|
5480
5927
|
}
|
|
5481
5928
|
if (tagsJson) {
|
|
5482
|
-
|
|
5929
|
+
await db.run(
|
|
5483
5930
|
`UPDATE node_versions SET tags_json = ?
|
|
5484
|
-
WHERE nest_id = ? AND node_id = ? AND version = ? AND tags_json IS NULL
|
|
5931
|
+
WHERE nest_id = ? AND node_id = ? AND version = ? AND tags_json IS NULL`,
|
|
5932
|
+
[tagsJson, nestId, doc.id, latestVersion]
|
|
5485
5933
|
);
|
|
5486
|
-
updateTags.run(tagsJson, nestId, doc.id, latestVersion);
|
|
5487
5934
|
}
|
|
5488
5935
|
}
|
|
5489
5936
|
}
|
|
5490
|
-
db.
|
|
5491
|
-
|
|
5937
|
+
await db.run(
|
|
5938
|
+
insertOrIgnore(db, "INSERT INTO schema_migrations (id) VALUES (?)"),
|
|
5939
|
+
[MIGRATION_ID]
|
|
5492
5940
|
);
|
|
5493
5941
|
console.log(
|
|
5494
5942
|
`[backfill] node_versions: scanned ${totalDocs} docs across ${nests.length} nests, inserted ${totalInserted} rows`
|
|
@@ -5497,7 +5945,7 @@ async function backfillNodeVersionsFromHistory(db) {
|
|
|
5497
5945
|
|
|
5498
5946
|
// src/index.ts
|
|
5499
5947
|
async function main() {
|
|
5500
|
-
const db =
|
|
5948
|
+
const db = await initDb();
|
|
5501
5949
|
try {
|
|
5502
5950
|
await backfillNodeVersionsFromHistory(db);
|
|
5503
5951
|
} catch (err) {
|
|
@@ -5531,7 +5979,8 @@ async function main() {
|
|
|
5531
5979
|
}
|
|
5532
5980
|
const app = createApp();
|
|
5533
5981
|
startLicenseSafetyPoll();
|
|
5534
|
-
const
|
|
5982
|
+
const driftRaw = process.env.DRIFT_SCAN_INTERVAL_MS;
|
|
5983
|
+
const driftScanIntervalMs = driftRaw != null && driftRaw !== "" && Number.isFinite(Number(driftRaw)) ? Number(driftRaw) : 3e4;
|
|
5535
5984
|
if (driftScanIntervalMs > 0) {
|
|
5536
5985
|
startDriftScanner(driftScanIntervalMs);
|
|
5537
5986
|
}
|
|
@@ -5542,11 +5991,13 @@ async function main() {
|
|
|
5542
5991
|
licensed: license.valid
|
|
5543
5992
|
});
|
|
5544
5993
|
const authDesc = config.AUTH_MODE === "open" ? "open (no auth \u2014 single-user / LAN only)" : "key (API key required on every request)";
|
|
5994
|
+
const dbDesc = db.dialect === "postgres" ? `postgres (${config.CLOUD_SQL_CONNECTION_NAME ? `cloudsql ${config.CLOUD_SQL_CONNECTION_NAME}` : config.DATABASE_URL ? "DATABASE_URL" : `${config.DB_HOST || "localhost"}:${config.DB_PORT}/${config.DB_NAME}`})` : `sqlite (${config.DATABASE_PATH})`;
|
|
5545
5995
|
console.log(`
|
|
5546
5996
|
ContextNest Community Server v0.1.0
|
|
5547
5997
|
\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
5548
5998
|
Port: ${config.PORT}
|
|
5549
5999
|
Data: ${config.DATA_ROOT}
|
|
6000
|
+
Database: ${dbDesc}
|
|
5550
6001
|
License: ${license.valid ? `${license.tier}${license.org ? ` (${license.org})` : ""}` : "unlicensed (register at promptowl.ai)"}
|
|
5551
6002
|
Auth: ${authDesc}
|
|
5552
6003
|
Telemetry: ${config.TELEMETRY_ENABLED ? "on" : "off"}
|