@promptowl/contextnest-community 0.1.0-alpha.1
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 +118 -0
- package/LICENSE.md +142 -0
- package/README.md +105 -0
- package/dist/chunk-7K2LLJXK.js +58 -0
- package/dist/chunk-DJFEV4ET.js +199 -0
- package/dist/chunk-P6NG56CO.js +127 -0
- package/dist/chunk-Q2DCOS7V.js +491 -0
- package/dist/chunk-USIDOGVJ.js +347 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2867 -0
- package/dist/keys-YV33AJK3.js +16 -0
- package/dist/review-service-5CLVZKAR.js +23 -0
- package/dist/stewardship-service-NC67XBYO.js +31 -0
- package/dist/version-service-Z6FYJRAG.js +23 -0
- package/dist/web3/assets/hootie-C2ocYkn4.svg +8 -0
- package/dist/web3/assets/index-CemroDXg.css +1 -0
- package/dist/web3/assets/index-xLLf4lHJ.js +332 -0
- package/dist/web3/index.html +14 -0
- package/package.json +108 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2867 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
generateApiKey,
|
|
4
|
+
getKeyPrefix,
|
|
5
|
+
hashApiKey,
|
|
6
|
+
hashPassword,
|
|
7
|
+
parseBearerToken,
|
|
8
|
+
verifyPassword
|
|
9
|
+
} from "./chunk-7K2LLJXK.js";
|
|
10
|
+
import {
|
|
11
|
+
checkConflict,
|
|
12
|
+
createVersion,
|
|
13
|
+
getApprovedVersion,
|
|
14
|
+
getCurrentVersion,
|
|
15
|
+
getVersions,
|
|
16
|
+
setApprovedVersion
|
|
17
|
+
} from "./chunk-P6NG56CO.js";
|
|
18
|
+
import {
|
|
19
|
+
approve,
|
|
20
|
+
cancelReview,
|
|
21
|
+
getReviewHistory,
|
|
22
|
+
getReviewQueue,
|
|
23
|
+
reject,
|
|
24
|
+
submitForReview
|
|
25
|
+
} from "./chunk-DJFEV4ET.js";
|
|
26
|
+
import {
|
|
27
|
+
assignSteward,
|
|
28
|
+
canUserAccess,
|
|
29
|
+
canUserApprove,
|
|
30
|
+
canUserEdit,
|
|
31
|
+
createStewardRecord,
|
|
32
|
+
getStewardsForNest,
|
|
33
|
+
isSuperAdmin,
|
|
34
|
+
listStewards,
|
|
35
|
+
loadAccessConfig,
|
|
36
|
+
removeSteward,
|
|
37
|
+
resolveStewardsForNode,
|
|
38
|
+
resolveStewardsWithFallback,
|
|
39
|
+
syncFromConfig
|
|
40
|
+
} from "./chunk-Q2DCOS7V.js";
|
|
41
|
+
import {
|
|
42
|
+
config,
|
|
43
|
+
getDb
|
|
44
|
+
} from "./chunk-USIDOGVJ.js";
|
|
45
|
+
|
|
46
|
+
// src/index.ts
|
|
47
|
+
import { serve } from "@hono/node-server";
|
|
48
|
+
|
|
49
|
+
// src/app.ts
|
|
50
|
+
import { Hono as Hono8 } from "hono";
|
|
51
|
+
import { createMiddleware as createMiddleware2 } from "hono/factory";
|
|
52
|
+
import { logger } from "hono/logger";
|
|
53
|
+
import { cors } from "hono/cors";
|
|
54
|
+
|
|
55
|
+
// src/auth/routes.ts
|
|
56
|
+
import { Hono } from "hono";
|
|
57
|
+
import { v4 as uuid } from "uuid";
|
|
58
|
+
|
|
59
|
+
// src/auth/middleware.ts
|
|
60
|
+
import { createMiddleware } from "hono/factory";
|
|
61
|
+
var authMiddleware = createMiddleware(async (c, next) => {
|
|
62
|
+
const key = parseBearerToken(c.req.header("Authorization"));
|
|
63
|
+
if (!key) {
|
|
64
|
+
return c.json({ error: "Missing or invalid API key" }, 401);
|
|
65
|
+
}
|
|
66
|
+
const keyHash = hashApiKey(key);
|
|
67
|
+
const db = getDb();
|
|
68
|
+
const record = db.prepare("SELECT user_id, nest_id FROM api_keys WHERE key_hash = ?").get(keyHash);
|
|
69
|
+
if (!record) {
|
|
70
|
+
return c.json({ error: "Invalid API key" }, 401);
|
|
71
|
+
}
|
|
72
|
+
db.prepare(
|
|
73
|
+
"UPDATE api_keys SET last_used_at = datetime('now') WHERE key_hash = ?"
|
|
74
|
+
).run(keyHash);
|
|
75
|
+
c.set("userId", record.user_id);
|
|
76
|
+
c.set("nestScope", record.nest_id);
|
|
77
|
+
await next();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// src/shared/errors.ts
|
|
81
|
+
var AppError = class extends Error {
|
|
82
|
+
constructor(statusCode, message) {
|
|
83
|
+
super(message);
|
|
84
|
+
this.statusCode = statusCode;
|
|
85
|
+
this.name = "AppError";
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
var NotFoundError = class extends AppError {
|
|
89
|
+
constructor(message = "Not found") {
|
|
90
|
+
super(404, message);
|
|
91
|
+
this.name = "NotFoundError";
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
var ValidationError = class extends AppError {
|
|
95
|
+
constructor(message) {
|
|
96
|
+
super(400, message);
|
|
97
|
+
this.name = "ValidationError";
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// src/telemetry/tracker.ts
|
|
102
|
+
function trackEvent(event, data) {
|
|
103
|
+
if (!config.TELEMETRY_ENABLED) return;
|
|
104
|
+
try {
|
|
105
|
+
const db = getDb();
|
|
106
|
+
db.prepare(
|
|
107
|
+
"INSERT INTO telemetry_events (event, data_json) VALUES (?, ?)"
|
|
108
|
+
).run(event, data ? JSON.stringify(data) : null);
|
|
109
|
+
} catch {
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async function flushTelemetry() {
|
|
113
|
+
if (!config.TELEMETRY_ENABLED || !config.PROMPTOWL_KEY) return;
|
|
114
|
+
const db = getDb();
|
|
115
|
+
const userCount = db.prepare("SELECT COUNT(*) as c FROM users").get()?.c || 0;
|
|
116
|
+
const nestCount = db.prepare("SELECT COUNT(*) as c FROM nests").get()?.c || 0;
|
|
117
|
+
const events = db.prepare(
|
|
118
|
+
"SELECT id, event, data_json, created_at FROM telemetry_events WHERE sent = 0 ORDER BY id LIMIT 100"
|
|
119
|
+
).all();
|
|
120
|
+
if (events.length === 0 && userCount === 0) return;
|
|
121
|
+
const payload = {
|
|
122
|
+
server_key: config.PROMPTOWL_KEY,
|
|
123
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
124
|
+
stats: { users: userCount, nests: nestCount },
|
|
125
|
+
events: events.map((e) => ({
|
|
126
|
+
event: e.event,
|
|
127
|
+
data: e.data_json ? JSON.parse(e.data_json) : null,
|
|
128
|
+
at: e.created_at
|
|
129
|
+
}))
|
|
130
|
+
};
|
|
131
|
+
try {
|
|
132
|
+
const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
|
|
133
|
+
const res = await fetch(`${promptowlUrl}/api/telemetry/ingest`, {
|
|
134
|
+
method: "POST",
|
|
135
|
+
headers: { "Content-Type": "application/json" },
|
|
136
|
+
body: JSON.stringify(payload)
|
|
137
|
+
});
|
|
138
|
+
if (res.ok && events.length > 0) {
|
|
139
|
+
const ids = events.map((e) => e.id);
|
|
140
|
+
db.prepare(
|
|
141
|
+
`UPDATE telemetry_events SET sent = 1 WHERE id IN (${ids.map(() => "?").join(",")})`
|
|
142
|
+
).run(...ids);
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
var telemetryTimer = null;
|
|
148
|
+
function startTelemetryLoop() {
|
|
149
|
+
if (!config.TELEMETRY_ENABLED) return;
|
|
150
|
+
setTimeout(() => flushTelemetry(), 3e4);
|
|
151
|
+
telemetryTimer = setInterval(
|
|
152
|
+
() => flushTelemetry(),
|
|
153
|
+
config.TELEMETRY_INTERVAL_MS
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// src/shared/rate-limit.ts
|
|
158
|
+
var buckets = /* @__PURE__ */ new Map();
|
|
159
|
+
function tryConsume(key, cfg) {
|
|
160
|
+
const now = Date.now();
|
|
161
|
+
const cutoff = now - cfg.windowMs;
|
|
162
|
+
let bucket = buckets.get(key);
|
|
163
|
+
if (!bucket) {
|
|
164
|
+
bucket = { hits: [] };
|
|
165
|
+
buckets.set(key, bucket);
|
|
166
|
+
}
|
|
167
|
+
while (bucket.hits.length && bucket.hits[0] < cutoff) {
|
|
168
|
+
bucket.hits.shift();
|
|
169
|
+
}
|
|
170
|
+
if (bucket.hits.length >= cfg.max) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
bucket.hits.push(now);
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// src/auth/routes.ts
|
|
178
|
+
var LOGIN_LIMIT = { max: 5, windowMs: 15 * 6e4 };
|
|
179
|
+
var REGISTER_LIMIT = { max: 3, windowMs: 60 * 6e4 };
|
|
180
|
+
var DEVICE_LIMIT = { max: 10, windowMs: 15 * 6e4 };
|
|
181
|
+
function clientIp(c) {
|
|
182
|
+
const xff = c.req.header("x-forwarded-for");
|
|
183
|
+
if (xff) return xff.split(",")[0].trim();
|
|
184
|
+
const realIp = c.req.header("x-real-ip");
|
|
185
|
+
if (realIp) return realIp.trim();
|
|
186
|
+
return "unknown";
|
|
187
|
+
}
|
|
188
|
+
var authRoutes = new Hono();
|
|
189
|
+
authRoutes.post("/register", async (c) => {
|
|
190
|
+
const body = await c.req.json();
|
|
191
|
+
if (!body.email || !body.password) {
|
|
192
|
+
throw new ValidationError("email and password are required");
|
|
193
|
+
}
|
|
194
|
+
const ip = clientIp(c);
|
|
195
|
+
if (!tryConsume(`register:ip:${ip}`, REGISTER_LIMIT)) {
|
|
196
|
+
return c.json({ error: "Too many registration attempts, try again later" }, 429);
|
|
197
|
+
}
|
|
198
|
+
const db = getDb();
|
|
199
|
+
const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(body.email);
|
|
200
|
+
if (existing) {
|
|
201
|
+
throw new ValidationError("Email already registered");
|
|
202
|
+
}
|
|
203
|
+
const userId = uuid();
|
|
204
|
+
const passwordHash = await hashPassword(body.password);
|
|
205
|
+
db.prepare(
|
|
206
|
+
"INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)"
|
|
207
|
+
).run(userId, body.email, body.name || null, passwordHash);
|
|
208
|
+
const apiKey = generateApiKey();
|
|
209
|
+
const keyId = uuid();
|
|
210
|
+
db.prepare(
|
|
211
|
+
"INSERT INTO api_keys (id, user_id, key_hash, key_prefix, label) VALUES (?, ?, ?, ?, ?)"
|
|
212
|
+
).run(keyId, userId, hashApiKey(apiKey), getKeyPrefix(apiKey), "default");
|
|
213
|
+
trackEvent("user.register", { userId, email: body.email });
|
|
214
|
+
return c.json(
|
|
215
|
+
{
|
|
216
|
+
user: { id: userId, email: body.email, name: body.name || null },
|
|
217
|
+
api_key: apiKey
|
|
218
|
+
},
|
|
219
|
+
201
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
authRoutes.post("/login", async (c) => {
|
|
223
|
+
const body = await c.req.json();
|
|
224
|
+
if (!body.email || !body.password) {
|
|
225
|
+
throw new ValidationError("email and password are required");
|
|
226
|
+
}
|
|
227
|
+
const ip = clientIp(c);
|
|
228
|
+
const emailLower = body.email.toLowerCase();
|
|
229
|
+
if (!tryConsume(`login:ip:${ip}`, LOGIN_LIMIT) || !tryConsume(`login:email:${emailLower}`, LOGIN_LIMIT)) {
|
|
230
|
+
return c.json({ error: "Too many login attempts, try again later" }, 429);
|
|
231
|
+
}
|
|
232
|
+
const db = getDb();
|
|
233
|
+
const user = db.prepare(
|
|
234
|
+
"SELECT id, email, name, password_hash FROM users WHERE email = ?"
|
|
235
|
+
).get(body.email);
|
|
236
|
+
const check = user ? await verifyPassword(body.password, user.password_hash) : { ok: false, needsRehash: false };
|
|
237
|
+
if (!user || !check.ok) {
|
|
238
|
+
return c.json({ error: "Invalid credentials" }, 401);
|
|
239
|
+
}
|
|
240
|
+
if (check.needsRehash) {
|
|
241
|
+
try {
|
|
242
|
+
const newHash = await hashPassword(body.password);
|
|
243
|
+
db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(
|
|
244
|
+
newHash,
|
|
245
|
+
user.id
|
|
246
|
+
);
|
|
247
|
+
} catch {
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const apiKey = generateApiKey();
|
|
251
|
+
const keyId = uuid();
|
|
252
|
+
db.prepare(
|
|
253
|
+
"INSERT INTO api_keys (id, user_id, key_hash, key_prefix, label) VALUES (?, ?, ?, ?, ?)"
|
|
254
|
+
).run(
|
|
255
|
+
keyId,
|
|
256
|
+
user.id,
|
|
257
|
+
hashApiKey(apiKey),
|
|
258
|
+
getKeyPrefix(apiKey),
|
|
259
|
+
body.label || "login"
|
|
260
|
+
);
|
|
261
|
+
trackEvent("user.login", { userId: user.id });
|
|
262
|
+
return c.json({ api_key: apiKey });
|
|
263
|
+
});
|
|
264
|
+
authRoutes.post("/keys", authMiddleware, async (c) => {
|
|
265
|
+
const body = await c.req.json();
|
|
266
|
+
const db = getDb();
|
|
267
|
+
const apiKey = generateApiKey();
|
|
268
|
+
const keyId = uuid();
|
|
269
|
+
db.prepare(
|
|
270
|
+
"INSERT INTO api_keys (id, user_id, key_hash, key_prefix, nest_id, label) VALUES (?, ?, ?, ?, ?, ?)"
|
|
271
|
+
).run(
|
|
272
|
+
keyId,
|
|
273
|
+
c.get("userId"),
|
|
274
|
+
hashApiKey(apiKey),
|
|
275
|
+
getKeyPrefix(apiKey),
|
|
276
|
+
body.nest_id || null,
|
|
277
|
+
body.label || null
|
|
278
|
+
);
|
|
279
|
+
return c.json(
|
|
280
|
+
{ api_key: apiKey, key_prefix: getKeyPrefix(apiKey) },
|
|
281
|
+
201
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
authRoutes.get("/keys", authMiddleware, async (c) => {
|
|
285
|
+
const db = getDb();
|
|
286
|
+
const keys = db.prepare(
|
|
287
|
+
"SELECT id, key_prefix, nest_id, label, created_at, last_used_at FROM api_keys WHERE user_id = ?"
|
|
288
|
+
).all(c.get("userId"));
|
|
289
|
+
return c.json({ keys });
|
|
290
|
+
});
|
|
291
|
+
authRoutes.delete("/keys/:keyId", authMiddleware, async (c) => {
|
|
292
|
+
const db = getDb();
|
|
293
|
+
const result = db.prepare("DELETE FROM api_keys WHERE id = ? AND user_id = ?").run(c.req.param("keyId"), c.get("userId"));
|
|
294
|
+
if (result.changes === 0) {
|
|
295
|
+
return c.json({ error: "Key not found" }, 404);
|
|
296
|
+
}
|
|
297
|
+
return c.json({ deleted: true });
|
|
298
|
+
});
|
|
299
|
+
authRoutes.post("/device", async (c) => {
|
|
300
|
+
if (!tryConsume(`device:ip:${clientIp(c)}`, DEVICE_LIMIT)) {
|
|
301
|
+
return c.json({ error: "Too many device auth attempts, try again later" }, 429);
|
|
302
|
+
}
|
|
303
|
+
const body = await c.req.json();
|
|
304
|
+
const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
|
|
305
|
+
const res = await fetch(`${promptowlUrl}/api/auth/device`, {
|
|
306
|
+
method: "POST",
|
|
307
|
+
headers: { "Content-Type": "application/json" },
|
|
308
|
+
body: JSON.stringify({
|
|
309
|
+
deviceName: body.deviceName || "ContextNest Community",
|
|
310
|
+
deviceType: body.deviceType || "webapp"
|
|
311
|
+
})
|
|
312
|
+
});
|
|
313
|
+
const data = await res.json();
|
|
314
|
+
if (!res.ok) return c.json(data, res.status);
|
|
315
|
+
if (data.verificationUrl && !data.verificationUrl.startsWith("http")) {
|
|
316
|
+
data.verificationUrl = `${promptowlUrl}${data.verificationUrl}`;
|
|
317
|
+
}
|
|
318
|
+
return c.json(data);
|
|
319
|
+
});
|
|
320
|
+
authRoutes.get("/device/poll", async (c) => {
|
|
321
|
+
const code = c.req.query("code");
|
|
322
|
+
const clientSecret = c.req.query("client_secret");
|
|
323
|
+
if (!code || !clientSecret) {
|
|
324
|
+
return c.json({ error: "code and client_secret are required" }, 400);
|
|
325
|
+
}
|
|
326
|
+
const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
|
|
327
|
+
const res = await fetch(
|
|
328
|
+
`${promptowlUrl}/api/auth/device/poll?code=${encodeURIComponent(code)}&client_secret=${encodeURIComponent(clientSecret)}`
|
|
329
|
+
);
|
|
330
|
+
const data = await res.json();
|
|
331
|
+
return c.json(data, res.status);
|
|
332
|
+
});
|
|
333
|
+
authRoutes.post("/promptowl", async (c) => {
|
|
334
|
+
if (!tryConsume(`promptowl:ip:${clientIp(c)}`, DEVICE_LIMIT)) {
|
|
335
|
+
return c.json({ error: "Too many exchange attempts, try again later" }, 429);
|
|
336
|
+
}
|
|
337
|
+
const body = await c.req.json();
|
|
338
|
+
if (!body.token) {
|
|
339
|
+
throw new ValidationError("token is required");
|
|
340
|
+
}
|
|
341
|
+
const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
|
|
342
|
+
const meRes = await fetch(`${promptowlUrl}/api/user/me`, {
|
|
343
|
+
headers: { Authorization: `Bearer ${body.token}` }
|
|
344
|
+
});
|
|
345
|
+
if (!meRes.ok) {
|
|
346
|
+
return c.json({ error: "Invalid or expired PromptOwl token" }, 401);
|
|
347
|
+
}
|
|
348
|
+
const meData = await meRes.json();
|
|
349
|
+
const me = meData.user || meData;
|
|
350
|
+
if (!me.email) {
|
|
351
|
+
return c.json(
|
|
352
|
+
{ error: "Could not retrieve user email from PromptOwl" },
|
|
353
|
+
401
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
const db = getDb();
|
|
357
|
+
let user = db.prepare("SELECT id, email, name FROM users WHERE email = ?").get(me.email);
|
|
358
|
+
if (!user) {
|
|
359
|
+
const userId = uuid();
|
|
360
|
+
const placeholderHash = await hashPassword(uuid());
|
|
361
|
+
db.prepare(
|
|
362
|
+
"INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)"
|
|
363
|
+
).run(userId, me.email, me.name || null, placeholderHash);
|
|
364
|
+
user = { id: userId, email: me.email, name: me.name || null };
|
|
365
|
+
trackEvent("user.register", {
|
|
366
|
+
userId,
|
|
367
|
+
email: me.email,
|
|
368
|
+
method: "promptowl"
|
|
369
|
+
});
|
|
370
|
+
} else {
|
|
371
|
+
trackEvent("user.login", { userId: user.id, method: "promptowl" });
|
|
372
|
+
}
|
|
373
|
+
let isAdmin = false;
|
|
374
|
+
const claim = db.transaction((userId) => {
|
|
375
|
+
const result = db.prepare(
|
|
376
|
+
`UPDATE users SET is_admin = 1
|
|
377
|
+
WHERE id = ?
|
|
378
|
+
AND NOT EXISTS (SELECT 1 FROM users WHERE is_admin = 1)`
|
|
379
|
+
).run(userId);
|
|
380
|
+
return result.changes > 0;
|
|
381
|
+
});
|
|
382
|
+
if (claim(user.id)) {
|
|
383
|
+
isAdmin = true;
|
|
384
|
+
trackEvent("admin.claim", { userId: user.id, email: user.email });
|
|
385
|
+
} else {
|
|
386
|
+
const userRow = db.prepare("SELECT is_admin FROM users WHERE id = ?").get(user.id);
|
|
387
|
+
isAdmin = !!userRow?.is_admin;
|
|
388
|
+
}
|
|
389
|
+
const apiKey = generateApiKey();
|
|
390
|
+
const keyId = uuid();
|
|
391
|
+
db.prepare(
|
|
392
|
+
"INSERT INTO api_keys (id, user_id, key_hash, key_prefix, label) VALUES (?, ?, ?, ?, ?)"
|
|
393
|
+
).run(
|
|
394
|
+
keyId,
|
|
395
|
+
user.id,
|
|
396
|
+
hashApiKey(apiKey),
|
|
397
|
+
getKeyPrefix(apiKey),
|
|
398
|
+
"promptowl"
|
|
399
|
+
);
|
|
400
|
+
return c.json({
|
|
401
|
+
api_key: apiKey,
|
|
402
|
+
user: {
|
|
403
|
+
id: user.id,
|
|
404
|
+
email: user.email,
|
|
405
|
+
name: user.name,
|
|
406
|
+
is_admin: isAdmin
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
authRoutes.get("/admin-status", async (c) => {
|
|
411
|
+
const db = getDb();
|
|
412
|
+
const admin = db.prepare("SELECT email, name FROM users WHERE is_admin = 1 LIMIT 1").get();
|
|
413
|
+
const callerKey = parseBearerToken(c.req.header("Authorization"));
|
|
414
|
+
let me = null;
|
|
415
|
+
if (callerKey) {
|
|
416
|
+
const keyHash = hashApiKey(callerKey);
|
|
417
|
+
const row = db.prepare(
|
|
418
|
+
`SELECT u.email, u.is_admin FROM api_keys k
|
|
419
|
+
JOIN users u ON u.id = k.user_id
|
|
420
|
+
WHERE k.key_hash = ?`
|
|
421
|
+
).get(keyHash);
|
|
422
|
+
if (row) {
|
|
423
|
+
me = { email: row.email, is_admin: !!row.is_admin };
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return c.json({
|
|
427
|
+
claimed: !!admin,
|
|
428
|
+
admin: admin ? { email: admin.email, name: admin.name } : null,
|
|
429
|
+
me
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
authRoutes.post("/invite", async (c) => {
|
|
433
|
+
const body = await c.req.json();
|
|
434
|
+
if (!body.email) throw new ValidationError("email is required");
|
|
435
|
+
const inviteKey = parseBearerToken(c.req.header("Authorization"));
|
|
436
|
+
if (!inviteKey) {
|
|
437
|
+
return c.json({ error: "Admin authentication required" }, 401);
|
|
438
|
+
}
|
|
439
|
+
const db = getDb();
|
|
440
|
+
const keyHash = hashApiKey(inviteKey);
|
|
441
|
+
const caller = db.prepare(
|
|
442
|
+
`SELECT u.id, u.is_admin FROM api_keys k
|
|
443
|
+
JOIN users u ON u.id = k.user_id
|
|
444
|
+
WHERE k.key_hash = ?`
|
|
445
|
+
).get(keyHash);
|
|
446
|
+
if (!caller || !caller.is_admin) {
|
|
447
|
+
return c.json({ error: "Admin only" }, 403);
|
|
448
|
+
}
|
|
449
|
+
let user = db.prepare("SELECT id, email FROM users WHERE email = ?").get(body.email);
|
|
450
|
+
if (!user) {
|
|
451
|
+
const userId = uuid();
|
|
452
|
+
const placeholderHash = await hashPassword(uuid());
|
|
453
|
+
db.prepare(
|
|
454
|
+
"INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)"
|
|
455
|
+
).run(userId, body.email, null, placeholderHash);
|
|
456
|
+
user = { id: userId, email: body.email };
|
|
457
|
+
}
|
|
458
|
+
const apiKey = generateApiKey();
|
|
459
|
+
const keyId = uuid();
|
|
460
|
+
db.prepare(
|
|
461
|
+
"INSERT INTO api_keys (id, user_id, key_hash, key_prefix, label) VALUES (?, ?, ?, ?, ?)"
|
|
462
|
+
).run(keyId, user.id, hashApiKey(apiKey), getKeyPrefix(apiKey), body.label || "teammate");
|
|
463
|
+
trackEvent("admin.invite", { adminId: caller.id, email: body.email });
|
|
464
|
+
return c.json(
|
|
465
|
+
{
|
|
466
|
+
api_key: apiKey,
|
|
467
|
+
user: { id: user.id, email: user.email },
|
|
468
|
+
message: "Copy this key and share it securely \u2014 it won't be shown again."
|
|
469
|
+
},
|
|
470
|
+
201
|
|
471
|
+
);
|
|
472
|
+
});
|
|
473
|
+
authRoutes.get("/teammates", async (c) => {
|
|
474
|
+
const teammatesKey = parseBearerToken(c.req.header("Authorization"));
|
|
475
|
+
if (!teammatesKey) {
|
|
476
|
+
return c.json({ error: "Admin authentication required" }, 401);
|
|
477
|
+
}
|
|
478
|
+
const db = getDb();
|
|
479
|
+
const keyHash = hashApiKey(teammatesKey);
|
|
480
|
+
const caller = db.prepare(
|
|
481
|
+
`SELECT u.is_admin FROM api_keys k
|
|
482
|
+
JOIN users u ON u.id = k.user_id
|
|
483
|
+
WHERE k.key_hash = ?`
|
|
484
|
+
).get(keyHash);
|
|
485
|
+
if (!caller || !caller.is_admin) {
|
|
486
|
+
return c.json({ error: "Admin only" }, 403);
|
|
487
|
+
}
|
|
488
|
+
const teammates = db.prepare(
|
|
489
|
+
`SELECT u.id, u.email, u.name, u.is_admin,
|
|
490
|
+
(SELECT COUNT(*) FROM api_keys WHERE user_id = u.id) as key_count,
|
|
491
|
+
(SELECT MAX(last_used_at) FROM api_keys WHERE user_id = u.id) as last_active
|
|
492
|
+
FROM users u
|
|
493
|
+
WHERE u.id != '00000000-0000-0000-0000-000000000000'
|
|
494
|
+
ORDER BY u.is_admin DESC, u.created_at DESC`
|
|
495
|
+
).all();
|
|
496
|
+
const pendingStewards = db.prepare(
|
|
497
|
+
`SELECT DISTINCT s.user_email AS email
|
|
498
|
+
FROM stewards s
|
|
499
|
+
WHERE s.is_active = 1
|
|
500
|
+
AND NOT EXISTS (
|
|
501
|
+
SELECT 1 FROM users u
|
|
502
|
+
JOIN api_keys k ON k.user_id = u.id
|
|
503
|
+
WHERE lower(u.email) = lower(s.user_email)
|
|
504
|
+
)
|
|
505
|
+
ORDER BY s.user_email`
|
|
506
|
+
).all();
|
|
507
|
+
return c.json({
|
|
508
|
+
teammates: teammates.map((t) => ({
|
|
509
|
+
...t,
|
|
510
|
+
is_admin: !!t.is_admin
|
|
511
|
+
})),
|
|
512
|
+
pending_stewards: pendingStewards.map((p) => p.email)
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
// src/nests/routes.ts
|
|
517
|
+
import { Hono as Hono2 } from "hono";
|
|
518
|
+
|
|
519
|
+
// src/shared/access.ts
|
|
520
|
+
var PERMISSION_LEVELS = {
|
|
521
|
+
none: 0,
|
|
522
|
+
read: 1,
|
|
523
|
+
write: 2,
|
|
524
|
+
admin: 3,
|
|
525
|
+
owner: 4
|
|
526
|
+
};
|
|
527
|
+
function resolveNestPermission(nestId, userId) {
|
|
528
|
+
const db = getDb();
|
|
529
|
+
const nest = db.prepare("SELECT user_id, visibility FROM nests WHERE id = ?").get(nestId);
|
|
530
|
+
if (!nest) return "none";
|
|
531
|
+
if (nest.user_id === userId) return "owner";
|
|
532
|
+
const directGrant = db.prepare(
|
|
533
|
+
"SELECT permission FROM nest_collaborators WHERE nest_id = ? AND user_id = ?"
|
|
534
|
+
).get(nestId, userId);
|
|
535
|
+
if (directGrant) return directGrant.permission;
|
|
536
|
+
if (nest.visibility === "public") return "read";
|
|
537
|
+
return "none";
|
|
538
|
+
}
|
|
539
|
+
function permissionLevel(p) {
|
|
540
|
+
return PERMISSION_LEVELS[p] ?? 0;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// src/nests/service.ts
|
|
544
|
+
import { join } from "path";
|
|
545
|
+
import { rmSync, mkdirSync } from "fs";
|
|
546
|
+
import { v4 as uuid2 } from "uuid";
|
|
547
|
+
import { NestStorage } from "@promptowl/contextnest-engine";
|
|
548
|
+
function nestPath(nestId) {
|
|
549
|
+
return join(config.DATA_ROOT, "nests", nestId);
|
|
550
|
+
}
|
|
551
|
+
function toSlug(name) {
|
|
552
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
553
|
+
}
|
|
554
|
+
function isStewardshipEnabled(nestId) {
|
|
555
|
+
const db = getDb();
|
|
556
|
+
const row = db.prepare("SELECT stewardship_enabled FROM nests WHERE id = ?").get(nestId);
|
|
557
|
+
return !!row?.stewardship_enabled;
|
|
558
|
+
}
|
|
559
|
+
function setStewardshipEnabled(nestId, enabled) {
|
|
560
|
+
const db = getDb();
|
|
561
|
+
db.prepare("UPDATE nests SET stewardship_enabled = ? WHERE id = ?").run(
|
|
562
|
+
enabled ? 1 : 0,
|
|
563
|
+
nestId
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
async function createNest(userId, name, description) {
|
|
567
|
+
const id = uuid2();
|
|
568
|
+
const slug = toSlug(name);
|
|
569
|
+
const db = getDb();
|
|
570
|
+
db.prepare(
|
|
571
|
+
"INSERT INTO nests (id, user_id, name, slug, description) VALUES (?, ?, ?, ?, ?)"
|
|
572
|
+
).run(id, userId, name, slug, description || null);
|
|
573
|
+
const path = nestPath(id);
|
|
574
|
+
mkdirSync(path, { recursive: true });
|
|
575
|
+
const storage = new NestStorage(path);
|
|
576
|
+
await storage.init(name);
|
|
577
|
+
trackEvent("nest.create", { nestId: id, userId });
|
|
578
|
+
return db.prepare("SELECT * FROM nests WHERE id = ?").get(id);
|
|
579
|
+
}
|
|
580
|
+
var ANON_USER_ID = "00000000-0000-0000-0000-000000000000";
|
|
581
|
+
function listNests(userId) {
|
|
582
|
+
const db = getDb();
|
|
583
|
+
if (userId === ANON_USER_ID) {
|
|
584
|
+
return db.prepare("SELECT * FROM nests WHERE user_id = ? ORDER BY created_at DESC").all(ANON_USER_ID);
|
|
585
|
+
}
|
|
586
|
+
return db.prepare(
|
|
587
|
+
"SELECT * FROM nests WHERE user_id = ? OR user_id = ? ORDER BY created_at DESC"
|
|
588
|
+
).all(userId, ANON_USER_ID);
|
|
589
|
+
}
|
|
590
|
+
function listSharedNests(userId) {
|
|
591
|
+
const db = getDb();
|
|
592
|
+
return db.prepare(
|
|
593
|
+
`SELECT DISTINCT n.* FROM nests n
|
|
594
|
+
JOIN nest_collaborators nc ON nc.nest_id = n.id
|
|
595
|
+
WHERE n.user_id != ? AND nc.user_id = ?
|
|
596
|
+
ORDER BY n.created_at DESC`
|
|
597
|
+
).all(userId, userId);
|
|
598
|
+
}
|
|
599
|
+
function getNest(nestId) {
|
|
600
|
+
const db = getDb();
|
|
601
|
+
return db.prepare("SELECT * FROM nests WHERE id = ?").get(nestId) || null;
|
|
602
|
+
}
|
|
603
|
+
async function deleteNest(nestId) {
|
|
604
|
+
const db = getDb();
|
|
605
|
+
db.prepare("DELETE FROM api_keys WHERE nest_id = ?").run(nestId);
|
|
606
|
+
db.prepare("DELETE FROM nests WHERE id = ?").run(nestId);
|
|
607
|
+
const path = nestPath(nestId);
|
|
608
|
+
try {
|
|
609
|
+
rmSync(path, { recursive: true, force: true });
|
|
610
|
+
} catch {
|
|
611
|
+
}
|
|
612
|
+
trackEvent("nest.delete", { nestId });
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// src/nests/routes.ts
|
|
616
|
+
var ANON_USER_ID2 = "00000000-0000-0000-0000-000000000000";
|
|
617
|
+
function effectivePermission(nestId, userId) {
|
|
618
|
+
if (config.AUTH_MODE === "open") {
|
|
619
|
+
const db = getDb();
|
|
620
|
+
const nest = db.prepare("SELECT user_id FROM nests WHERE id = ?").get(nestId);
|
|
621
|
+
if (nest && nest.user_id === ANON_USER_ID2) return "owner";
|
|
622
|
+
}
|
|
623
|
+
return resolveNestPermission(nestId, userId);
|
|
624
|
+
}
|
|
625
|
+
var nestRoutes = new Hono2();
|
|
626
|
+
nestRoutes.get("/", async (c) => {
|
|
627
|
+
const userId = c.get("userId");
|
|
628
|
+
const owned = listNests(userId);
|
|
629
|
+
const shared = listSharedNests(userId);
|
|
630
|
+
return c.json({ nests: [...owned, ...shared] });
|
|
631
|
+
});
|
|
632
|
+
nestRoutes.post("/", async (c) => {
|
|
633
|
+
const body = await c.req.json();
|
|
634
|
+
if (!body.name) {
|
|
635
|
+
throw new ValidationError("name is required");
|
|
636
|
+
}
|
|
637
|
+
const nest = await createNest(c.get("userId"), body.name, body.description);
|
|
638
|
+
return c.json({ nest }, 201);
|
|
639
|
+
});
|
|
640
|
+
nestRoutes.get("/:nestId", async (c) => {
|
|
641
|
+
const nestId = c.req.param("nestId");
|
|
642
|
+
const permission = effectivePermission(nestId, c.get("userId"));
|
|
643
|
+
if (permission === "none") {
|
|
644
|
+
throw new NotFoundError("Nest not found");
|
|
645
|
+
}
|
|
646
|
+
const nest = getNest(nestId);
|
|
647
|
+
return c.json({ nest, permission });
|
|
648
|
+
});
|
|
649
|
+
nestRoutes.delete("/:nestId", async (c) => {
|
|
650
|
+
const nestId = c.req.param("nestId");
|
|
651
|
+
const permission = effectivePermission(nestId, c.get("userId"));
|
|
652
|
+
if (permission !== "owner") {
|
|
653
|
+
throw new NotFoundError("Nest not found");
|
|
654
|
+
}
|
|
655
|
+
await deleteNest(nestId);
|
|
656
|
+
return c.json({ deleted: true });
|
|
657
|
+
});
|
|
658
|
+
nestRoutes.get("/:nestId/settings", async (c) => {
|
|
659
|
+
const nestId = c.req.param("nestId");
|
|
660
|
+
const permission = effectivePermission(nestId, c.get("userId"));
|
|
661
|
+
if (permission === "none") {
|
|
662
|
+
throw new NotFoundError("Nest not found");
|
|
663
|
+
}
|
|
664
|
+
return c.json({
|
|
665
|
+
stewardship_enabled: isStewardshipEnabled(nestId)
|
|
666
|
+
});
|
|
667
|
+
});
|
|
668
|
+
nestRoutes.patch("/:nestId/settings", async (c) => {
|
|
669
|
+
const nestId = c.req.param("nestId");
|
|
670
|
+
const userId = c.get("userId");
|
|
671
|
+
const db = getDb();
|
|
672
|
+
const userRow = db.prepare("SELECT is_admin FROM users WHERE id = ?").get(userId);
|
|
673
|
+
const isServerAdmin = !!userRow?.is_admin;
|
|
674
|
+
const permission = effectivePermission(nestId, userId);
|
|
675
|
+
if (!isServerAdmin && permission !== "owner") {
|
|
676
|
+
return c.json({ error: "Admin only" }, 403);
|
|
677
|
+
}
|
|
678
|
+
const body = await c.req.json();
|
|
679
|
+
if (typeof body.stewardship_enabled === "boolean") {
|
|
680
|
+
setStewardshipEnabled(nestId, body.stewardship_enabled);
|
|
681
|
+
}
|
|
682
|
+
return c.json({
|
|
683
|
+
stewardship_enabled: isStewardshipEnabled(nestId)
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
// src/nests/sharing-routes.ts
|
|
688
|
+
import { Hono as Hono3 } from "hono";
|
|
689
|
+
import { v4 as uuid3 } from "uuid";
|
|
690
|
+
var sharingRoutes = new Hono3();
|
|
691
|
+
sharingRoutes.get("/collaborators", async (c) => {
|
|
692
|
+
const db = getDb();
|
|
693
|
+
const collabs = db.prepare(
|
|
694
|
+
`SELECT nc.*, u.email FROM nest_collaborators nc
|
|
695
|
+
LEFT JOIN users u ON nc.user_id = u.id
|
|
696
|
+
WHERE nc.nest_id = ?
|
|
697
|
+
ORDER BY nc.granted_at`
|
|
698
|
+
).all(c.req.param("nestId"));
|
|
699
|
+
return c.json({ collaborators: collabs });
|
|
700
|
+
});
|
|
701
|
+
sharingRoutes.post("/collaborators", async (c) => {
|
|
702
|
+
const body = await c.req.json();
|
|
703
|
+
if (!body.permission || !["read", "write", "admin"].includes(body.permission)) {
|
|
704
|
+
throw new ValidationError("permission must be read, write, or admin");
|
|
705
|
+
}
|
|
706
|
+
const db = getDb();
|
|
707
|
+
let userId = body.user_id;
|
|
708
|
+
if (!userId && body.email) {
|
|
709
|
+
let user = db.prepare("SELECT id FROM users WHERE email = ?").get(body.email);
|
|
710
|
+
if (!user) {
|
|
711
|
+
const { hashPassword: hashPassword2 } = await import("./keys-YV33AJK3.js");
|
|
712
|
+
userId = uuid3();
|
|
713
|
+
db.prepare(
|
|
714
|
+
"INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)"
|
|
715
|
+
).run(userId, body.email, null, await hashPassword2(uuid3()));
|
|
716
|
+
} else {
|
|
717
|
+
userId = user.id;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
if (!userId) {
|
|
721
|
+
throw new ValidationError("user_id or email is required");
|
|
722
|
+
}
|
|
723
|
+
const collabId = uuid3();
|
|
724
|
+
db.prepare(
|
|
725
|
+
"INSERT INTO nest_collaborators (id, nest_id, user_id, permission, granted_by) VALUES (?, ?, ?, ?, ?)"
|
|
726
|
+
).run(
|
|
727
|
+
collabId,
|
|
728
|
+
c.req.param("nestId"),
|
|
729
|
+
userId,
|
|
730
|
+
body.permission,
|
|
731
|
+
c.get("userId")
|
|
732
|
+
);
|
|
733
|
+
const collab = db.prepare("SELECT * FROM nest_collaborators WHERE id = ?").get(collabId);
|
|
734
|
+
return c.json({ collaborator: collab }, 201);
|
|
735
|
+
});
|
|
736
|
+
sharingRoutes.patch("/collaborators/:collabId", async (c) => {
|
|
737
|
+
const body = await c.req.json();
|
|
738
|
+
if (!body.permission || !["read", "write", "admin"].includes(body.permission)) {
|
|
739
|
+
throw new ValidationError("permission must be read, write, or admin");
|
|
740
|
+
}
|
|
741
|
+
const db = getDb();
|
|
742
|
+
db.prepare("UPDATE nest_collaborators SET permission = ? WHERE id = ?").run(
|
|
743
|
+
body.permission,
|
|
744
|
+
c.req.param("collabId")
|
|
745
|
+
);
|
|
746
|
+
return c.json({ updated: true });
|
|
747
|
+
});
|
|
748
|
+
sharingRoutes.delete("/collaborators/:collabId", async (c) => {
|
|
749
|
+
const db = getDb();
|
|
750
|
+
db.prepare("DELETE FROM nest_collaborators WHERE id = ?").run(
|
|
751
|
+
c.req.param("collabId")
|
|
752
|
+
);
|
|
753
|
+
return c.json({ removed: true });
|
|
754
|
+
});
|
|
755
|
+
sharingRoutes.patch("/visibility", async (c) => {
|
|
756
|
+
const body = await c.req.json();
|
|
757
|
+
if (!body.visibility || !["private", "public"].includes(body.visibility)) {
|
|
758
|
+
throw new ValidationError("visibility must be private or public");
|
|
759
|
+
}
|
|
760
|
+
const db = getDb();
|
|
761
|
+
db.prepare("UPDATE nests SET visibility = ? WHERE id = ?").run(
|
|
762
|
+
body.visibility,
|
|
763
|
+
c.req.param("nestId")
|
|
764
|
+
);
|
|
765
|
+
return c.json({ visibility: body.visibility });
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
// src/nodes/routes.ts
|
|
769
|
+
import { Hono as Hono4 } from "hono";
|
|
770
|
+
import { serializeDocument } from "@promptowl/contextnest-engine";
|
|
771
|
+
|
|
772
|
+
// src/nodes/engine.ts
|
|
773
|
+
import { join as join2 } from "path";
|
|
774
|
+
import { NestStorage as NestStorage2, GraphQueryEngine } from "@promptowl/contextnest-engine";
|
|
775
|
+
var NestEngineCache = class {
|
|
776
|
+
cache = /* @__PURE__ */ new Map();
|
|
777
|
+
get(nestId) {
|
|
778
|
+
let engine = this.cache.get(nestId);
|
|
779
|
+
if (!engine) {
|
|
780
|
+
const nestPath2 = join2(config.DATA_ROOT, "nests", nestId);
|
|
781
|
+
const storage = new NestStorage2(nestPath2);
|
|
782
|
+
const query = new GraphQueryEngine(storage);
|
|
783
|
+
engine = { storage, query };
|
|
784
|
+
this.cache.set(nestId, engine);
|
|
785
|
+
}
|
|
786
|
+
return engine;
|
|
787
|
+
}
|
|
788
|
+
evict(nestId) {
|
|
789
|
+
this.cache.delete(nestId);
|
|
790
|
+
}
|
|
791
|
+
};
|
|
792
|
+
var engineCache = new NestEngineCache();
|
|
793
|
+
|
|
794
|
+
// src/governance/tag-index-service.ts
|
|
795
|
+
function normalizeTag(raw) {
|
|
796
|
+
return raw.trim().replace(/^#+/, "").toLowerCase();
|
|
797
|
+
}
|
|
798
|
+
function syncNodeTags(nestId, nodeId, tags) {
|
|
799
|
+
const db = getDb();
|
|
800
|
+
const normalized = Array.from(
|
|
801
|
+
new Set(
|
|
802
|
+
tags.filter((t) => typeof t === "string").map(normalizeTag).filter(Boolean)
|
|
803
|
+
)
|
|
804
|
+
);
|
|
805
|
+
db.transaction(() => {
|
|
806
|
+
db.prepare(
|
|
807
|
+
"DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?"
|
|
808
|
+
).run(nestId, nodeId);
|
|
809
|
+
const insert = db.prepare(
|
|
810
|
+
"INSERT OR IGNORE INTO node_tag_index (nest_id, node_id, tag_name) VALUES (?, ?, ?)"
|
|
811
|
+
);
|
|
812
|
+
for (const tag of normalized) {
|
|
813
|
+
insert.run(nestId, nodeId, tag);
|
|
814
|
+
}
|
|
815
|
+
})();
|
|
816
|
+
}
|
|
817
|
+
function removeNodeFromTagIndex(nestId, nodeId) {
|
|
818
|
+
const db = getDb();
|
|
819
|
+
db.prepare(
|
|
820
|
+
"DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?"
|
|
821
|
+
).run(nestId, nodeId);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// src/governance/access-guard.ts
|
|
825
|
+
function resolveCallerEmail(userId) {
|
|
826
|
+
if (!userId) return "admin@localhost";
|
|
827
|
+
const db = getDb();
|
|
828
|
+
const row = db.prepare("SELECT email FROM users WHERE id = ?").get(userId);
|
|
829
|
+
return row?.email || "admin@localhost";
|
|
830
|
+
}
|
|
831
|
+
function canReadNode(nestId, nodeId, userEmail) {
|
|
832
|
+
if (!isStewardshipEnabled(nestId)) return true;
|
|
833
|
+
return canUserAccess(nestId, nodeId, userEmail).allowed;
|
|
834
|
+
}
|
|
835
|
+
function filterAccessible(nestId, userEmail, nodes) {
|
|
836
|
+
if (!isStewardshipEnabled(nestId)) return nodes;
|
|
837
|
+
return nodes.filter((n) => canUserAccess(nestId, n.id, userEmail).allowed);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// src/nodes/routes.ts
|
|
841
|
+
var nodeRoutes = new Hono4();
|
|
842
|
+
function toNodeResponse(node) {
|
|
843
|
+
return {
|
|
844
|
+
id: node.id,
|
|
845
|
+
title: node.frontmatter.title,
|
|
846
|
+
type: node.frontmatter.type || "document",
|
|
847
|
+
tags: node.frontmatter.tags || [],
|
|
848
|
+
status: node.frontmatter.status || "draft",
|
|
849
|
+
version: node.frontmatter.version || 1,
|
|
850
|
+
author: node.frontmatter.author,
|
|
851
|
+
description: node.frontmatter.description,
|
|
852
|
+
created_at: node.frontmatter.created_at,
|
|
853
|
+
updated_at: node.frontmatter.updated_at,
|
|
854
|
+
content: node.body
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
nodeRoutes.get("/", async (c) => {
|
|
858
|
+
const nestId = c.req.param("nestId");
|
|
859
|
+
const { storage } = engineCache.get(nestId);
|
|
860
|
+
const documents = await storage.discoverDocuments();
|
|
861
|
+
const userEmail = resolveCallerEmail(c.get("userId"));
|
|
862
|
+
const accessible = filterAccessible(nestId, userEmail, documents);
|
|
863
|
+
return c.json({
|
|
864
|
+
count: accessible.length,
|
|
865
|
+
nodes: accessible.map(toNodeResponse)
|
|
866
|
+
});
|
|
867
|
+
});
|
|
868
|
+
nodeRoutes.post("/", async (c) => {
|
|
869
|
+
const body = await c.req.json();
|
|
870
|
+
if (!body.title || !body.content) {
|
|
871
|
+
throw new ValidationError("title and content are required");
|
|
872
|
+
}
|
|
873
|
+
const nestId = c.req.param("nestId");
|
|
874
|
+
const { storage } = engineCache.get(nestId);
|
|
875
|
+
const slug = body.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
876
|
+
const id = `nodes/${slug}`;
|
|
877
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
878
|
+
const tags = body.tags?.map((t) => t.startsWith("#") ? t : `#${t}`) || [];
|
|
879
|
+
const hasStewards = isStewardshipEnabled(nestId);
|
|
880
|
+
const initialStatus = hasStewards ? "draft" : "approved";
|
|
881
|
+
const node = {
|
|
882
|
+
id,
|
|
883
|
+
filePath: "",
|
|
884
|
+
frontmatter: {
|
|
885
|
+
title: body.title,
|
|
886
|
+
type: body.type || "document",
|
|
887
|
+
tags,
|
|
888
|
+
status: body.status || initialStatus,
|
|
889
|
+
version: 1,
|
|
890
|
+
created_at: now,
|
|
891
|
+
updated_at: now,
|
|
892
|
+
metadata: {
|
|
893
|
+
owners: ["*"],
|
|
894
|
+
scope: body.scope || "team"
|
|
895
|
+
}
|
|
896
|
+
},
|
|
897
|
+
body: body.content,
|
|
898
|
+
rawContent: ""
|
|
899
|
+
};
|
|
900
|
+
const serialized = serializeDocument(node);
|
|
901
|
+
await storage.writeDocument(id, serialized);
|
|
902
|
+
syncNodeTags(nestId, id, tags);
|
|
903
|
+
const authorEmail = getUserEmail(c);
|
|
904
|
+
createVersion({
|
|
905
|
+
nestId,
|
|
906
|
+
nodeId: id,
|
|
907
|
+
version: 1,
|
|
908
|
+
content: body.content,
|
|
909
|
+
author: authorEmail,
|
|
910
|
+
status: initialStatus,
|
|
911
|
+
tags
|
|
912
|
+
});
|
|
913
|
+
if (initialStatus === "approved") {
|
|
914
|
+
setApprovedVersion(nestId, id, 1, authorEmail);
|
|
915
|
+
}
|
|
916
|
+
trackEvent("node.create", { nestId, nodeId: id });
|
|
917
|
+
const resolved = resolveStewardsForNode(nestId, id);
|
|
918
|
+
return c.json({
|
|
919
|
+
node: toNodeResponse(node),
|
|
920
|
+
stewards: resolved.length > 0 ? resolved.map((r) => ({
|
|
921
|
+
email: r.steward.userEmail,
|
|
922
|
+
role: r.steward.role,
|
|
923
|
+
source: r.source
|
|
924
|
+
})) : void 0
|
|
925
|
+
}, 201);
|
|
926
|
+
});
|
|
927
|
+
nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
|
|
928
|
+
const nestId = c.req.param("nestId");
|
|
929
|
+
const nodeId = c.req.param("nodeId");
|
|
930
|
+
const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-NC67XBYO.js");
|
|
931
|
+
const { stewards, fallbackToOwner, ownerEmail } = resolveStewardsWithFallback2(
|
|
932
|
+
nestId,
|
|
933
|
+
nodeId
|
|
934
|
+
);
|
|
935
|
+
return c.json({
|
|
936
|
+
nodeId,
|
|
937
|
+
stewards: stewards.map((r) => ({
|
|
938
|
+
email: r.steward.userEmail,
|
|
939
|
+
role: r.steward.role,
|
|
940
|
+
scope: r.steward.scope,
|
|
941
|
+
source: r.source,
|
|
942
|
+
priority: r.priority,
|
|
943
|
+
canApprove: r.steward.canApprove
|
|
944
|
+
})),
|
|
945
|
+
fallbackToOwner,
|
|
946
|
+
ownerEmail
|
|
947
|
+
});
|
|
948
|
+
});
|
|
949
|
+
nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
|
|
950
|
+
const nestId = c.req.param("nestId");
|
|
951
|
+
const nodeId = c.req.param("nodeId");
|
|
952
|
+
const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-Z6FYJRAG.js");
|
|
953
|
+
const allVersions = getVersions2(nestId, nodeId);
|
|
954
|
+
const approved = getApprovedVersion2(nestId, nodeId);
|
|
955
|
+
return c.json({
|
|
956
|
+
versions: allVersions,
|
|
957
|
+
approvedVersion: approved,
|
|
958
|
+
currentVersion: allVersions[0]?.version || 0
|
|
959
|
+
});
|
|
960
|
+
});
|
|
961
|
+
nodeRoutes.get("/:nodeId{.+?}/reviews", async (c) => {
|
|
962
|
+
const nestId = c.req.param("nestId");
|
|
963
|
+
const nodeId = c.req.param("nodeId");
|
|
964
|
+
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-5CLVZKAR.js");
|
|
965
|
+
const history = getReviewHistory2(nestId, nodeId);
|
|
966
|
+
return c.json({ reviews: history });
|
|
967
|
+
});
|
|
968
|
+
nodeRoutes.get("/:nodeId{.+}", async (c) => {
|
|
969
|
+
const nestId = c.req.param("nestId");
|
|
970
|
+
const nodeId = c.req.param("nodeId");
|
|
971
|
+
const { storage } = engineCache.get(nestId);
|
|
972
|
+
const userEmail = resolveCallerEmail(c.get("userId"));
|
|
973
|
+
if (!canReadNode(nestId, nodeId, userEmail)) {
|
|
974
|
+
return c.json(
|
|
975
|
+
{ error: "Access denied \u2014 no steward assignment for this node" },
|
|
976
|
+
403
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
try {
|
|
980
|
+
const node = await storage.readDocument(nodeId);
|
|
981
|
+
return c.json({ node: toNodeResponse(node) });
|
|
982
|
+
} catch {
|
|
983
|
+
throw new NotFoundError(`Node not found: ${nodeId}`);
|
|
984
|
+
}
|
|
985
|
+
});
|
|
986
|
+
nodeRoutes.patch("/:nodeId{.+}", async (c) => {
|
|
987
|
+
const nestId = c.req.param("nestId");
|
|
988
|
+
const nodeId = c.req.param("nodeId");
|
|
989
|
+
const { storage } = engineCache.get(nestId);
|
|
990
|
+
const body = await c.req.json();
|
|
991
|
+
const baseVersionHeader = c.req.header("X-Base-Version");
|
|
992
|
+
if (baseVersionHeader) {
|
|
993
|
+
const baseVersion = parseInt(baseVersionHeader, 10);
|
|
994
|
+
const conflict = checkConflict(nestId, nodeId, baseVersion);
|
|
995
|
+
if (conflict.conflict) {
|
|
996
|
+
return c.json(
|
|
997
|
+
{
|
|
998
|
+
error: "Version conflict",
|
|
999
|
+
your_version: baseVersion,
|
|
1000
|
+
current_version: conflict.currentVersion,
|
|
1001
|
+
updated_by: conflict.updatedBy,
|
|
1002
|
+
updated_at: conflict.updatedAt,
|
|
1003
|
+
rejected_content: body.content || body.append || null
|
|
1004
|
+
},
|
|
1005
|
+
409
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
let node;
|
|
1010
|
+
try {
|
|
1011
|
+
node = await storage.readDocument(nodeId);
|
|
1012
|
+
} catch {
|
|
1013
|
+
throw new NotFoundError(`Node not found: ${nodeId}`);
|
|
1014
|
+
}
|
|
1015
|
+
if (body.content !== void 0) {
|
|
1016
|
+
node = { ...node, body: body.content };
|
|
1017
|
+
}
|
|
1018
|
+
if (body.append) {
|
|
1019
|
+
node = { ...node, body: (node.body || "") + "\n\n" + body.append };
|
|
1020
|
+
}
|
|
1021
|
+
if (body.tags) {
|
|
1022
|
+
const newTags = body.tags.map((t) => t.startsWith("#") ? t : `#${t}`);
|
|
1023
|
+
const merged = [.../* @__PURE__ */ new Set([...node.frontmatter.tags || [], ...newTags])];
|
|
1024
|
+
node = { ...node, frontmatter: { ...node.frontmatter, tags: merged } };
|
|
1025
|
+
}
|
|
1026
|
+
if (body.status) {
|
|
1027
|
+
node = {
|
|
1028
|
+
...node,
|
|
1029
|
+
frontmatter: { ...node.frontmatter, status: body.status }
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
if (body.title) {
|
|
1033
|
+
node = {
|
|
1034
|
+
...node,
|
|
1035
|
+
frontmatter: { ...node.frontmatter, title: body.title }
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
const currentVersion = getCurrentVersion(nestId, nodeId);
|
|
1039
|
+
const newVersion = currentVersion + 1;
|
|
1040
|
+
node = {
|
|
1041
|
+
...node,
|
|
1042
|
+
frontmatter: {
|
|
1043
|
+
...node.frontmatter,
|
|
1044
|
+
version: newVersion,
|
|
1045
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1046
|
+
}
|
|
1047
|
+
};
|
|
1048
|
+
const fm = Object.fromEntries(
|
|
1049
|
+
Object.entries(node.frontmatter).filter(([, v]) => v !== void 0)
|
|
1050
|
+
);
|
|
1051
|
+
node = { ...node, frontmatter: fm };
|
|
1052
|
+
const serialized = serializeDocument(node);
|
|
1053
|
+
await storage.writeDocument(nodeId, serialized);
|
|
1054
|
+
const authorEmail = getUserEmail(c);
|
|
1055
|
+
const hasStewards = isStewardshipEnabled(nestId);
|
|
1056
|
+
const currentTags = node.frontmatter.tags || [];
|
|
1057
|
+
syncNodeTags(nestId, nodeId, currentTags);
|
|
1058
|
+
createVersion({
|
|
1059
|
+
nestId,
|
|
1060
|
+
nodeId,
|
|
1061
|
+
version: newVersion,
|
|
1062
|
+
content: node.body || "",
|
|
1063
|
+
author: authorEmail,
|
|
1064
|
+
status: hasStewards ? "draft" : "approved",
|
|
1065
|
+
tags: currentTags,
|
|
1066
|
+
changeNote: body.changeNote
|
|
1067
|
+
});
|
|
1068
|
+
if (!hasStewards) {
|
|
1069
|
+
setApprovedVersion(nestId, nodeId, newVersion, authorEmail);
|
|
1070
|
+
}
|
|
1071
|
+
return c.json({ node: toNodeResponse(node), version: newVersion });
|
|
1072
|
+
});
|
|
1073
|
+
nodeRoutes.delete("/:nodeId{.+}", async (c) => {
|
|
1074
|
+
const nestId = c.req.param("nestId");
|
|
1075
|
+
const nodeId = c.req.param("nodeId");
|
|
1076
|
+
const { storage } = engineCache.get(nestId);
|
|
1077
|
+
try {
|
|
1078
|
+
await storage.deleteDocument(nodeId);
|
|
1079
|
+
} catch {
|
|
1080
|
+
throw new NotFoundError(`Node not found: ${nodeId}`);
|
|
1081
|
+
}
|
|
1082
|
+
removeNodeFromTagIndex(nestId, nodeId);
|
|
1083
|
+
trackEvent("node.delete", { nestId, nodeId });
|
|
1084
|
+
return c.json({ deleted: true });
|
|
1085
|
+
});
|
|
1086
|
+
function getUserEmail(c) {
|
|
1087
|
+
const userId = c.get("userId");
|
|
1088
|
+
const db = getDb();
|
|
1089
|
+
const user = db.prepare("SELECT email FROM users WHERE id = ?").get(userId);
|
|
1090
|
+
return user?.email || "anonymous@localhost";
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// src/nodes/query-routes.ts
|
|
1094
|
+
import { Hono as Hono5 } from "hono";
|
|
1095
|
+
import { serializeDocument as serializeDocument2 } from "@promptowl/contextnest-engine";
|
|
1096
|
+
|
|
1097
|
+
// src/nodes/prompt-compiler.ts
|
|
1098
|
+
var STOPWORDS = /* @__PURE__ */ new Set([
|
|
1099
|
+
"the",
|
|
1100
|
+
"a",
|
|
1101
|
+
"an",
|
|
1102
|
+
"and",
|
|
1103
|
+
"or",
|
|
1104
|
+
"but",
|
|
1105
|
+
"if",
|
|
1106
|
+
"then",
|
|
1107
|
+
"else",
|
|
1108
|
+
"so",
|
|
1109
|
+
"as",
|
|
1110
|
+
"is",
|
|
1111
|
+
"are",
|
|
1112
|
+
"was",
|
|
1113
|
+
"were",
|
|
1114
|
+
"be",
|
|
1115
|
+
"been",
|
|
1116
|
+
"being",
|
|
1117
|
+
"have",
|
|
1118
|
+
"has",
|
|
1119
|
+
"had",
|
|
1120
|
+
"do",
|
|
1121
|
+
"does",
|
|
1122
|
+
"did",
|
|
1123
|
+
"done",
|
|
1124
|
+
"can",
|
|
1125
|
+
"could",
|
|
1126
|
+
"should",
|
|
1127
|
+
"would",
|
|
1128
|
+
"may",
|
|
1129
|
+
"might",
|
|
1130
|
+
"must",
|
|
1131
|
+
"shall",
|
|
1132
|
+
"will",
|
|
1133
|
+
"about",
|
|
1134
|
+
"above",
|
|
1135
|
+
"after",
|
|
1136
|
+
"again",
|
|
1137
|
+
"against",
|
|
1138
|
+
"all",
|
|
1139
|
+
"am",
|
|
1140
|
+
"any",
|
|
1141
|
+
"because",
|
|
1142
|
+
"before",
|
|
1143
|
+
"below",
|
|
1144
|
+
"between",
|
|
1145
|
+
"both",
|
|
1146
|
+
"by",
|
|
1147
|
+
"during",
|
|
1148
|
+
"each",
|
|
1149
|
+
"few",
|
|
1150
|
+
"for",
|
|
1151
|
+
"from",
|
|
1152
|
+
"further",
|
|
1153
|
+
"here",
|
|
1154
|
+
"how",
|
|
1155
|
+
"i",
|
|
1156
|
+
"me",
|
|
1157
|
+
"my",
|
|
1158
|
+
"myself",
|
|
1159
|
+
"we",
|
|
1160
|
+
"us",
|
|
1161
|
+
"our",
|
|
1162
|
+
"ours",
|
|
1163
|
+
"you",
|
|
1164
|
+
"your",
|
|
1165
|
+
"yours",
|
|
1166
|
+
"he",
|
|
1167
|
+
"him",
|
|
1168
|
+
"his",
|
|
1169
|
+
"she",
|
|
1170
|
+
"her",
|
|
1171
|
+
"hers",
|
|
1172
|
+
"it",
|
|
1173
|
+
"its",
|
|
1174
|
+
"they",
|
|
1175
|
+
"them",
|
|
1176
|
+
"their",
|
|
1177
|
+
"theirs",
|
|
1178
|
+
"this",
|
|
1179
|
+
"that",
|
|
1180
|
+
"these",
|
|
1181
|
+
"those",
|
|
1182
|
+
"to",
|
|
1183
|
+
"too",
|
|
1184
|
+
"very",
|
|
1185
|
+
"of",
|
|
1186
|
+
"on",
|
|
1187
|
+
"off",
|
|
1188
|
+
"over",
|
|
1189
|
+
"under",
|
|
1190
|
+
"up",
|
|
1191
|
+
"down",
|
|
1192
|
+
"in",
|
|
1193
|
+
"out",
|
|
1194
|
+
"into",
|
|
1195
|
+
"onto",
|
|
1196
|
+
"some",
|
|
1197
|
+
"such",
|
|
1198
|
+
"same",
|
|
1199
|
+
"other",
|
|
1200
|
+
"than",
|
|
1201
|
+
"what",
|
|
1202
|
+
"when",
|
|
1203
|
+
"where",
|
|
1204
|
+
"which",
|
|
1205
|
+
"who",
|
|
1206
|
+
"whom",
|
|
1207
|
+
"why",
|
|
1208
|
+
"with",
|
|
1209
|
+
"without",
|
|
1210
|
+
"just",
|
|
1211
|
+
"also",
|
|
1212
|
+
"only",
|
|
1213
|
+
"more",
|
|
1214
|
+
"most",
|
|
1215
|
+
"use",
|
|
1216
|
+
"using",
|
|
1217
|
+
"used",
|
|
1218
|
+
"get",
|
|
1219
|
+
"got",
|
|
1220
|
+
"make",
|
|
1221
|
+
"made",
|
|
1222
|
+
"need",
|
|
1223
|
+
"want",
|
|
1224
|
+
"like",
|
|
1225
|
+
"help",
|
|
1226
|
+
"show",
|
|
1227
|
+
"tell",
|
|
1228
|
+
"give",
|
|
1229
|
+
"take",
|
|
1230
|
+
"find",
|
|
1231
|
+
"look",
|
|
1232
|
+
"know",
|
|
1233
|
+
"think",
|
|
1234
|
+
"say",
|
|
1235
|
+
"mean",
|
|
1236
|
+
"tell",
|
|
1237
|
+
"not",
|
|
1238
|
+
"no",
|
|
1239
|
+
"yes",
|
|
1240
|
+
"okay",
|
|
1241
|
+
"ok",
|
|
1242
|
+
"please",
|
|
1243
|
+
"thanks",
|
|
1244
|
+
"thank"
|
|
1245
|
+
]);
|
|
1246
|
+
function tokenizePrompt(prompt) {
|
|
1247
|
+
const words = prompt.toLowerCase().split(/[^a-z0-9_-]+/).filter(Boolean).filter((w) => w.length >= 3 && !STOPWORDS.has(w));
|
|
1248
|
+
return Array.from(new Set(words));
|
|
1249
|
+
}
|
|
1250
|
+
function compilePrompt(prompt, nestId, titles) {
|
|
1251
|
+
const tokens = tokenizePrompt(prompt);
|
|
1252
|
+
if (tokens.length === 0) {
|
|
1253
|
+
return { selector: null, matchedTags: [], matchedTitles: [], unmatched: [] };
|
|
1254
|
+
}
|
|
1255
|
+
const db = getDb();
|
|
1256
|
+
const tagRows = db.prepare(
|
|
1257
|
+
"SELECT DISTINCT tag_name FROM node_tag_index WHERE nest_id = ?"
|
|
1258
|
+
).all(nestId);
|
|
1259
|
+
const knownTags = new Set(tagRows.map((r) => r.tag_name));
|
|
1260
|
+
const matchedTags = /* @__PURE__ */ new Set();
|
|
1261
|
+
for (const token of tokens) {
|
|
1262
|
+
const norm = normalizeTag(token);
|
|
1263
|
+
if (knownTags.has(norm)) matchedTags.add(norm);
|
|
1264
|
+
}
|
|
1265
|
+
const matchedTitles = /* @__PURE__ */ new Set();
|
|
1266
|
+
for (const title of titles) {
|
|
1267
|
+
const titleTokens = tokenizePrompt(title);
|
|
1268
|
+
if (titleTokens.length === 0) continue;
|
|
1269
|
+
const hit = titleTokens.some((t) => tokens.includes(t));
|
|
1270
|
+
if (hit) matchedTitles.add(title);
|
|
1271
|
+
}
|
|
1272
|
+
const unmatched = tokens.filter(
|
|
1273
|
+
(t) => !matchedTags.has(normalizeTag(t)) && !Array.from(matchedTitles).some(
|
|
1274
|
+
(title) => tokenizePrompt(title).includes(t)
|
|
1275
|
+
)
|
|
1276
|
+
);
|
|
1277
|
+
const tagParts = Array.from(matchedTags).map((t) => `#${t}`);
|
|
1278
|
+
return {
|
|
1279
|
+
selector: tagParts.length > 0 ? tagParts.join(" | ") : null,
|
|
1280
|
+
matchedTags: Array.from(matchedTags),
|
|
1281
|
+
matchedTitles: Array.from(matchedTitles),
|
|
1282
|
+
unmatched
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// src/nodes/query-routes.ts
|
|
1287
|
+
var queryRoutes = new Hono5();
|
|
1288
|
+
function approxTokens(text) {
|
|
1289
|
+
return Math.ceil(text.length / 4);
|
|
1290
|
+
}
|
|
1291
|
+
function formatContextBlock(node) {
|
|
1292
|
+
const tags = (node.frontmatter.tags || []).join(" ");
|
|
1293
|
+
const header = tags ? `# ${node.frontmatter.title}
|
|
1294
|
+
|
|
1295
|
+
_tags: ${tags}_
|
|
1296
|
+
|
|
1297
|
+
` : `# ${node.frontmatter.title}
|
|
1298
|
+
|
|
1299
|
+
`;
|
|
1300
|
+
return header + (node.body || "").trim();
|
|
1301
|
+
}
|
|
1302
|
+
queryRoutes.post("/context", async (c) => {
|
|
1303
|
+
const body = await c.req.json();
|
|
1304
|
+
if (!body.prompt && !body.selector) {
|
|
1305
|
+
throw new ValidationError("prompt or selector is required");
|
|
1306
|
+
}
|
|
1307
|
+
const nestId = c.req.param("nestId");
|
|
1308
|
+
const { query: queryEngine, storage } = engineCache.get(nestId);
|
|
1309
|
+
const maxTokens = Math.max(50, body.max_tokens ?? 4e3);
|
|
1310
|
+
const hops = body.hops ?? 2;
|
|
1311
|
+
const includeDrafts = body.include_drafts === true;
|
|
1312
|
+
let selector = body.selector?.trim() || null;
|
|
1313
|
+
let compileDetail = null;
|
|
1314
|
+
let titleMatches = [];
|
|
1315
|
+
const allDocs = await storage.discoverDocuments();
|
|
1316
|
+
if (!selector && body.prompt) {
|
|
1317
|
+
const titles = allDocs.map((d) => d.frontmatter.title);
|
|
1318
|
+
compileDetail = compilePrompt(body.prompt, nestId, titles);
|
|
1319
|
+
selector = compileDetail.selector;
|
|
1320
|
+
if (compileDetail.matchedTitles.length > 0) {
|
|
1321
|
+
const matchedLower = new Set(
|
|
1322
|
+
compileDetail.matchedTitles.map((t) => t.toLowerCase())
|
|
1323
|
+
);
|
|
1324
|
+
titleMatches = allDocs.filter(
|
|
1325
|
+
(d) => matchedLower.has(d.frontmatter.title.toLowerCase())
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
let documents = [];
|
|
1330
|
+
let hopsUsed = 0;
|
|
1331
|
+
let nodesTraversed = 0;
|
|
1332
|
+
if (selector) {
|
|
1333
|
+
const result = await queryEngine.query(selector, {
|
|
1334
|
+
hops,
|
|
1335
|
+
includeDrafts,
|
|
1336
|
+
full: true
|
|
1337
|
+
});
|
|
1338
|
+
documents = result.documents;
|
|
1339
|
+
hopsUsed = result.hopsUsed;
|
|
1340
|
+
nodesTraversed = result.nodesTraversed;
|
|
1341
|
+
}
|
|
1342
|
+
if (titleMatches.length > 0) {
|
|
1343
|
+
const seen = new Set(documents.map((d) => d.id));
|
|
1344
|
+
for (const t of titleMatches) {
|
|
1345
|
+
if (!seen.has(t.id)) {
|
|
1346
|
+
documents.push(t);
|
|
1347
|
+
seen.add(t.id);
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
const userEmail = resolveCallerEmail(c.get("userId"));
|
|
1352
|
+
const beforePermission = documents.length;
|
|
1353
|
+
const accessible = filterAccessible(nestId, userEmail, documents);
|
|
1354
|
+
const permissionFiltered = beforePermission - accessible.length;
|
|
1355
|
+
const included = [];
|
|
1356
|
+
let tokenCount = 0;
|
|
1357
|
+
for (const doc of accessible) {
|
|
1358
|
+
const block = formatContextBlock(doc);
|
|
1359
|
+
const blockTokens = approxTokens(block);
|
|
1360
|
+
if (tokenCount + blockTokens > maxTokens && included.length > 0) break;
|
|
1361
|
+
included.push(doc);
|
|
1362
|
+
tokenCount += blockTokens;
|
|
1363
|
+
}
|
|
1364
|
+
const truncatedByBudget = accessible.length - included.length;
|
|
1365
|
+
const contextMarkdown = included.map(formatContextBlock).join("\n\n---\n\n");
|
|
1366
|
+
trackEvent("context.resolve", {
|
|
1367
|
+
nestId,
|
|
1368
|
+
selector_from_prompt: !!compileDetail,
|
|
1369
|
+
included: included.length,
|
|
1370
|
+
permission_filtered: permissionFiltered,
|
|
1371
|
+
token_count: tokenCount
|
|
1372
|
+
});
|
|
1373
|
+
return c.json({
|
|
1374
|
+
context: contextMarkdown,
|
|
1375
|
+
nodes: included.map((n) => ({
|
|
1376
|
+
id: n.id,
|
|
1377
|
+
title: n.frontmatter.title,
|
|
1378
|
+
tags: n.frontmatter.tags || [],
|
|
1379
|
+
type: n.frontmatter.type || "document"
|
|
1380
|
+
})),
|
|
1381
|
+
trace: {
|
|
1382
|
+
selector_used: selector,
|
|
1383
|
+
from_prompt: body.prompt ?? null,
|
|
1384
|
+
compiled: compileDetail ? {
|
|
1385
|
+
matched_tags: compileDetail.matchedTags,
|
|
1386
|
+
matched_titles: compileDetail.matchedTitles,
|
|
1387
|
+
unmatched_tokens: compileDetail.unmatched
|
|
1388
|
+
} : null,
|
|
1389
|
+
total_matched: documents.length,
|
|
1390
|
+
permission_filtered: permissionFiltered,
|
|
1391
|
+
included: included.length,
|
|
1392
|
+
truncated_by_budget: truncatedByBudget,
|
|
1393
|
+
hops_used: hopsUsed,
|
|
1394
|
+
nodes_traversed: nodesTraversed,
|
|
1395
|
+
approx_tokens: tokenCount,
|
|
1396
|
+
max_tokens: maxTokens
|
|
1397
|
+
}
|
|
1398
|
+
});
|
|
1399
|
+
});
|
|
1400
|
+
queryRoutes.post("/query", async (c) => {
|
|
1401
|
+
const body = await c.req.json();
|
|
1402
|
+
if (!body.query) {
|
|
1403
|
+
throw new ValidationError("query is required");
|
|
1404
|
+
}
|
|
1405
|
+
const nestId = c.req.param("nestId");
|
|
1406
|
+
const { query: queryEngine } = engineCache.get(nestId);
|
|
1407
|
+
const result = await queryEngine.query(body.query, {
|
|
1408
|
+
hops: body.hops ?? 2
|
|
1409
|
+
});
|
|
1410
|
+
const userEmail = resolveCallerEmail(c.get("userId"));
|
|
1411
|
+
const accessible = filterAccessible(nestId, userEmail, result.documents);
|
|
1412
|
+
return c.json({
|
|
1413
|
+
query: body.query,
|
|
1414
|
+
count: accessible.length,
|
|
1415
|
+
nodes: accessible.map((n) => ({
|
|
1416
|
+
id: n.id,
|
|
1417
|
+
title: n.frontmatter.title,
|
|
1418
|
+
type: n.frontmatter.type || "document",
|
|
1419
|
+
tags: n.frontmatter.tags || [],
|
|
1420
|
+
snippet: (n.body || "").slice(0, 200).replace(/\n/g, " ")
|
|
1421
|
+
}))
|
|
1422
|
+
});
|
|
1423
|
+
});
|
|
1424
|
+
queryRoutes.get("/search", async (c) => {
|
|
1425
|
+
const q = c.req.query("q");
|
|
1426
|
+
if (!q) {
|
|
1427
|
+
throw new ValidationError("q query parameter is required");
|
|
1428
|
+
}
|
|
1429
|
+
const nestId = c.req.param("nestId");
|
|
1430
|
+
const { storage } = engineCache.get(nestId);
|
|
1431
|
+
const documents = await storage.discoverDocuments();
|
|
1432
|
+
const terms = q.toLowerCase().split(/\s+/).filter(Boolean);
|
|
1433
|
+
const matches = documents.filter((node) => {
|
|
1434
|
+
const haystack = [
|
|
1435
|
+
node.frontmatter.title,
|
|
1436
|
+
node.body || "",
|
|
1437
|
+
node.frontmatter.type || "",
|
|
1438
|
+
...node.frontmatter.tags || []
|
|
1439
|
+
].join(" ").toLowerCase();
|
|
1440
|
+
return terms.every((term) => haystack.includes(term));
|
|
1441
|
+
});
|
|
1442
|
+
const userEmail = resolveCallerEmail(c.get("userId"));
|
|
1443
|
+
const accessible = filterAccessible(nestId, userEmail, matches);
|
|
1444
|
+
return c.json({
|
|
1445
|
+
query: q,
|
|
1446
|
+
count: accessible.length,
|
|
1447
|
+
nodes: accessible.map((n) => ({
|
|
1448
|
+
id: n.id,
|
|
1449
|
+
title: n.frontmatter.title,
|
|
1450
|
+
type: n.frontmatter.type || "document",
|
|
1451
|
+
tags: n.frontmatter.tags || [],
|
|
1452
|
+
snippet: (n.body || "").slice(0, 200).replace(/\n/g, " ")
|
|
1453
|
+
}))
|
|
1454
|
+
});
|
|
1455
|
+
});
|
|
1456
|
+
queryRoutes.get("/overview", async (c) => {
|
|
1457
|
+
const { storage } = engineCache.get(c.req.param("nestId"));
|
|
1458
|
+
const documents = await storage.discoverDocuments();
|
|
1459
|
+
const types = {};
|
|
1460
|
+
const tags = {};
|
|
1461
|
+
for (const node of documents) {
|
|
1462
|
+
const t = node.frontmatter.type || "document";
|
|
1463
|
+
types[t] = (types[t] || 0) + 1;
|
|
1464
|
+
for (const tag of node.frontmatter.tags || []) {
|
|
1465
|
+
tags[tag] = (tags[tag] || 0) + 1;
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
return c.json({
|
|
1469
|
+
total: documents.length,
|
|
1470
|
+
types,
|
|
1471
|
+
tags,
|
|
1472
|
+
nodes: documents.map((n) => ({
|
|
1473
|
+
id: n.id,
|
|
1474
|
+
title: n.frontmatter.title,
|
|
1475
|
+
type: n.frontmatter.type || "document",
|
|
1476
|
+
tags: n.frontmatter.tags || [],
|
|
1477
|
+
snippet: (n.body || "").slice(0, 120).replace(/\n/g, " ")
|
|
1478
|
+
}))
|
|
1479
|
+
});
|
|
1480
|
+
});
|
|
1481
|
+
queryRoutes.get("/context", async (c) => {
|
|
1482
|
+
const { storage } = engineCache.get(c.req.param("nestId"));
|
|
1483
|
+
const content = await storage.readContextMd();
|
|
1484
|
+
return c.json({ content: content || "" });
|
|
1485
|
+
});
|
|
1486
|
+
queryRoutes.post("/publish", async (c) => {
|
|
1487
|
+
const body = await c.req.json();
|
|
1488
|
+
if (!body.documents?.length && !body.context_md) {
|
|
1489
|
+
throw new ValidationError("documents array or context_md is required");
|
|
1490
|
+
}
|
|
1491
|
+
const nestId = c.req.param("nestId");
|
|
1492
|
+
const { storage } = engineCache.get(nestId);
|
|
1493
|
+
const created = [];
|
|
1494
|
+
if (body.context_md) {
|
|
1495
|
+
await storage.writeContextMd(body.context_md);
|
|
1496
|
+
}
|
|
1497
|
+
for (const doc of body.documents || []) {
|
|
1498
|
+
if (!doc.title || !doc.content) continue;
|
|
1499
|
+
const slug = doc.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1500
|
+
const id = `nodes/${slug}`;
|
|
1501
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1502
|
+
const tags = doc.tags?.map((t) => t.startsWith("#") ? t : `#${t}`) || [];
|
|
1503
|
+
const node = {
|
|
1504
|
+
id,
|
|
1505
|
+
filePath: "",
|
|
1506
|
+
frontmatter: {
|
|
1507
|
+
title: doc.title,
|
|
1508
|
+
type: doc.type || "document",
|
|
1509
|
+
tags,
|
|
1510
|
+
status: "draft",
|
|
1511
|
+
version: 1,
|
|
1512
|
+
created_at: now,
|
|
1513
|
+
updated_at: now,
|
|
1514
|
+
metadata: {
|
|
1515
|
+
owners: ["*"],
|
|
1516
|
+
scope: doc.scope || "team"
|
|
1517
|
+
}
|
|
1518
|
+
},
|
|
1519
|
+
body: doc.content,
|
|
1520
|
+
rawContent: ""
|
|
1521
|
+
};
|
|
1522
|
+
const serialized = serializeDocument2(node);
|
|
1523
|
+
await storage.writeDocument(id, serialized);
|
|
1524
|
+
syncNodeTags(nestId, id, tags);
|
|
1525
|
+
created.push(id);
|
|
1526
|
+
}
|
|
1527
|
+
trackEvent("nest.publish", { nestId, count: created.length });
|
|
1528
|
+
return c.json(
|
|
1529
|
+
{
|
|
1530
|
+
published: created.length,
|
|
1531
|
+
context_md_updated: !!body.context_md,
|
|
1532
|
+
node_ids: created
|
|
1533
|
+
},
|
|
1534
|
+
201
|
|
1535
|
+
);
|
|
1536
|
+
});
|
|
1537
|
+
|
|
1538
|
+
// src/mcp/routes.ts
|
|
1539
|
+
import { Hono as Hono6 } from "hono";
|
|
1540
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
1541
|
+
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
1542
|
+
|
|
1543
|
+
// src/mcp/tools.ts
|
|
1544
|
+
import { serializeDocument as serializeDocument3 } from "@promptowl/contextnest-engine";
|
|
1545
|
+
var TOOL_DEFINITIONS = [
|
|
1546
|
+
{
|
|
1547
|
+
name: "context_init",
|
|
1548
|
+
description: "Load the vault's CONTEXT.md which contains operating instructions and behavioral guidelines. Call this FIRST in every conversation.",
|
|
1549
|
+
inputSchema: { type: "object", properties: {} }
|
|
1550
|
+
},
|
|
1551
|
+
{
|
|
1552
|
+
name: "context_overview",
|
|
1553
|
+
description: "Get a complete map of the vault: total node count, types, tags, and a title+snippet for every node.",
|
|
1554
|
+
inputSchema: { type: "object", properties: {} }
|
|
1555
|
+
},
|
|
1556
|
+
{
|
|
1557
|
+
name: "context_search",
|
|
1558
|
+
description: "Full-text keyword search across all node content, titles, tags, and metadata.",
|
|
1559
|
+
inputSchema: {
|
|
1560
|
+
type: "object",
|
|
1561
|
+
properties: {
|
|
1562
|
+
query: { type: "string", description: "Search terms" }
|
|
1563
|
+
},
|
|
1564
|
+
required: ["query"]
|
|
1565
|
+
}
|
|
1566
|
+
},
|
|
1567
|
+
{
|
|
1568
|
+
name: "context_query",
|
|
1569
|
+
description: "Run a structured selector query. Supports: #tag, type:X, [[Title]], scope:X. Combine with +AND, |OR, -NOT.",
|
|
1570
|
+
inputSchema: {
|
|
1571
|
+
type: "object",
|
|
1572
|
+
properties: {
|
|
1573
|
+
query: { type: "string", description: "Selector query" }
|
|
1574
|
+
},
|
|
1575
|
+
required: ["query"]
|
|
1576
|
+
}
|
|
1577
|
+
},
|
|
1578
|
+
{
|
|
1579
|
+
name: "context_get",
|
|
1580
|
+
description: "Get the FULL content of a specific node by title or ID.",
|
|
1581
|
+
inputSchema: {
|
|
1582
|
+
type: "object",
|
|
1583
|
+
properties: {
|
|
1584
|
+
title: { type: "string", description: "Title of the node" },
|
|
1585
|
+
id: { type: "string", description: "ID of the node" }
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
},
|
|
1589
|
+
{
|
|
1590
|
+
name: "context_list",
|
|
1591
|
+
description: "Browse vault contents with optional type, tag, or limit filters.",
|
|
1592
|
+
inputSchema: {
|
|
1593
|
+
type: "object",
|
|
1594
|
+
properties: {
|
|
1595
|
+
type: { type: "string", description: "Filter by node type" },
|
|
1596
|
+
tag: { type: "string", description: "Filter by tag" },
|
|
1597
|
+
limit: { type: "number", description: "Max nodes to return" }
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
},
|
|
1601
|
+
{
|
|
1602
|
+
name: "context_resolve",
|
|
1603
|
+
description: "Full context resolution \u2014 run a selector and return complete node content, respecting a token budget.",
|
|
1604
|
+
inputSchema: {
|
|
1605
|
+
type: "object",
|
|
1606
|
+
properties: {
|
|
1607
|
+
selector: {
|
|
1608
|
+
type: "string",
|
|
1609
|
+
description: "Selector query string"
|
|
1610
|
+
},
|
|
1611
|
+
max_tokens: {
|
|
1612
|
+
type: "number",
|
|
1613
|
+
description: "Approximate token budget (default: 8000)"
|
|
1614
|
+
}
|
|
1615
|
+
},
|
|
1616
|
+
required: ["selector"]
|
|
1617
|
+
}
|
|
1618
|
+
},
|
|
1619
|
+
{
|
|
1620
|
+
name: "context_create",
|
|
1621
|
+
description: "Create a new knowledge node in the vault.",
|
|
1622
|
+
inputSchema: {
|
|
1623
|
+
type: "object",
|
|
1624
|
+
properties: {
|
|
1625
|
+
title: { type: "string", description: "Descriptive title" },
|
|
1626
|
+
content: {
|
|
1627
|
+
type: "string",
|
|
1628
|
+
description: "Markdown content body"
|
|
1629
|
+
},
|
|
1630
|
+
type: {
|
|
1631
|
+
type: "string",
|
|
1632
|
+
description: "Node type (default: document)"
|
|
1633
|
+
},
|
|
1634
|
+
tags: {
|
|
1635
|
+
type: "array",
|
|
1636
|
+
items: { type: "string" },
|
|
1637
|
+
description: "Tags"
|
|
1638
|
+
},
|
|
1639
|
+
scope: { type: "string", description: "Visibility scope" }
|
|
1640
|
+
},
|
|
1641
|
+
required: ["title", "content"]
|
|
1642
|
+
}
|
|
1643
|
+
},
|
|
1644
|
+
{
|
|
1645
|
+
name: "context_update",
|
|
1646
|
+
description: "Update an existing node \u2014 append, replace content, add tags.",
|
|
1647
|
+
inputSchema: {
|
|
1648
|
+
type: "object",
|
|
1649
|
+
properties: {
|
|
1650
|
+
title: {
|
|
1651
|
+
type: "string",
|
|
1652
|
+
description: "Title of node to update"
|
|
1653
|
+
},
|
|
1654
|
+
content: {
|
|
1655
|
+
type: "string",
|
|
1656
|
+
description: "New content (replaces)"
|
|
1657
|
+
},
|
|
1658
|
+
append: { type: "string", description: "Content to append" },
|
|
1659
|
+
tags: {
|
|
1660
|
+
type: "array",
|
|
1661
|
+
items: { type: "string" },
|
|
1662
|
+
description: "Tags to add"
|
|
1663
|
+
},
|
|
1664
|
+
scope: { type: "string", description: "New scope" }
|
|
1665
|
+
},
|
|
1666
|
+
required: ["title"]
|
|
1667
|
+
}
|
|
1668
|
+
},
|
|
1669
|
+
// ─── Governance Tools ───────────────────────────────────────────────
|
|
1670
|
+
{
|
|
1671
|
+
name: "context_stewards",
|
|
1672
|
+
description: "Show who the data stewards are for a specific node, or list all stewards for the vault. Stewards are the people who approve or reject changes before they go live.",
|
|
1673
|
+
inputSchema: {
|
|
1674
|
+
type: "object",
|
|
1675
|
+
properties: {
|
|
1676
|
+
title: {
|
|
1677
|
+
type: "string",
|
|
1678
|
+
description: "Title of node to resolve stewards for (optional \u2014 omit for all stewards)"
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
},
|
|
1683
|
+
{
|
|
1684
|
+
name: "context_review_queue",
|
|
1685
|
+
description: "Show documents pending review. These need a steward to approve or reject them before they become available to AI.",
|
|
1686
|
+
inputSchema: {
|
|
1687
|
+
type: "object",
|
|
1688
|
+
properties: {
|
|
1689
|
+
status: {
|
|
1690
|
+
type: "string",
|
|
1691
|
+
description: "Filter by status: pending (default), approved, rejected, cancelled"
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
},
|
|
1696
|
+
{
|
|
1697
|
+
name: "context_submit_review",
|
|
1698
|
+
description: "Submit a node for steward review. The node will be marked as pending review and stewards will be notified.",
|
|
1699
|
+
inputSchema: {
|
|
1700
|
+
type: "object",
|
|
1701
|
+
properties: {
|
|
1702
|
+
title: { type: "string", description: "Title of the node to submit" },
|
|
1703
|
+
note: { type: "string", description: "Optional note for the reviewer" },
|
|
1704
|
+
priority: { type: "string", description: "Priority: low, normal (default), high, urgent" }
|
|
1705
|
+
},
|
|
1706
|
+
required: ["title"]
|
|
1707
|
+
}
|
|
1708
|
+
},
|
|
1709
|
+
{
|
|
1710
|
+
name: "context_approve",
|
|
1711
|
+
description: "Approve a node that is pending review. Only stewards with approval permission can do this. The approved version becomes available to AI queries.",
|
|
1712
|
+
inputSchema: {
|
|
1713
|
+
type: "object",
|
|
1714
|
+
properties: {
|
|
1715
|
+
title: { type: "string", description: "Title of the node to approve" },
|
|
1716
|
+
note: { type: "string", description: "Optional approval note" }
|
|
1717
|
+
},
|
|
1718
|
+
required: ["title"]
|
|
1719
|
+
}
|
|
1720
|
+
},
|
|
1721
|
+
{
|
|
1722
|
+
name: "context_reject",
|
|
1723
|
+
description: "Reject a node that is pending review with a required note explaining what needs to change. Only stewards can reject.",
|
|
1724
|
+
inputSchema: {
|
|
1725
|
+
type: "object",
|
|
1726
|
+
properties: {
|
|
1727
|
+
title: { type: "string", description: "Title of the node to reject" },
|
|
1728
|
+
note: { type: "string", description: "Required: explain what needs to change" }
|
|
1729
|
+
},
|
|
1730
|
+
required: ["title", "note"]
|
|
1731
|
+
}
|
|
1732
|
+
},
|
|
1733
|
+
{
|
|
1734
|
+
name: "context_versions",
|
|
1735
|
+
description: "Show the version history of a node \u2014 all edits, who made them, their approval status, and which version is currently approved for AI use.",
|
|
1736
|
+
inputSchema: {
|
|
1737
|
+
type: "object",
|
|
1738
|
+
properties: {
|
|
1739
|
+
title: { type: "string", description: "Title of the node" }
|
|
1740
|
+
},
|
|
1741
|
+
required: ["title"]
|
|
1742
|
+
}
|
|
1743
|
+
},
|
|
1744
|
+
{
|
|
1745
|
+
name: "context_assign_steward",
|
|
1746
|
+
description: "Assign a data steward to govern a scope (nest, tag, folder pattern, or specific document). Stewards review and approve changes before they go live.",
|
|
1747
|
+
inputSchema: {
|
|
1748
|
+
type: "object",
|
|
1749
|
+
properties: {
|
|
1750
|
+
email: { type: "string", description: "Email of the person to assign as steward" },
|
|
1751
|
+
scope: { type: "string", description: "Scope: nest (all docs), tag, folder, or document" },
|
|
1752
|
+
target: { type: "string", description: "Scope target: tag name (e.g. #architecture), folder pattern (e.g. nodes/api-*), or document title" },
|
|
1753
|
+
role: { type: "string", description: "Role: reviewer (default), editor, or viewer" }
|
|
1754
|
+
},
|
|
1755
|
+
required: ["email", "scope"]
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
];
|
|
1759
|
+
async function handleToolCall(toolName, args, ctx) {
|
|
1760
|
+
const { storage, queryEngine, nestId, userEmail } = ctx;
|
|
1761
|
+
switch (toolName) {
|
|
1762
|
+
case "context_init": {
|
|
1763
|
+
const content = await storage.readContextMd();
|
|
1764
|
+
if (!content)
|
|
1765
|
+
return "No CONTEXT.md found. Use context_overview to see what's available.";
|
|
1766
|
+
return `# Vault Instructions (CONTEXT.md)
|
|
1767
|
+
|
|
1768
|
+
${content}`;
|
|
1769
|
+
}
|
|
1770
|
+
case "context_overview": {
|
|
1771
|
+
const docs = await storage.discoverDocuments();
|
|
1772
|
+
const types = {};
|
|
1773
|
+
const tags = {};
|
|
1774
|
+
for (const n of docs) {
|
|
1775
|
+
const t = n.frontmatter.type || "document";
|
|
1776
|
+
types[t] = (types[t] || 0) + 1;
|
|
1777
|
+
for (const tag of n.frontmatter.tags || []) tags[tag] = (tags[tag] || 0) + 1;
|
|
1778
|
+
}
|
|
1779
|
+
const typeList = Object.entries(types).sort((a, b) => b[1] - a[1]).map(([t, c]) => ` ${t}: ${c}`).join("\n");
|
|
1780
|
+
const tagList = Object.entries(tags).sort((a, b) => b[1] - a[1]).map(([t, c]) => ` ${t}: ${c}`).join("\n");
|
|
1781
|
+
const nodeList = docs.map(
|
|
1782
|
+
(n, i) => `${i + 1}. **${n.frontmatter.title}** [${n.frontmatter.type || "document"}] ${(n.frontmatter.tags || []).slice(0, 4).join(" ")}
|
|
1783
|
+
${(n.body || "").slice(0, 120).replace(/\n/g, " ")}`
|
|
1784
|
+
).join("\n\n");
|
|
1785
|
+
return `# Vault Overview
|
|
1786
|
+
**Total nodes:** ${docs.length}
|
|
1787
|
+
|
|
1788
|
+
## Types
|
|
1789
|
+
${typeList}
|
|
1790
|
+
|
|
1791
|
+
## Tags
|
|
1792
|
+
${tagList}
|
|
1793
|
+
|
|
1794
|
+
## All Nodes
|
|
1795
|
+
|
|
1796
|
+
${nodeList}`;
|
|
1797
|
+
}
|
|
1798
|
+
case "context_search": {
|
|
1799
|
+
const docs = await storage.discoverDocuments();
|
|
1800
|
+
const terms = (args.query || "").toLowerCase().split(/\s+/).filter(Boolean);
|
|
1801
|
+
const matches = docs.filter((n) => {
|
|
1802
|
+
const hay = [n.frontmatter.title, n.body || "", n.frontmatter.type || "", ...n.frontmatter.tags || []].join(" ").toLowerCase();
|
|
1803
|
+
return terms.every((t) => hay.includes(t));
|
|
1804
|
+
});
|
|
1805
|
+
if (!matches.length) return `No nodes matched search: "${args.query}"`;
|
|
1806
|
+
const results = matches.map(
|
|
1807
|
+
(n, i) => `${i + 1}. **${n.frontmatter.title}** [${n.frontmatter.type || "document"}] ${(n.frontmatter.tags || []).join(" ")}
|
|
1808
|
+
${(n.body || "").slice(0, 200).replace(/\n/g, " ")}`
|
|
1809
|
+
).join("\n\n");
|
|
1810
|
+
return `Found ${matches.length} node(s) matching "${args.query}":
|
|
1811
|
+
|
|
1812
|
+
${results}`;
|
|
1813
|
+
}
|
|
1814
|
+
case "context_query": {
|
|
1815
|
+
const result = await queryEngine.query(args.query, { hops: 2 });
|
|
1816
|
+
const nodes = result.documents;
|
|
1817
|
+
if (!nodes.length) return `No nodes matched: ${args.query}`;
|
|
1818
|
+
const list = nodes.map(
|
|
1819
|
+
(n, i) => `${i + 1}. **${n.frontmatter.title}** [${n.frontmatter.type || "document"}] ${(n.frontmatter.tags || []).join(" ")}`
|
|
1820
|
+
).join("\n\n");
|
|
1821
|
+
return `Found ${nodes.length} node(s) for \`${args.query}\`:
|
|
1822
|
+
|
|
1823
|
+
${list}`;
|
|
1824
|
+
}
|
|
1825
|
+
case "context_get": {
|
|
1826
|
+
const docs = await storage.discoverDocuments();
|
|
1827
|
+
let node;
|
|
1828
|
+
if (args.title) {
|
|
1829
|
+
node = docs.find(
|
|
1830
|
+
(n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
|
|
1831
|
+
);
|
|
1832
|
+
} else if (args.id) {
|
|
1833
|
+
node = docs.find((n) => n.id === args.id);
|
|
1834
|
+
}
|
|
1835
|
+
if (!node) return `Node not found: ${args.title || args.id}`;
|
|
1836
|
+
const meta = [
|
|
1837
|
+
`**Title:** ${node.frontmatter.title}`,
|
|
1838
|
+
`**Type:** ${node.frontmatter.type || "document"}`,
|
|
1839
|
+
node.frontmatter.tags?.length ? `**Tags:** ${node.frontmatter.tags.join(" ")}` : null
|
|
1840
|
+
].filter(Boolean).join("\n");
|
|
1841
|
+
return `${meta}
|
|
1842
|
+
|
|
1843
|
+
---
|
|
1844
|
+
|
|
1845
|
+
${node.body || "(no content)"}`;
|
|
1846
|
+
}
|
|
1847
|
+
case "context_list": {
|
|
1848
|
+
let docs = await storage.discoverDocuments();
|
|
1849
|
+
if (args.type)
|
|
1850
|
+
docs = docs.filter((n) => n.frontmatter.type === args.type);
|
|
1851
|
+
if (args.tag) {
|
|
1852
|
+
const tag = args.tag.startsWith("#") ? args.tag : `#${args.tag}`;
|
|
1853
|
+
docs = docs.filter((n) => n.frontmatter.tags?.includes(tag));
|
|
1854
|
+
}
|
|
1855
|
+
docs = docs.slice(0, args.limit || 50);
|
|
1856
|
+
if (!docs.length) return "No nodes found with the given filters.";
|
|
1857
|
+
const list = docs.map(
|
|
1858
|
+
(n, i) => `${i + 1}. **${n.frontmatter.title}** [${n.frontmatter.type || "document"}]`
|
|
1859
|
+
).join("\n");
|
|
1860
|
+
return `${docs.length} node(s):
|
|
1861
|
+
|
|
1862
|
+
${list}`;
|
|
1863
|
+
}
|
|
1864
|
+
case "context_resolve": {
|
|
1865
|
+
const result = await queryEngine.query(args.selector, { hops: 2 });
|
|
1866
|
+
const maxTokens = args.max_tokens || 8e3;
|
|
1867
|
+
const approxChars = maxTokens * 4;
|
|
1868
|
+
let total = 0;
|
|
1869
|
+
const resolved = [];
|
|
1870
|
+
for (const n of result.documents) {
|
|
1871
|
+
const entry = `## ${n.frontmatter.title}
|
|
1872
|
+
|
|
1873
|
+
${n.body || ""}`;
|
|
1874
|
+
if (total + entry.length > approxChars) break;
|
|
1875
|
+
resolved.push(entry);
|
|
1876
|
+
total += entry.length;
|
|
1877
|
+
}
|
|
1878
|
+
return resolved.join("\n\n---\n\n") || "No nodes resolved.";
|
|
1879
|
+
}
|
|
1880
|
+
case "context_create": {
|
|
1881
|
+
const slug = args.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1882
|
+
const id = `nodes/${slug}`;
|
|
1883
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1884
|
+
const tags = (args.tags || []).map(
|
|
1885
|
+
(t) => t.startsWith("#") ? t : `#${t}`
|
|
1886
|
+
);
|
|
1887
|
+
const hasStewards = isStewardshipEnabled(nestId);
|
|
1888
|
+
const initialStatus = hasStewards ? "draft" : "approved";
|
|
1889
|
+
const node = {
|
|
1890
|
+
id,
|
|
1891
|
+
filePath: "",
|
|
1892
|
+
frontmatter: {
|
|
1893
|
+
title: args.title,
|
|
1894
|
+
type: args.type || "document",
|
|
1895
|
+
tags,
|
|
1896
|
+
status: initialStatus,
|
|
1897
|
+
version: 1,
|
|
1898
|
+
created_at: now,
|
|
1899
|
+
updated_at: now,
|
|
1900
|
+
metadata: { owners: ["*"], scope: args.scope || "team" }
|
|
1901
|
+
},
|
|
1902
|
+
body: args.content,
|
|
1903
|
+
rawContent: ""
|
|
1904
|
+
};
|
|
1905
|
+
await storage.writeDocument(id, serializeDocument3(node));
|
|
1906
|
+
syncNodeTags(nestId, id, tags);
|
|
1907
|
+
createVersion({
|
|
1908
|
+
nestId,
|
|
1909
|
+
nodeId: id,
|
|
1910
|
+
version: 1,
|
|
1911
|
+
content: args.content,
|
|
1912
|
+
author: userEmail,
|
|
1913
|
+
status: initialStatus,
|
|
1914
|
+
tags
|
|
1915
|
+
});
|
|
1916
|
+
if (initialStatus === "approved") {
|
|
1917
|
+
setApprovedVersion(nestId, id, 1, userEmail);
|
|
1918
|
+
}
|
|
1919
|
+
return `Created node: **${args.title}** (${id}) \u2014 status: ${initialStatus}`;
|
|
1920
|
+
}
|
|
1921
|
+
case "context_update": {
|
|
1922
|
+
const docs = await storage.discoverDocuments();
|
|
1923
|
+
const node = docs.find(
|
|
1924
|
+
(n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
|
|
1925
|
+
);
|
|
1926
|
+
if (!node) return `Node not found: ${args.title}`;
|
|
1927
|
+
let body = node.body || "";
|
|
1928
|
+
if (args.content !== void 0) body = args.content;
|
|
1929
|
+
if (args.append) body = body + "\n\n" + args.append;
|
|
1930
|
+
let tags = node.frontmatter.tags || [];
|
|
1931
|
+
if (args.tags) {
|
|
1932
|
+
const newTags = args.tags.map(
|
|
1933
|
+
(t) => t.startsWith("#") ? t : `#${t}`
|
|
1934
|
+
);
|
|
1935
|
+
tags = [.../* @__PURE__ */ new Set([...tags, ...newTags])];
|
|
1936
|
+
}
|
|
1937
|
+
const updated = {
|
|
1938
|
+
...node,
|
|
1939
|
+
body,
|
|
1940
|
+
frontmatter: {
|
|
1941
|
+
...node.frontmatter,
|
|
1942
|
+
tags,
|
|
1943
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1944
|
+
}
|
|
1945
|
+
};
|
|
1946
|
+
await storage.writeDocument(node.id, serializeDocument3(updated));
|
|
1947
|
+
return `Updated node: **${node.frontmatter.title}**`;
|
|
1948
|
+
}
|
|
1949
|
+
// ─── Governance Tool Handlers ──────────────────────────────────────
|
|
1950
|
+
case "context_stewards": {
|
|
1951
|
+
if (args.title) {
|
|
1952
|
+
const docs = await storage.discoverDocuments();
|
|
1953
|
+
const node = docs.find(
|
|
1954
|
+
(n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
|
|
1955
|
+
);
|
|
1956
|
+
const nodeId = node?.id || "";
|
|
1957
|
+
const resolved = resolveStewardsForNode(ctx.nestId, nodeId);
|
|
1958
|
+
if (resolved.length === 0) {
|
|
1959
|
+
return `No stewards configured for "${args.title}". Changes are auto-approved.`;
|
|
1960
|
+
}
|
|
1961
|
+
const list = resolved.map(
|
|
1962
|
+
(r, i) => `${i + 1}. **${r.steward.userEmail}** \u2014 ${r.steward.role} (${r.source})${r.steward.canApprove ? " \u2713 can approve" : ""}`
|
|
1963
|
+
).join("\n");
|
|
1964
|
+
return `# Stewards for "${args.title}"
|
|
1965
|
+
|
|
1966
|
+
${list}`;
|
|
1967
|
+
}
|
|
1968
|
+
const allStewards = getStewardsForNest(ctx.nestId);
|
|
1969
|
+
if (allStewards.length === 0) {
|
|
1970
|
+
return "No stewards configured. All changes are auto-approved.\n\nTo configure stewards, add a `stewards.yaml` file to the vault root, or use `context_assign_steward`.";
|
|
1971
|
+
}
|
|
1972
|
+
const byScope = {};
|
|
1973
|
+
for (const s of allStewards) {
|
|
1974
|
+
const key = s.scope === "nest" ? "Nest-level" : s.scope === "tag" ? `Tag: ${s.tagName}` : s.scope === "folder" ? `Folder: ${s.nodePattern}` : `Document: ${s.nodePattern}`;
|
|
1975
|
+
(byScope[key] = byScope[key] || []).push(s);
|
|
1976
|
+
}
|
|
1977
|
+
const sections = Object.entries(byScope).map(
|
|
1978
|
+
([scope, stewards]) => `## ${scope}
|
|
1979
|
+
${stewards.map((s) => `- **${s.userEmail}** (${s.role})${s.canApprove ? " \u2014 can approve" : ""}`).join("\n")}`
|
|
1980
|
+
).join("\n\n");
|
|
1981
|
+
return `# Data Stewards
|
|
1982
|
+
|
|
1983
|
+
${sections}`;
|
|
1984
|
+
}
|
|
1985
|
+
case "context_review_queue": {
|
|
1986
|
+
const status = args.status || "pending";
|
|
1987
|
+
const result = getReviewQueue({
|
|
1988
|
+
nestId: ctx.nestId,
|
|
1989
|
+
status
|
|
1990
|
+
});
|
|
1991
|
+
if (result.requests.length === 0) {
|
|
1992
|
+
return status === "pending" ? "No documents pending review. All caught up!" : `No reviews with status "${status}".`;
|
|
1993
|
+
}
|
|
1994
|
+
const list = result.requests.map(
|
|
1995
|
+
(r, i) => `${i + 1}. **${r.nodeId}** v${r.version} \u2014 ${r.priority} priority
|
|
1996
|
+
Submitted by: ${r.requestedBy} at ${r.requestedAt}${r.requestNote ? `
|
|
1997
|
+
Note: "${r.requestNote}"` : ""}`
|
|
1998
|
+
).join("\n\n");
|
|
1999
|
+
return `# Review Queue (${status})
|
|
2000
|
+
**${result.total} item(s)**
|
|
2001
|
+
|
|
2002
|
+
${list}`;
|
|
2003
|
+
}
|
|
2004
|
+
case "context_submit_review": {
|
|
2005
|
+
const docs = await storage.discoverDocuments();
|
|
2006
|
+
const node = docs.find(
|
|
2007
|
+
(n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
|
|
2008
|
+
);
|
|
2009
|
+
if (!node) return `Node not found: ${args.title}`;
|
|
2010
|
+
const currentVersion = getCurrentVersion(ctx.nestId, node.id);
|
|
2011
|
+
if (currentVersion === 0) return `No versions found for "${args.title}"`;
|
|
2012
|
+
try {
|
|
2013
|
+
const request = submitForReview({
|
|
2014
|
+
nestId: ctx.nestId,
|
|
2015
|
+
nodeId: node.id,
|
|
2016
|
+
version: currentVersion,
|
|
2017
|
+
requestedBy: ctx.userEmail,
|
|
2018
|
+
note: args.note,
|
|
2019
|
+
priority: args.priority
|
|
2020
|
+
});
|
|
2021
|
+
const resolved = resolveStewardsForNode(
|
|
2022
|
+
ctx.nestId,
|
|
2023
|
+
node.id
|
|
2024
|
+
);
|
|
2025
|
+
const stewardList = resolved.length > 0 ? `
|
|
2026
|
+
|
|
2027
|
+
**Stewards who can review:**
|
|
2028
|
+
${resolved.map((r) => `- ${r.steward.userEmail} (${r.source})`).join("\n")}` : "";
|
|
2029
|
+
return `Submitted "${args.title}" v${currentVersion} for review (${request.priority} priority).${stewardList}`;
|
|
2030
|
+
} catch (err) {
|
|
2031
|
+
return `Failed to submit: ${err.message}`;
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
case "context_approve": {
|
|
2035
|
+
const docs = await storage.discoverDocuments();
|
|
2036
|
+
const node = docs.find(
|
|
2037
|
+
(n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
|
|
2038
|
+
);
|
|
2039
|
+
if (!node) return `Node not found: ${args.title}`;
|
|
2040
|
+
const currentVersion = getCurrentVersion(ctx.nestId, node.id);
|
|
2041
|
+
try {
|
|
2042
|
+
const request = approve({
|
|
2043
|
+
nestId: ctx.nestId,
|
|
2044
|
+
nodeId: node.id,
|
|
2045
|
+
version: currentVersion,
|
|
2046
|
+
approvedBy: ctx.userEmail,
|
|
2047
|
+
note: args.note
|
|
2048
|
+
});
|
|
2049
|
+
return `Approved "${args.title}" v${currentVersion}. This version is now live for AI queries.${args.note ? `
|
|
2050
|
+
Note: ${args.note}` : ""}`;
|
|
2051
|
+
} catch (err) {
|
|
2052
|
+
return `Cannot approve: ${err.message}`;
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
case "context_reject": {
|
|
2056
|
+
const docs = await storage.discoverDocuments();
|
|
2057
|
+
const node = docs.find(
|
|
2058
|
+
(n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
|
|
2059
|
+
);
|
|
2060
|
+
if (!node) return `Node not found: ${args.title}`;
|
|
2061
|
+
const currentVersion = getCurrentVersion(ctx.nestId, node.id);
|
|
2062
|
+
try {
|
|
2063
|
+
const request = reject({
|
|
2064
|
+
nestId: ctx.nestId,
|
|
2065
|
+
nodeId: node.id,
|
|
2066
|
+
version: currentVersion,
|
|
2067
|
+
rejectedBy: ctx.userEmail,
|
|
2068
|
+
note: args.note
|
|
2069
|
+
});
|
|
2070
|
+
return `Rejected "${args.title}" v${currentVersion}.
|
|
2071
|
+
Reason: ${args.note}`;
|
|
2072
|
+
} catch (err) {
|
|
2073
|
+
return `Cannot reject: ${err.message}`;
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
case "context_versions": {
|
|
2077
|
+
const docs = await storage.discoverDocuments();
|
|
2078
|
+
const node = docs.find(
|
|
2079
|
+
(n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
|
|
2080
|
+
);
|
|
2081
|
+
if (!node) return `Node not found: ${args.title}`;
|
|
2082
|
+
const allVersions = getVersions(ctx.nestId, node.id);
|
|
2083
|
+
const approved = getApprovedVersion(ctx.nestId, node.id);
|
|
2084
|
+
if (allVersions.length === 0) {
|
|
2085
|
+
return `No version history for "${args.title}".`;
|
|
2086
|
+
}
|
|
2087
|
+
const list = allVersions.map(
|
|
2088
|
+
(v) => `- **v${v.version}** ${v.status}${v.version === approved ? " (AI-active)" : ""} \u2014 by ${v.editedBy} at ${v.editedAt}${v.changeNote ? ` "${v.changeNote}"` : ""}`
|
|
2089
|
+
).join("\n");
|
|
2090
|
+
return `# Version History: ${args.title}
|
|
2091
|
+
**Current:** v${allVersions[0]?.version || 0} | **AI-active:** ${approved ? `v${approved}` : "none"}
|
|
2092
|
+
|
|
2093
|
+
${list}`;
|
|
2094
|
+
}
|
|
2095
|
+
case "context_assign_steward": {
|
|
2096
|
+
const scope = args.scope;
|
|
2097
|
+
if (!["nest", "tag", "folder", "document"].includes(scope)) {
|
|
2098
|
+
return `Invalid scope "${args.scope}". Use: nest, tag, folder, or document.`;
|
|
2099
|
+
}
|
|
2100
|
+
try {
|
|
2101
|
+
assignSteward({
|
|
2102
|
+
nestId: ctx.nestId,
|
|
2103
|
+
scope,
|
|
2104
|
+
nodePattern: scope === "folder" || scope === "document" ? args.target : void 0,
|
|
2105
|
+
tagName: scope === "tag" ? args.target : void 0,
|
|
2106
|
+
userEmail: args.email,
|
|
2107
|
+
role: args.role || "reviewer",
|
|
2108
|
+
canApprove: true,
|
|
2109
|
+
canReject: true,
|
|
2110
|
+
assignedBy: ctx.userEmail,
|
|
2111
|
+
assignedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2112
|
+
isActive: true
|
|
2113
|
+
});
|
|
2114
|
+
const targetDesc = scope === "nest" ? "all documents" : `${scope}: ${args.target}`;
|
|
2115
|
+
return `Assigned **${args.email}** as ${args.role || "reviewer"} for ${targetDesc}.`;
|
|
2116
|
+
} catch (err) {
|
|
2117
|
+
return `Failed to assign steward: ${err.message}`;
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
default:
|
|
2121
|
+
return `Unknown tool: ${toolName}`;
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
// src/mcp/routes.ts
|
|
2126
|
+
import { z } from "zod";
|
|
2127
|
+
var mcpRoutes = new Hono6();
|
|
2128
|
+
function getUserEmail2(userId) {
|
|
2129
|
+
const db = getDb();
|
|
2130
|
+
const user = db.prepare("SELECT email FROM users WHERE id = ?").get(userId);
|
|
2131
|
+
return user?.email || "anonymous@localhost";
|
|
2132
|
+
}
|
|
2133
|
+
function createMcpServerForNest(nestId, userEmail) {
|
|
2134
|
+
const server = new McpServer(
|
|
2135
|
+
{ name: `contextnest-${nestId}`, version: "1.0.0" },
|
|
2136
|
+
{ capabilities: { tools: {} } }
|
|
2137
|
+
);
|
|
2138
|
+
const engine = engineCache.get(nestId);
|
|
2139
|
+
for (const tool of TOOL_DEFINITIONS) {
|
|
2140
|
+
const props = tool.inputSchema.properties || {};
|
|
2141
|
+
const required = tool.inputSchema.required || [];
|
|
2142
|
+
const shape = {};
|
|
2143
|
+
for (const [key, def] of Object.entries(props)) {
|
|
2144
|
+
let field;
|
|
2145
|
+
if (def.type === "string") field = z.string();
|
|
2146
|
+
else if (def.type === "number") field = z.number();
|
|
2147
|
+
else if (def.type === "array") field = z.array(z.string());
|
|
2148
|
+
else field = z.any();
|
|
2149
|
+
if (!required.includes(key)) field = field.optional();
|
|
2150
|
+
shape[key] = field;
|
|
2151
|
+
}
|
|
2152
|
+
server.tool(tool.name, tool.description, shape, async (args) => {
|
|
2153
|
+
const text = await handleToolCall(tool.name, args, {
|
|
2154
|
+
storage: engine.storage,
|
|
2155
|
+
queryEngine: engine.query,
|
|
2156
|
+
nestId,
|
|
2157
|
+
userEmail
|
|
2158
|
+
});
|
|
2159
|
+
return { content: [{ type: "text", text }] };
|
|
2160
|
+
});
|
|
2161
|
+
}
|
|
2162
|
+
return server;
|
|
2163
|
+
}
|
|
2164
|
+
mcpRoutes.all("/", async (c) => {
|
|
2165
|
+
const nestId = c.req.param("nestId");
|
|
2166
|
+
const userId = c.get("userId");
|
|
2167
|
+
const userEmail = getUserEmail2(userId);
|
|
2168
|
+
const server = createMcpServerForNest(nestId, userEmail);
|
|
2169
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
2170
|
+
sessionIdGenerator: void 0,
|
|
2171
|
+
enableJsonResponse: true
|
|
2172
|
+
});
|
|
2173
|
+
await server.server.connect(transport);
|
|
2174
|
+
try {
|
|
2175
|
+
const response = await transport.handleRequest(c.req.raw);
|
|
2176
|
+
return response;
|
|
2177
|
+
} finally {
|
|
2178
|
+
await transport.close();
|
|
2179
|
+
await server.server.close();
|
|
2180
|
+
}
|
|
2181
|
+
});
|
|
2182
|
+
|
|
2183
|
+
// src/governance/routes.ts
|
|
2184
|
+
import { Hono as Hono7 } from "hono";
|
|
2185
|
+
|
|
2186
|
+
// src/governance/stewards-parser.ts
|
|
2187
|
+
import { readFileSync, existsSync } from "fs";
|
|
2188
|
+
import { join as join3 } from "path";
|
|
2189
|
+
function parseStewardsYaml(content) {
|
|
2190
|
+
const result = { version: 1 };
|
|
2191
|
+
const lines = content.split("\n");
|
|
2192
|
+
let currentSection = null;
|
|
2193
|
+
let currentTarget = null;
|
|
2194
|
+
let currentEntries = [];
|
|
2195
|
+
const flushTarget = () => {
|
|
2196
|
+
if (!currentSection || currentEntries.length === 0) return;
|
|
2197
|
+
if (currentSection === "nest") {
|
|
2198
|
+
result.nest = [...result.nest || [], ...currentEntries];
|
|
2199
|
+
} else if (currentSection === "folders" && currentTarget) {
|
|
2200
|
+
result.folders = result.folders || {};
|
|
2201
|
+
result.folders[currentTarget] = currentEntries;
|
|
2202
|
+
} else if (currentSection === "tags" && currentTarget) {
|
|
2203
|
+
result.tags = result.tags || {};
|
|
2204
|
+
result.tags[currentTarget] = currentEntries;
|
|
2205
|
+
} else if (currentSection === "documents" && currentTarget) {
|
|
2206
|
+
result.documents = result.documents || {};
|
|
2207
|
+
result.documents[currentTarget] = currentEntries;
|
|
2208
|
+
}
|
|
2209
|
+
currentEntries = [];
|
|
2210
|
+
};
|
|
2211
|
+
for (const rawLine of lines) {
|
|
2212
|
+
const line = rawLine.trimEnd();
|
|
2213
|
+
if (!line || line.startsWith("#")) continue;
|
|
2214
|
+
if (!line.startsWith(" ") && !line.startsWith(" ") && line.endsWith(":")) {
|
|
2215
|
+
flushTarget();
|
|
2216
|
+
currentTarget = null;
|
|
2217
|
+
const key = line.slice(0, -1).trim();
|
|
2218
|
+
if (key === "version") continue;
|
|
2219
|
+
if (key === "nest" || key === "data_room") {
|
|
2220
|
+
currentSection = "nest";
|
|
2221
|
+
} else if (key === "folders") {
|
|
2222
|
+
currentSection = "folders";
|
|
2223
|
+
} else if (key === "tags") {
|
|
2224
|
+
currentSection = "tags";
|
|
2225
|
+
} else if (key === "documents") {
|
|
2226
|
+
currentSection = "documents";
|
|
2227
|
+
}
|
|
2228
|
+
continue;
|
|
2229
|
+
}
|
|
2230
|
+
const subMatch = line.match(/^(?: |\t)([^\s].*):$/);
|
|
2231
|
+
if (subMatch && currentSection && currentSection !== "nest") {
|
|
2232
|
+
flushTarget();
|
|
2233
|
+
currentTarget = subMatch[1].trim().replace(/^["']|["']$/g, "");
|
|
2234
|
+
continue;
|
|
2235
|
+
}
|
|
2236
|
+
const entryMatch = line.match(/^\s+-\s+(.*)/);
|
|
2237
|
+
if (entryMatch) {
|
|
2238
|
+
const entryStr = entryMatch[1].trim();
|
|
2239
|
+
const entry = parseEntry(entryStr);
|
|
2240
|
+
if (entry) currentEntries.push(entry);
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
flushTarget();
|
|
2244
|
+
return result;
|
|
2245
|
+
}
|
|
2246
|
+
function parseEntry(str) {
|
|
2247
|
+
const emailMatch = str.match(/email:\s*["']?([^\s,"'{}]+)["']?/);
|
|
2248
|
+
if (!emailMatch) return null;
|
|
2249
|
+
const entry = { email: emailMatch[1] };
|
|
2250
|
+
const roleMatch = str.match(/role:\s*["']?(\w+)["']?/);
|
|
2251
|
+
if (roleMatch) entry.role = roleMatch[1];
|
|
2252
|
+
if (str.includes("can_approve:")) {
|
|
2253
|
+
entry.can_approve = str.includes("can_approve: true");
|
|
2254
|
+
}
|
|
2255
|
+
if (str.includes("can_reject:")) {
|
|
2256
|
+
entry.can_reject = str.includes("can_reject: true");
|
|
2257
|
+
}
|
|
2258
|
+
return entry;
|
|
2259
|
+
}
|
|
2260
|
+
function loadStewardsConfig(nestId) {
|
|
2261
|
+
const dataRoot = config.DATA_ROOT;
|
|
2262
|
+
const nestPath2 = join3(dataRoot, "nests", nestId);
|
|
2263
|
+
const candidates = [
|
|
2264
|
+
join3(nestPath2, "stewards.yaml"),
|
|
2265
|
+
join3(nestPath2, "stewards.yml"),
|
|
2266
|
+
join3(nestPath2, ".context", "stewards.yaml")
|
|
2267
|
+
];
|
|
2268
|
+
for (const candidatePath of candidates) {
|
|
2269
|
+
if (existsSync(candidatePath)) {
|
|
2270
|
+
const content = readFileSync(candidatePath, "utf-8");
|
|
2271
|
+
return parseStewardsYaml(content);
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
return null;
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
// src/governance/routes.ts
|
|
2278
|
+
var governanceRoutes = new Hono7();
|
|
2279
|
+
governanceRoutes.get("/stewards", async (c) => {
|
|
2280
|
+
const nestId = c.req.param("nestId");
|
|
2281
|
+
const scope = c.req.query("scope");
|
|
2282
|
+
const search = c.req.query("search");
|
|
2283
|
+
const stewards = listStewards({
|
|
2284
|
+
nestId,
|
|
2285
|
+
scope: scope || void 0,
|
|
2286
|
+
search: search || void 0
|
|
2287
|
+
});
|
|
2288
|
+
return c.json({ stewards });
|
|
2289
|
+
});
|
|
2290
|
+
governanceRoutes.post("/stewards", async (c) => {
|
|
2291
|
+
const nestId = c.req.param("nestId");
|
|
2292
|
+
const body = await c.req.json();
|
|
2293
|
+
const assignedBy = getUserEmail3(c);
|
|
2294
|
+
if (!body.scope) throw new ValidationError("scope is required");
|
|
2295
|
+
if (Array.isArray(body.users)) {
|
|
2296
|
+
const created2 = createStewardRecord({
|
|
2297
|
+
nestId,
|
|
2298
|
+
scope: body.scope,
|
|
2299
|
+
documentId: body.documentId,
|
|
2300
|
+
folderPath: body.folderPath,
|
|
2301
|
+
tagName: body.tagName,
|
|
2302
|
+
users: body.users,
|
|
2303
|
+
assignedBy
|
|
2304
|
+
});
|
|
2305
|
+
return c.json({ stewards: created2 }, 201);
|
|
2306
|
+
}
|
|
2307
|
+
if (!body.email) {
|
|
2308
|
+
throw new ValidationError("users[] or email is required");
|
|
2309
|
+
}
|
|
2310
|
+
const created = createStewardRecord({
|
|
2311
|
+
nestId,
|
|
2312
|
+
scope: body.scope,
|
|
2313
|
+
documentId: body.scope === "document" ? body.nodePattern : void 0,
|
|
2314
|
+
folderPath: body.scope === "folder" ? body.nodePattern : void 0,
|
|
2315
|
+
tagName: body.scope === "tag" ? body.tagName : void 0,
|
|
2316
|
+
users: [
|
|
2317
|
+
{
|
|
2318
|
+
email: body.email,
|
|
2319
|
+
role: body.role,
|
|
2320
|
+
canApprove: body.canApprove,
|
|
2321
|
+
canReject: body.canReject
|
|
2322
|
+
}
|
|
2323
|
+
],
|
|
2324
|
+
assignedBy
|
|
2325
|
+
});
|
|
2326
|
+
return c.json({ steward: created[0] }, 201);
|
|
2327
|
+
});
|
|
2328
|
+
governanceRoutes.delete("/stewards/:stewardId", async (c) => {
|
|
2329
|
+
const stewardId = c.req.param("stewardId");
|
|
2330
|
+
removeSteward(stewardId);
|
|
2331
|
+
return c.json({ removed: true });
|
|
2332
|
+
});
|
|
2333
|
+
governanceRoutes.post("/stewards/sync", async (c) => {
|
|
2334
|
+
const nestId = c.req.param("nestId");
|
|
2335
|
+
const stewardsConfig = loadStewardsConfig(nestId);
|
|
2336
|
+
if (!stewardsConfig) {
|
|
2337
|
+
return c.json({ synced: 0, message: "No stewards.yaml found" });
|
|
2338
|
+
}
|
|
2339
|
+
const count = syncFromConfig(nestId, stewardsConfig);
|
|
2340
|
+
return c.json({ synced: count });
|
|
2341
|
+
});
|
|
2342
|
+
governanceRoutes.get("/review-queue", async (c) => {
|
|
2343
|
+
const nestId = c.req.param("nestId");
|
|
2344
|
+
const status = c.req.query("status") || "pending";
|
|
2345
|
+
const limit = parseInt(c.req.query("limit") || "50", 10);
|
|
2346
|
+
const offset = parseInt(c.req.query("offset") || "0", 10);
|
|
2347
|
+
const result = getReviewQueue({
|
|
2348
|
+
nestId,
|
|
2349
|
+
status,
|
|
2350
|
+
limit,
|
|
2351
|
+
offset
|
|
2352
|
+
});
|
|
2353
|
+
return c.json(result);
|
|
2354
|
+
});
|
|
2355
|
+
var governanceNodeRoutes = new Hono7();
|
|
2356
|
+
governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
|
|
2357
|
+
const nestId = c.req.param("nestId");
|
|
2358
|
+
const nodeId = c.req.param("nodeId");
|
|
2359
|
+
const { stewards: resolved, fallbackToOwner, ownerEmail } = resolveStewardsWithFallback(nestId, nodeId);
|
|
2360
|
+
return c.json({
|
|
2361
|
+
nodeId,
|
|
2362
|
+
stewards: resolved.map((r) => ({
|
|
2363
|
+
email: r.steward.userEmail,
|
|
2364
|
+
role: r.steward.role,
|
|
2365
|
+
scope: r.steward.scope,
|
|
2366
|
+
source: r.source,
|
|
2367
|
+
priority: r.priority,
|
|
2368
|
+
canApprove: r.steward.canApprove
|
|
2369
|
+
})),
|
|
2370
|
+
fallbackToOwner,
|
|
2371
|
+
ownerEmail
|
|
2372
|
+
});
|
|
2373
|
+
});
|
|
2374
|
+
governanceNodeRoutes.get("/:nodeId{.+}/versions", async (c) => {
|
|
2375
|
+
const nestId = c.req.param("nestId");
|
|
2376
|
+
const nodeId = c.req.param("nodeId");
|
|
2377
|
+
const allVersions = getVersions(nestId, nodeId);
|
|
2378
|
+
const approved = getApprovedVersion(nestId, nodeId);
|
|
2379
|
+
return c.json({
|
|
2380
|
+
versions: allVersions,
|
|
2381
|
+
approvedVersion: approved,
|
|
2382
|
+
currentVersion: allVersions[0]?.version || 0
|
|
2383
|
+
});
|
|
2384
|
+
});
|
|
2385
|
+
governanceNodeRoutes.get("/:nodeId{.+}/reviews", async (c) => {
|
|
2386
|
+
const nestId = c.req.param("nestId");
|
|
2387
|
+
const nodeId = c.req.param("nodeId");
|
|
2388
|
+
const history = getReviewHistory(nestId, nodeId);
|
|
2389
|
+
return c.json({ reviews: history });
|
|
2390
|
+
});
|
|
2391
|
+
governanceNodeRoutes.post("/:nodeId{.+}/submit-review", async (c) => {
|
|
2392
|
+
const nestId = c.req.param("nestId");
|
|
2393
|
+
const nodeId = c.req.param("nodeId");
|
|
2394
|
+
const body = await c.req.json();
|
|
2395
|
+
const currentVersion = getCurrentVersion(nestId, nodeId);
|
|
2396
|
+
if (currentVersion === 0) {
|
|
2397
|
+
throw new ValidationError("Node has no versions to review");
|
|
2398
|
+
}
|
|
2399
|
+
const userEmail = getUserEmail3(c);
|
|
2400
|
+
const request = submitForReview({
|
|
2401
|
+
nestId,
|
|
2402
|
+
nodeId,
|
|
2403
|
+
version: currentVersion,
|
|
2404
|
+
requestedBy: userEmail,
|
|
2405
|
+
note: body.note,
|
|
2406
|
+
priority: body.priority
|
|
2407
|
+
});
|
|
2408
|
+
const resolved = resolveStewardsForNode(nestId, nodeId);
|
|
2409
|
+
return c.json(
|
|
2410
|
+
{
|
|
2411
|
+
review: request,
|
|
2412
|
+
stewards: resolved.map((r) => ({
|
|
2413
|
+
email: r.steward.userEmail,
|
|
2414
|
+
role: r.steward.role,
|
|
2415
|
+
source: r.source
|
|
2416
|
+
}))
|
|
2417
|
+
},
|
|
2418
|
+
201
|
|
2419
|
+
);
|
|
2420
|
+
});
|
|
2421
|
+
governanceNodeRoutes.post("/:nodeId{.+}/approve", async (c) => {
|
|
2422
|
+
const nestId = c.req.param("nestId");
|
|
2423
|
+
const nodeId = c.req.param("nodeId");
|
|
2424
|
+
const body = await c.req.json();
|
|
2425
|
+
const userEmail = getUserEmail3(c);
|
|
2426
|
+
const isAdmin = isSuperAdmin(userEmail);
|
|
2427
|
+
try {
|
|
2428
|
+
const request = approve({
|
|
2429
|
+
nestId,
|
|
2430
|
+
nodeId,
|
|
2431
|
+
version: getCurrentVersion(nestId, nodeId),
|
|
2432
|
+
approvedBy: userEmail,
|
|
2433
|
+
note: body.note,
|
|
2434
|
+
override: body.override && isAdmin
|
|
2435
|
+
});
|
|
2436
|
+
return c.json({ review: request });
|
|
2437
|
+
} catch (err) {
|
|
2438
|
+
return c.json({ error: err.message }, 403);
|
|
2439
|
+
}
|
|
2440
|
+
});
|
|
2441
|
+
governanceNodeRoutes.post("/:nodeId{.+}/reject", async (c) => {
|
|
2442
|
+
const nestId = c.req.param("nestId");
|
|
2443
|
+
const nodeId = c.req.param("nodeId");
|
|
2444
|
+
const body = await c.req.json();
|
|
2445
|
+
if (!body.note) {
|
|
2446
|
+
throw new ValidationError("Rejection note is required");
|
|
2447
|
+
}
|
|
2448
|
+
const userEmail = getUserEmail3(c);
|
|
2449
|
+
try {
|
|
2450
|
+
const request = reject({
|
|
2451
|
+
nestId,
|
|
2452
|
+
nodeId,
|
|
2453
|
+
version: getCurrentVersion(nestId, nodeId),
|
|
2454
|
+
rejectedBy: userEmail,
|
|
2455
|
+
note: body.note
|
|
2456
|
+
});
|
|
2457
|
+
return c.json({ review: request });
|
|
2458
|
+
} catch (err) {
|
|
2459
|
+
return c.json({ error: err.message }, 403);
|
|
2460
|
+
}
|
|
2461
|
+
});
|
|
2462
|
+
governanceNodeRoutes.get("/:nodeId{.+}/can-access", async (c) => {
|
|
2463
|
+
const nestId = c.req.param("nestId");
|
|
2464
|
+
const nodeId = c.req.param("nodeId");
|
|
2465
|
+
const userEmail = getUserEmail3(c);
|
|
2466
|
+
return c.json(canUserAccess(nestId, nodeId, userEmail));
|
|
2467
|
+
});
|
|
2468
|
+
governanceNodeRoutes.get("/:nodeId{.+}/can-approve", async (c) => {
|
|
2469
|
+
const nestId = c.req.param("nestId");
|
|
2470
|
+
const nodeId = c.req.param("nodeId");
|
|
2471
|
+
const userEmail = getUserEmail3(c);
|
|
2472
|
+
return c.json(canUserApprove(nestId, nodeId, userEmail));
|
|
2473
|
+
});
|
|
2474
|
+
governanceNodeRoutes.get("/:nodeId{.+}/can-edit", async (c) => {
|
|
2475
|
+
const nestId = c.req.param("nestId");
|
|
2476
|
+
const nodeId = c.req.param("nodeId");
|
|
2477
|
+
const userEmail = getUserEmail3(c);
|
|
2478
|
+
return c.json(canUserEdit(nestId, nodeId, userEmail));
|
|
2479
|
+
});
|
|
2480
|
+
governanceNodeRoutes.post("/:nodeId{.+}/cancel-review", async (c) => {
|
|
2481
|
+
const nestId = c.req.param("nestId");
|
|
2482
|
+
const nodeId = c.req.param("nodeId");
|
|
2483
|
+
const userEmail = getUserEmail3(c);
|
|
2484
|
+
const request = cancelReview({
|
|
2485
|
+
nestId,
|
|
2486
|
+
nodeId,
|
|
2487
|
+
cancelledBy: userEmail
|
|
2488
|
+
});
|
|
2489
|
+
return c.json({ review: request });
|
|
2490
|
+
});
|
|
2491
|
+
function getUserEmail3(c) {
|
|
2492
|
+
const userId = c.get("userId");
|
|
2493
|
+
const db = getDb();
|
|
2494
|
+
const user = db.prepare("SELECT email FROM users WHERE id = ?").get(userId);
|
|
2495
|
+
return user?.email || "anonymous@localhost";
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
// src/auth/anonymous.ts
|
|
2499
|
+
import bcrypt from "bcryptjs";
|
|
2500
|
+
var ANON_USER_ID3 = "00000000-0000-0000-0000-000000000000";
|
|
2501
|
+
var ANON_EMAIL = "admin@localhost";
|
|
2502
|
+
function ensureAnonymousUser() {
|
|
2503
|
+
const db = getDb();
|
|
2504
|
+
const exists = db.prepare("SELECT id FROM users WHERE id = ?").get(ANON_USER_ID3);
|
|
2505
|
+
if (!exists) {
|
|
2506
|
+
const placeholder = bcrypt.hashSync("anon-no-login", 4);
|
|
2507
|
+
db.prepare(
|
|
2508
|
+
"INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)"
|
|
2509
|
+
).run(ANON_USER_ID3, ANON_EMAIL, "Admin", placeholder);
|
|
2510
|
+
}
|
|
2511
|
+
return ANON_USER_ID3;
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
// src/auth/license.ts
|
|
2515
|
+
var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
2516
|
+
var suspensionFirstSeen = null;
|
|
2517
|
+
var suspensionConfirmed = false;
|
|
2518
|
+
var suspensionReason = null;
|
|
2519
|
+
var SUSPENSION_CONFIRM_WINDOW_MS = 60 * 60 * 1e3;
|
|
2520
|
+
function isSuspended() {
|
|
2521
|
+
return suspensionConfirmed;
|
|
2522
|
+
}
|
|
2523
|
+
function getSuspensionReason() {
|
|
2524
|
+
return suspensionReason;
|
|
2525
|
+
}
|
|
2526
|
+
async function validateLicense() {
|
|
2527
|
+
const key = config.PROMPTOWL_KEY;
|
|
2528
|
+
if (!key) {
|
|
2529
|
+
return {
|
|
2530
|
+
valid: false,
|
|
2531
|
+
tier: "none",
|
|
2532
|
+
org: null,
|
|
2533
|
+
limits: null,
|
|
2534
|
+
suspended: false,
|
|
2535
|
+
suspendedReason: null
|
|
2536
|
+
};
|
|
2537
|
+
}
|
|
2538
|
+
const db = getDb();
|
|
2539
|
+
const cached = db.prepare("SELECT * FROM license_cache WHERE key = ?").get(key);
|
|
2540
|
+
if (cached) {
|
|
2541
|
+
const age = Date.now() - (/* @__PURE__ */ new Date(cached.validated_at + "Z")).getTime();
|
|
2542
|
+
if (age < CACHE_TTL_MS) {
|
|
2543
|
+
return {
|
|
2544
|
+
valid: true,
|
|
2545
|
+
tier: cached.tier,
|
|
2546
|
+
org: cached.org,
|
|
2547
|
+
limits: cached.limits_json ? JSON.parse(cached.limits_json) : null,
|
|
2548
|
+
suspended: suspensionConfirmed,
|
|
2549
|
+
suspendedReason: suspensionReason
|
|
2550
|
+
};
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
try {
|
|
2554
|
+
const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
|
|
2555
|
+
const res = await fetch(`${promptowlUrl}/api/license/validate`, {
|
|
2556
|
+
method: "POST",
|
|
2557
|
+
headers: { "Content-Type": "application/json" },
|
|
2558
|
+
body: JSON.stringify({ key })
|
|
2559
|
+
});
|
|
2560
|
+
if (!res.ok) {
|
|
2561
|
+
if (cached) {
|
|
2562
|
+
console.warn(
|
|
2563
|
+
" PromptOwl unreachable, using cached license (grace period)"
|
|
2564
|
+
);
|
|
2565
|
+
return {
|
|
2566
|
+
valid: true,
|
|
2567
|
+
tier: cached.tier,
|
|
2568
|
+
org: cached.org,
|
|
2569
|
+
limits: cached.limits_json ? JSON.parse(cached.limits_json) : null,
|
|
2570
|
+
suspended: suspensionConfirmed,
|
|
2571
|
+
suspendedReason: suspensionReason
|
|
2572
|
+
};
|
|
2573
|
+
}
|
|
2574
|
+
return {
|
|
2575
|
+
valid: false,
|
|
2576
|
+
tier: "none",
|
|
2577
|
+
org: null,
|
|
2578
|
+
limits: null,
|
|
2579
|
+
suspended: false,
|
|
2580
|
+
suspendedReason: null
|
|
2581
|
+
};
|
|
2582
|
+
}
|
|
2583
|
+
const data = await res.json();
|
|
2584
|
+
if (data.suspended === true) {
|
|
2585
|
+
if (!suspensionFirstSeen) {
|
|
2586
|
+
suspensionFirstSeen = Date.now();
|
|
2587
|
+
suspensionReason = data.suspended_reason || "Suspended by PromptOwl";
|
|
2588
|
+
console.warn(
|
|
2589
|
+
`
|
|
2590
|
+
WARNING: PromptOwl has flagged this server for suspension.`
|
|
2591
|
+
);
|
|
2592
|
+
console.warn(
|
|
2593
|
+
` Reason: ${suspensionReason}`
|
|
2594
|
+
);
|
|
2595
|
+
console.warn(
|
|
2596
|
+
` This will be confirmed in ~1 hour. If this is an error,`
|
|
2597
|
+
);
|
|
2598
|
+
console.warn(
|
|
2599
|
+
` contact support@promptowl.ai to reverse it.
|
|
2600
|
+
`
|
|
2601
|
+
);
|
|
2602
|
+
} else if (Date.now() - suspensionFirstSeen >= SUSPENSION_CONFIRM_WINDOW_MS) {
|
|
2603
|
+
suspensionConfirmed = true;
|
|
2604
|
+
suspensionReason = data.suspended_reason || "Suspended by PromptOwl";
|
|
2605
|
+
console.error(
|
|
2606
|
+
`
|
|
2607
|
+
SERVER SUSPENDED: ${suspensionReason}`
|
|
2608
|
+
);
|
|
2609
|
+
console.error(
|
|
2610
|
+
` Write operations are disabled. Reads still work.`
|
|
2611
|
+
);
|
|
2612
|
+
console.error(
|
|
2613
|
+
` Contact support@promptowl.ai to resolve.
|
|
2614
|
+
`
|
|
2615
|
+
);
|
|
2616
|
+
}
|
|
2617
|
+
} else {
|
|
2618
|
+
if (suspensionFirstSeen) {
|
|
2619
|
+
console.log(" Suspension flag cleared by PromptOwl.");
|
|
2620
|
+
}
|
|
2621
|
+
suspensionFirstSeen = null;
|
|
2622
|
+
suspensionConfirmed = false;
|
|
2623
|
+
suspensionReason = null;
|
|
2624
|
+
}
|
|
2625
|
+
if (!data.valid && !data.suspended) {
|
|
2626
|
+
return {
|
|
2627
|
+
valid: false,
|
|
2628
|
+
tier: "none",
|
|
2629
|
+
org: null,
|
|
2630
|
+
limits: null,
|
|
2631
|
+
suspended: false,
|
|
2632
|
+
suspendedReason: null
|
|
2633
|
+
};
|
|
2634
|
+
}
|
|
2635
|
+
if (data.valid) {
|
|
2636
|
+
const limitsJson = data.limits ? JSON.stringify(data.limits) : null;
|
|
2637
|
+
db.prepare(
|
|
2638
|
+
`INSERT OR REPLACE INTO license_cache (key, tier, org, limits_json, validated_at)
|
|
2639
|
+
VALUES (?, ?, ?, ?, datetime('now'))`
|
|
2640
|
+
).run(key, data.tier || "community", data.org || null, limitsJson);
|
|
2641
|
+
}
|
|
2642
|
+
return {
|
|
2643
|
+
valid: data.valid !== false,
|
|
2644
|
+
tier: data.tier || "community",
|
|
2645
|
+
org: data.org || null,
|
|
2646
|
+
limits: data.limits || null,
|
|
2647
|
+
suspended: suspensionConfirmed,
|
|
2648
|
+
suspendedReason: suspensionReason
|
|
2649
|
+
};
|
|
2650
|
+
} catch (err) {
|
|
2651
|
+
if (cached) {
|
|
2652
|
+
console.warn(
|
|
2653
|
+
` PromptOwl validation failed (${err.message}), using cached license`
|
|
2654
|
+
);
|
|
2655
|
+
return {
|
|
2656
|
+
valid: true,
|
|
2657
|
+
tier: cached.tier,
|
|
2658
|
+
org: cached.org,
|
|
2659
|
+
limits: cached.limits_json ? JSON.parse(cached.limits_json) : null,
|
|
2660
|
+
suspended: suspensionConfirmed,
|
|
2661
|
+
suspendedReason: suspensionReason
|
|
2662
|
+
};
|
|
2663
|
+
}
|
|
2664
|
+
return {
|
|
2665
|
+
valid: false,
|
|
2666
|
+
tier: "none",
|
|
2667
|
+
org: null,
|
|
2668
|
+
limits: null,
|
|
2669
|
+
suspended: false,
|
|
2670
|
+
suspendedReason: null
|
|
2671
|
+
};
|
|
2672
|
+
}
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
// src/app.ts
|
|
2676
|
+
import { serveStatic } from "@hono/node-server/serve-static";
|
|
2677
|
+
var openModeMiddleware = createMiddleware2(async (c, next) => {
|
|
2678
|
+
const anonId = ensureAnonymousUser();
|
|
2679
|
+
c.set("userId", anonId);
|
|
2680
|
+
c.set("nestScope", null);
|
|
2681
|
+
await next();
|
|
2682
|
+
});
|
|
2683
|
+
var flexAuthMiddleware = createMiddleware2(async (c, next) => {
|
|
2684
|
+
const header = c.req.header("Authorization");
|
|
2685
|
+
if (header?.startsWith("Bearer cnst_")) {
|
|
2686
|
+
return authMiddleware(c, next);
|
|
2687
|
+
}
|
|
2688
|
+
if (config.AUTH_MODE === "open") {
|
|
2689
|
+
const anonId = ensureAnonymousUser();
|
|
2690
|
+
c.set("userId", anonId);
|
|
2691
|
+
c.set("nestScope", null);
|
|
2692
|
+
return next();
|
|
2693
|
+
}
|
|
2694
|
+
return c.json({ error: "Missing or invalid API key" }, 401);
|
|
2695
|
+
});
|
|
2696
|
+
function createApp() {
|
|
2697
|
+
const app = new Hono8();
|
|
2698
|
+
app.use("*", logger());
|
|
2699
|
+
const corsOrigins = config.CORS_ORIGINS;
|
|
2700
|
+
app.use(
|
|
2701
|
+
"*",
|
|
2702
|
+
corsOrigins === "*" ? cors({ origin: "*" }) : cors({ origin: corsOrigins, credentials: true })
|
|
2703
|
+
);
|
|
2704
|
+
app.use("*", async (c, next) => {
|
|
2705
|
+
const lenStr = c.req.header("Content-Length");
|
|
2706
|
+
if (lenStr) {
|
|
2707
|
+
const len = parseInt(lenStr, 10);
|
|
2708
|
+
if (Number.isFinite(len) && len > config.MAX_BODY_BYTES) {
|
|
2709
|
+
return c.json(
|
|
2710
|
+
{
|
|
2711
|
+
error: `Request body too large (${len} bytes, max ${config.MAX_BODY_BYTES})`
|
|
2712
|
+
},
|
|
2713
|
+
413
|
|
2714
|
+
);
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
return next();
|
|
2718
|
+
});
|
|
2719
|
+
app.get(
|
|
2720
|
+
"/health",
|
|
2721
|
+
(c) => c.json({
|
|
2722
|
+
status: isSuspended() ? "suspended" : "ok",
|
|
2723
|
+
service: "contextnest-community",
|
|
2724
|
+
version: "0.1.0",
|
|
2725
|
+
auth_mode: config.AUTH_MODE,
|
|
2726
|
+
...isSuspended() && { suspended_reason: getSuspensionReason() }
|
|
2727
|
+
})
|
|
2728
|
+
);
|
|
2729
|
+
app.route("/auth", authRoutes);
|
|
2730
|
+
const nestsApp = new Hono8();
|
|
2731
|
+
nestsApp.use("*", flexAuthMiddleware);
|
|
2732
|
+
nestsApp.use("*", async (c, next) => {
|
|
2733
|
+
const localPath = c.req.path.replace(/^\/nests\//, "");
|
|
2734
|
+
const parts = localPath.split("/").filter(Boolean);
|
|
2735
|
+
if (parts.length < 2) return next();
|
|
2736
|
+
const nestId = parts[0];
|
|
2737
|
+
const userId = c.get("userId");
|
|
2738
|
+
const nestScope = c.get("nestScope");
|
|
2739
|
+
if (nestScope && nestScope !== nestId) {
|
|
2740
|
+
return c.json({ error: "API key not authorized for this nest" }, 403);
|
|
2741
|
+
}
|
|
2742
|
+
if (isSuspended() && c.req.method !== "GET") {
|
|
2743
|
+
return c.json(
|
|
2744
|
+
{
|
|
2745
|
+
error: "Server suspended by PromptOwl",
|
|
2746
|
+
reason: getSuspensionReason(),
|
|
2747
|
+
contact: "support@promptowl.ai"
|
|
2748
|
+
},
|
|
2749
|
+
503
|
|
2750
|
+
);
|
|
2751
|
+
}
|
|
2752
|
+
if (config.AUTH_MODE === "open") {
|
|
2753
|
+
c.set("nestPermission", "owner");
|
|
2754
|
+
return next();
|
|
2755
|
+
}
|
|
2756
|
+
const permission = resolveNestPermission(nestId, userId);
|
|
2757
|
+
if (permission === "none") {
|
|
2758
|
+
return c.json({ error: "Nest not found" }, 404);
|
|
2759
|
+
}
|
|
2760
|
+
let required = "read";
|
|
2761
|
+
const path = c.req.path;
|
|
2762
|
+
if (path.includes("/collaborators") || path.includes("/visibility")) {
|
|
2763
|
+
required = "admin";
|
|
2764
|
+
} else if (c.req.method !== "GET") {
|
|
2765
|
+
required = "write";
|
|
2766
|
+
}
|
|
2767
|
+
if (permissionLevel(permission) < permissionLevel(required)) {
|
|
2768
|
+
return c.json({ error: "Insufficient permissions" }, 403);
|
|
2769
|
+
}
|
|
2770
|
+
c.set("nestPermission", permission);
|
|
2771
|
+
return next();
|
|
2772
|
+
});
|
|
2773
|
+
nestsApp.route("/", nestRoutes);
|
|
2774
|
+
nestsApp.route("/:nestId", governanceRoutes);
|
|
2775
|
+
nestsApp.route("/:nestId/nodes", governanceNodeRoutes);
|
|
2776
|
+
nestsApp.route("/:nestId/nodes", nodeRoutes);
|
|
2777
|
+
nestsApp.route("/:nestId", queryRoutes);
|
|
2778
|
+
nestsApp.route("/:nestId", sharingRoutes);
|
|
2779
|
+
nestsApp.route("/:nestId/mcp", mcpRoutes);
|
|
2780
|
+
app.route("/nests", nestsApp);
|
|
2781
|
+
app.use("/assets/*", serveStatic({ root: "./dist/web3" }));
|
|
2782
|
+
app.get("*", serveStatic({ root: "./dist/web3", path: "index.html" }));
|
|
2783
|
+
app.onError((err, c) => {
|
|
2784
|
+
if (err instanceof AppError) {
|
|
2785
|
+
return c.json({ error: err.message }, err.statusCode);
|
|
2786
|
+
}
|
|
2787
|
+
console.error("Unhandled error:", err);
|
|
2788
|
+
return c.json({ error: "Internal server error" }, 500);
|
|
2789
|
+
});
|
|
2790
|
+
return app;
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
// src/index.ts
|
|
2794
|
+
async function main() {
|
|
2795
|
+
getDb();
|
|
2796
|
+
const accessCfg = loadAccessConfig();
|
|
2797
|
+
if (accessCfg) {
|
|
2798
|
+
console.log(` Loaded access.yaml (mode: ${accessCfg.mode || "open"})`);
|
|
2799
|
+
}
|
|
2800
|
+
const license = await validateLicense();
|
|
2801
|
+
if (!license.valid) {
|
|
2802
|
+
console.warn(`
|
|
2803
|
+
WARNING: No valid PromptOwl license key found.
|
|
2804
|
+
The server will run, but some features may be limited.
|
|
2805
|
+
|
|
2806
|
+
To register:
|
|
2807
|
+
1. Sign up at https://app.promptowl.ai
|
|
2808
|
+
2. Go to Settings > License Keys
|
|
2809
|
+
3. Create a Community Server key
|
|
2810
|
+
4. Set PROMPTOWL_KEY=pk_... in your environment
|
|
2811
|
+
`);
|
|
2812
|
+
}
|
|
2813
|
+
const app = createApp();
|
|
2814
|
+
startTelemetryLoop();
|
|
2815
|
+
trackEvent("server.start", {
|
|
2816
|
+
tier: license.tier,
|
|
2817
|
+
org: license.org,
|
|
2818
|
+
licensed: license.valid
|
|
2819
|
+
});
|
|
2820
|
+
const authDesc = config.AUTH_MODE === "open" ? "open (no auth \u2014 single-user / LAN only)" : "key (API key required on every request)";
|
|
2821
|
+
console.log(`
|
|
2822
|
+
ContextNest Community Server v0.1.0
|
|
2823
|
+
\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
|
|
2824
|
+
Port: ${config.PORT}
|
|
2825
|
+
Data: ${config.DATA_ROOT}
|
|
2826
|
+
License: ${license.valid ? `${license.tier}${license.org ? ` (${license.org})` : ""}` : "unlicensed (register at promptowl.ai)"}
|
|
2827
|
+
Auth: ${authDesc}
|
|
2828
|
+
Telemetry: ${config.TELEMETRY_ENABLED ? "on" : "off"}
|
|
2829
|
+
\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
|
|
2830
|
+
|
|
2831
|
+
To switch auth modes, set the AUTH_MODE env var:
|
|
2832
|
+
AUTH_MODE=key \u2014 default, safe for public/multi-user deployments
|
|
2833
|
+
AUTH_MODE=open \u2014 no auth, local/single-user only
|
|
2834
|
+
|
|
2835
|
+
Full reference: apps/community-server/CONFIGURATION.md
|
|
2836
|
+
|
|
2837
|
+
Ready! Endpoints:
|
|
2838
|
+
GET /nests List nests
|
|
2839
|
+
POST /nests Create nest
|
|
2840
|
+
GET /nests/:id/nodes List nodes
|
|
2841
|
+
POST /nests/:id/nodes Create node
|
|
2842
|
+
POST /nests/:id/query Selector query
|
|
2843
|
+
POST /nests/:id/context One-call context retrieval
|
|
2844
|
+
GET /nests/:id/search?q= Full-text search
|
|
2845
|
+
POST /nests/:id/publish Bulk publish
|
|
2846
|
+
POST /nests/:id/mcp MCP endpoint
|
|
2847
|
+
GET /health Health check
|
|
2848
|
+
${config.AUTH_MODE === "key" ? `
|
|
2849
|
+
Auth endpoints:
|
|
2850
|
+
POST /auth/register Create account
|
|
2851
|
+
POST /auth/login Login
|
|
2852
|
+
POST /auth/device PromptOwl device auth
|
|
2853
|
+
POST /auth/promptowl Exchange PromptOwl token
|
|
2854
|
+
` : ""}`);
|
|
2855
|
+
if (config.AUTH_MODE === "open") {
|
|
2856
|
+
console.warn(`
|
|
2857
|
+
\u26A0\uFE0F Running in open mode \u2014 no authentication.
|
|
2858
|
+
Anyone who can reach this port has full read/write + admin access.
|
|
2859
|
+
Bind to 127.0.0.1 only, or switch to AUTH_MODE=key before exposing.
|
|
2860
|
+
`);
|
|
2861
|
+
}
|
|
2862
|
+
serve({ fetch: app.fetch, port: config.PORT });
|
|
2863
|
+
}
|
|
2864
|
+
main().catch((err) => {
|
|
2865
|
+
console.error("Fatal:", err);
|
|
2866
|
+
process.exit(1);
|
|
2867
|
+
});
|