@promptowl/contextnest-community 0.1.0-alpha.2 → 1.0.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 -118
- package/dist/{chunk-DJFEV4ET.js → chunk-5VHKEIAW.js} +100 -4
- package/dist/{chunk-P6NG56CO.js → chunk-JMZ75ZCD.js} +44 -6
- package/dist/{chunk-Q2DCOS7V.js → chunk-K22GWPT4.js} +65 -58
- package/dist/{chunk-USIDOGVJ.js → chunk-KQCWNHDM.js} +218 -21
- package/dist/index.js +1738 -462
- package/dist/{review-service-5CLVZKAR.js → review-service-4WS3XL6K.js} +4 -3
- package/dist/{stewardship-service-NC67XBYO.js → stewardship-service-C5D2O7ZE.js} +2 -2
- package/dist/{version-service-Z6FYJRAG.js → version-service-TFEYNPH7.js} +10 -4
- package/dist/web3/assets/index-DkLevP7k.js +624 -0
- package/dist/web3/assets/index-DpoBdKrd.css +1 -0
- package/dist/web3/index.html +2 -2
- package/package.json +134 -108
- package/dist/web3/assets/index-CemroDXg.css +0 -1
- package/dist/web3/assets/index-xLLf4lHJ.js +0 -332
package/dist/index.js
CHANGED
|
@@ -7,22 +7,17 @@ import {
|
|
|
7
7
|
parseBearerToken,
|
|
8
8
|
verifyPassword
|
|
9
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
10
|
import {
|
|
19
11
|
approve,
|
|
20
12
|
cancelReview,
|
|
13
|
+
engineCache,
|
|
14
|
+
getPendingReview,
|
|
21
15
|
getReviewHistory,
|
|
22
16
|
getReviewQueue,
|
|
23
17
|
reject,
|
|
18
|
+
safePublishDocument,
|
|
24
19
|
submitForReview
|
|
25
|
-
} from "./chunk-
|
|
20
|
+
} from "./chunk-5VHKEIAW.js";
|
|
26
21
|
import {
|
|
27
22
|
assignSteward,
|
|
28
23
|
canUserAccess,
|
|
@@ -37,11 +32,22 @@ import {
|
|
|
37
32
|
resolveStewardsForNode,
|
|
38
33
|
resolveStewardsWithFallback,
|
|
39
34
|
syncFromConfig
|
|
40
|
-
} from "./chunk-
|
|
35
|
+
} from "./chunk-K22GWPT4.js";
|
|
41
36
|
import {
|
|
37
|
+
checkConflict,
|
|
38
|
+
createVersion,
|
|
39
|
+
getApprovedVersion,
|
|
40
|
+
getCurrentVersion,
|
|
41
|
+
getDisplayStatus,
|
|
42
|
+
getVersions,
|
|
43
|
+
setApprovedVersion
|
|
44
|
+
} from "./chunk-JMZ75ZCD.js";
|
|
45
|
+
import {
|
|
46
|
+
ANON_EMAIL,
|
|
47
|
+
ANON_USER_ID,
|
|
42
48
|
config,
|
|
43
49
|
getDb
|
|
44
|
-
} from "./chunk-
|
|
50
|
+
} from "./chunk-KQCWNHDM.js";
|
|
45
51
|
|
|
46
52
|
// src/index.ts
|
|
47
53
|
import { serve } from "@hono/node-server";
|
|
@@ -49,7 +55,6 @@ import { serve } from "@hono/node-server";
|
|
|
49
55
|
// src/app.ts
|
|
50
56
|
import { Hono as Hono8 } from "hono";
|
|
51
57
|
import { createMiddleware as createMiddleware2 } from "hono/factory";
|
|
52
|
-
import { logger } from "hono/logger";
|
|
53
58
|
import { cors } from "hono/cors";
|
|
54
59
|
|
|
55
60
|
// src/auth/routes.ts
|
|
@@ -58,23 +63,113 @@ import { v4 as uuid } from "uuid";
|
|
|
58
63
|
|
|
59
64
|
// src/auth/middleware.ts
|
|
60
65
|
import { createMiddleware } from "hono/factory";
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
|
|
67
|
+
// src/auth/sessions.ts
|
|
68
|
+
import { randomBytes } from "crypto";
|
|
69
|
+
var SESSION_COOKIE = "cnst_session";
|
|
70
|
+
var SESSION_TTL_DAYS = 30;
|
|
71
|
+
var SESSION_TTL_SECS = SESSION_TTL_DAYS * 24 * 60 * 60;
|
|
72
|
+
function newSessionId() {
|
|
73
|
+
return randomBytes(32).toString("hex");
|
|
74
|
+
}
|
|
75
|
+
function expiryIso(ttlSeconds = SESSION_TTL_SECS) {
|
|
76
|
+
return new Date(Date.now() + ttlSeconds * 1e3).toISOString();
|
|
77
|
+
}
|
|
78
|
+
function createSession(userId, userAgent) {
|
|
79
|
+
const db = getDb();
|
|
80
|
+
const id = newSessionId();
|
|
81
|
+
db.prepare(
|
|
82
|
+
`INSERT INTO sessions (id, user_id, expires_at, user_agent)
|
|
83
|
+
VALUES (?, ?, ?, ?)`
|
|
84
|
+
).run(id, userId, expiryIso(), userAgent || null);
|
|
85
|
+
return id;
|
|
86
|
+
}
|
|
87
|
+
function resolveSession(id) {
|
|
67
88
|
const db = getDb();
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
89
|
+
const row = db.prepare(
|
|
90
|
+
`SELECT user_id, expires_at FROM sessions WHERE id = ?`
|
|
91
|
+
).get(id);
|
|
92
|
+
if (!row) return null;
|
|
93
|
+
if (new Date(row.expires_at).getTime() <= Date.now()) {
|
|
94
|
+
db.prepare("DELETE FROM sessions WHERE id = ?").run(id);
|
|
95
|
+
return null;
|
|
71
96
|
}
|
|
72
97
|
db.prepare(
|
|
73
|
-
"UPDATE
|
|
74
|
-
).run(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
98
|
+
"UPDATE sessions SET last_seen_at = datetime('now') WHERE id = ?"
|
|
99
|
+
).run(id);
|
|
100
|
+
return row.user_id;
|
|
101
|
+
}
|
|
102
|
+
function deleteSession(id) {
|
|
103
|
+
const db = getDb();
|
|
104
|
+
db.prepare("DELETE FROM sessions WHERE id = ?").run(id);
|
|
105
|
+
}
|
|
106
|
+
function deleteAllSessionsForUser(userId) {
|
|
107
|
+
const db = getDb();
|
|
108
|
+
db.prepare("DELETE FROM sessions WHERE user_id = ?").run(userId);
|
|
109
|
+
}
|
|
110
|
+
function getSessionIdFromRequest(c) {
|
|
111
|
+
const cookieHeader = c.req.header("Cookie");
|
|
112
|
+
if (!cookieHeader) return null;
|
|
113
|
+
for (const part of cookieHeader.split(";")) {
|
|
114
|
+
const [k, ...rest] = part.trim().split("=");
|
|
115
|
+
if (k === SESSION_COOKIE) {
|
|
116
|
+
const v = rest.join("=").trim();
|
|
117
|
+
return /^[0-9a-f]{64}$/.test(v) ? v : null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
function buildSessionCookie(id, opts = {}) {
|
|
123
|
+
const maxAge = opts.maxAgeSeconds ?? SESSION_TTL_SECS;
|
|
124
|
+
const parts = [
|
|
125
|
+
`${SESSION_COOKIE}=${id}`,
|
|
126
|
+
"HttpOnly",
|
|
127
|
+
"Path=/",
|
|
128
|
+
"SameSite=Lax",
|
|
129
|
+
`Max-Age=${maxAge}`
|
|
130
|
+
];
|
|
131
|
+
if (opts.secure) parts.push("Secure");
|
|
132
|
+
return parts.join("; ");
|
|
133
|
+
}
|
|
134
|
+
function buildClearSessionCookie(opts = {}) {
|
|
135
|
+
return buildSessionCookie("", { secure: opts.secure, maxAgeSeconds: 0 });
|
|
136
|
+
}
|
|
137
|
+
function isSecureRequest(c) {
|
|
138
|
+
const proto = c.req.header("x-forwarded-proto");
|
|
139
|
+
if (proto) return proto.split(",")[0].trim() === "https";
|
|
140
|
+
try {
|
|
141
|
+
return new URL(c.req.url).protocol === "https:";
|
|
142
|
+
} catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// src/auth/middleware.ts
|
|
148
|
+
var authMiddleware = createMiddleware(async (c, next) => {
|
|
149
|
+
const db = getDb();
|
|
150
|
+
const sessionId = getSessionIdFromRequest(c);
|
|
151
|
+
if (sessionId) {
|
|
152
|
+
const userId = resolveSession(sessionId);
|
|
153
|
+
if (userId) {
|
|
154
|
+
c.set("userId", userId);
|
|
155
|
+
c.set("nestScope", null);
|
|
156
|
+
return next();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const key = parseBearerToken(c.req.header("Authorization"));
|
|
160
|
+
if (key) {
|
|
161
|
+
const keyHash = hashApiKey(key);
|
|
162
|
+
const record = db.prepare("SELECT user_id, nest_id FROM api_keys WHERE key_hash = ?").get(keyHash);
|
|
163
|
+
if (record) {
|
|
164
|
+
db.prepare(
|
|
165
|
+
"UPDATE api_keys SET last_used_at = datetime('now') WHERE key_hash = ?"
|
|
166
|
+
).run(keyHash);
|
|
167
|
+
c.set("userId", record.user_id);
|
|
168
|
+
c.set("nestScope", record.nest_id);
|
|
169
|
+
return next();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return c.json({ error: "Missing or invalid credentials" }, 401);
|
|
78
173
|
});
|
|
79
174
|
|
|
80
175
|
// src/shared/errors.ts
|
|
@@ -84,6 +179,7 @@ var AppError = class extends Error {
|
|
|
84
179
|
this.statusCode = statusCode;
|
|
85
180
|
this.name = "AppError";
|
|
86
181
|
}
|
|
182
|
+
statusCode;
|
|
87
183
|
};
|
|
88
184
|
var NotFoundError = class extends AppError {
|
|
89
185
|
constructor(message = "Not found") {
|
|
@@ -91,12 +187,24 @@ var NotFoundError = class extends AppError {
|
|
|
91
187
|
this.name = "NotFoundError";
|
|
92
188
|
}
|
|
93
189
|
};
|
|
190
|
+
var ForbiddenError = class extends AppError {
|
|
191
|
+
constructor(message = "Forbidden") {
|
|
192
|
+
super(403, message);
|
|
193
|
+
this.name = "ForbiddenError";
|
|
194
|
+
}
|
|
195
|
+
};
|
|
94
196
|
var ValidationError = class extends AppError {
|
|
95
197
|
constructor(message) {
|
|
96
198
|
super(400, message);
|
|
97
199
|
this.name = "ValidationError";
|
|
98
200
|
}
|
|
99
201
|
};
|
|
202
|
+
var ConflictError = class extends AppError {
|
|
203
|
+
constructor(message) {
|
|
204
|
+
super(409, message);
|
|
205
|
+
this.name = "ConflictError";
|
|
206
|
+
}
|
|
207
|
+
};
|
|
100
208
|
|
|
101
209
|
// src/telemetry/tracker.ts
|
|
102
210
|
function trackEvent(event, data) {
|
|
@@ -154,6 +262,368 @@ function startTelemetryLoop() {
|
|
|
154
262
|
);
|
|
155
263
|
}
|
|
156
264
|
|
|
265
|
+
// src/auth/license.ts
|
|
266
|
+
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
267
|
+
var currentLicense = null;
|
|
268
|
+
function getCurrentLicense() {
|
|
269
|
+
return currentLicense;
|
|
270
|
+
}
|
|
271
|
+
function isLicenseAdminEmail(email) {
|
|
272
|
+
if (!email) return false;
|
|
273
|
+
const lic = currentLicense;
|
|
274
|
+
if (!lic?.valid || !lic.ownerEmail) return false;
|
|
275
|
+
return lic.ownerEmail.toLowerCase() === email.toLowerCase();
|
|
276
|
+
}
|
|
277
|
+
function isLicenseAdminUserId(userId) {
|
|
278
|
+
try {
|
|
279
|
+
const row = getDb().prepare("SELECT email FROM users WHERE id = ?").get(userId);
|
|
280
|
+
return isLicenseAdminEmail(row?.email);
|
|
281
|
+
} catch {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
function upsertEnvVar(filePath, varName, value) {
|
|
286
|
+
const prefix = `${varName}=`;
|
|
287
|
+
let lines = [];
|
|
288
|
+
if (existsSync(filePath)) {
|
|
289
|
+
lines = readFileSync(filePath, "utf8").split(/\r?\n/);
|
|
290
|
+
}
|
|
291
|
+
const filtered = lines.filter((line) => !line.trimStart().startsWith(prefix));
|
|
292
|
+
if (value !== null) {
|
|
293
|
+
filtered.push(`${prefix}${value}`);
|
|
294
|
+
}
|
|
295
|
+
while (filtered.length && filtered[filtered.length - 1] === "") {
|
|
296
|
+
filtered.pop();
|
|
297
|
+
}
|
|
298
|
+
writeFileSync(filePath, filtered.join("\n") + "\n", "utf8");
|
|
299
|
+
}
|
|
300
|
+
async function installLicenseKey(key) {
|
|
301
|
+
const trimmed = key.trim();
|
|
302
|
+
if (!trimmed.startsWith("pk_")) {
|
|
303
|
+
throw new Error("Invalid license key format. Must start with pk_.");
|
|
304
|
+
}
|
|
305
|
+
const previousKey = process.env.PROMPTOWL_KEY || "";
|
|
306
|
+
process.env.PROMPTOWL_KEY = trimmed;
|
|
307
|
+
const info = await validateLicense({ forceFresh: true });
|
|
308
|
+
if (!info.valid) {
|
|
309
|
+
process.env.PROMPTOWL_KEY = previousKey;
|
|
310
|
+
if (previousKey) {
|
|
311
|
+
await validateLicense({ forceFresh: true });
|
|
312
|
+
}
|
|
313
|
+
return info;
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
upsertEnvVar(config.ENV_FILE_PATH, "PROMPTOWL_KEY", trimmed);
|
|
317
|
+
} catch (err) {
|
|
318
|
+
console.warn("[license] failed to write .env:", err);
|
|
319
|
+
}
|
|
320
|
+
startLicenseWatcher();
|
|
321
|
+
return info;
|
|
322
|
+
}
|
|
323
|
+
var watcherActive = false;
|
|
324
|
+
var watcherAbort = null;
|
|
325
|
+
var WATCHER_BACKOFF_MIN_MS = 2 * 1e3;
|
|
326
|
+
var WATCHER_BACKOFF_MAX_MS = 60 * 1e3;
|
|
327
|
+
function startLicenseWatcher() {
|
|
328
|
+
if (watcherActive) {
|
|
329
|
+
watcherAbort?.abort();
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
watcherActive = true;
|
|
333
|
+
void runLicenseWatcher();
|
|
334
|
+
}
|
|
335
|
+
async function runLicenseWatcher() {
|
|
336
|
+
let backoff = WATCHER_BACKOFF_MIN_MS;
|
|
337
|
+
while (watcherActive) {
|
|
338
|
+
const key = config.PROMPTOWL_KEY;
|
|
339
|
+
if (!key) {
|
|
340
|
+
await sleep(WATCHER_BACKOFF_MAX_MS);
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
try {
|
|
344
|
+
watcherAbort = new AbortController();
|
|
345
|
+
const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
|
|
346
|
+
const fetchTimeout = AbortSignal.timeout(30 * 1e3);
|
|
347
|
+
const signal = typeof AbortSignal.any === "function" ? AbortSignal.any([watcherAbort.signal, fetchTimeout]) : watcherAbort.signal;
|
|
348
|
+
const res = await fetch(`${promptowlUrl}/api/license/listen`, {
|
|
349
|
+
method: "POST",
|
|
350
|
+
headers: { "Content-Type": "application/json" },
|
|
351
|
+
body: JSON.stringify({
|
|
352
|
+
key,
|
|
353
|
+
since_updated_at: currentLicense ? (/* @__PURE__ */ new Date()).toISOString() : void 0
|
|
354
|
+
}),
|
|
355
|
+
signal
|
|
356
|
+
});
|
|
357
|
+
if (!res.ok) {
|
|
358
|
+
if (res.status === 504 || res.status === 408 || res.status === 502) {
|
|
359
|
+
backoff = WATCHER_BACKOFF_MIN_MS;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
throw new Error(`listen returned ${res.status}`);
|
|
363
|
+
}
|
|
364
|
+
const data = await res.json();
|
|
365
|
+
backoff = WATCHER_BACKOFF_MIN_MS;
|
|
366
|
+
if (data.event && data.event !== "no_change") {
|
|
367
|
+
console.log(
|
|
368
|
+
`[license] event from PromptOwl: ${data.event} \u2014 revalidating`
|
|
369
|
+
);
|
|
370
|
+
const wasValid = !!currentLicense?.valid;
|
|
371
|
+
await validateLicense({ forceFresh: true });
|
|
372
|
+
const isValid = !!currentLicense?.valid;
|
|
373
|
+
if (wasValid && !isValid) {
|
|
374
|
+
handleLicenseRevoked();
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
} catch (err) {
|
|
378
|
+
if (err.name === "AbortError") {
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
console.warn(
|
|
382
|
+
`[license] watcher error: ${err.message}; backing off ${backoff}ms`
|
|
383
|
+
);
|
|
384
|
+
await sleep(backoff);
|
|
385
|
+
backoff = Math.min(backoff * 2, WATCHER_BACKOFF_MAX_MS);
|
|
386
|
+
} finally {
|
|
387
|
+
watcherAbort = null;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
function sleep(ms) {
|
|
392
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
393
|
+
}
|
|
394
|
+
var safetyPollHandle = null;
|
|
395
|
+
var SAFETY_POLL_INTERVAL_MS = 60 * 1e3;
|
|
396
|
+
function startLicenseSafetyPoll() {
|
|
397
|
+
if (safetyPollHandle) return;
|
|
398
|
+
safetyPollHandle = setInterval(async () => {
|
|
399
|
+
if (!config.PROMPTOWL_KEY) return;
|
|
400
|
+
try {
|
|
401
|
+
const wasValid = !!currentLicense?.valid;
|
|
402
|
+
await validateLicense({ forceFresh: true });
|
|
403
|
+
const isValid = !!currentLicense?.valid;
|
|
404
|
+
if (wasValid && !isValid) {
|
|
405
|
+
console.log("[license] safety poll detected revocation");
|
|
406
|
+
handleLicenseRevoked();
|
|
407
|
+
}
|
|
408
|
+
} catch (err) {
|
|
409
|
+
console.warn(
|
|
410
|
+
`[license] safety poll error: ${err.message}`
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
}, SAFETY_POLL_INTERVAL_MS);
|
|
414
|
+
if (typeof safetyPollHandle.unref === "function") {
|
|
415
|
+
safetyPollHandle.unref();
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
function handleLicenseRevoked() {
|
|
419
|
+
try {
|
|
420
|
+
const db = getDb();
|
|
421
|
+
const result = db.prepare("DELETE FROM sessions").run();
|
|
422
|
+
console.warn(
|
|
423
|
+
`[license] revoked \u2014 wiped ${result.changes} active session(s).`
|
|
424
|
+
);
|
|
425
|
+
} catch (err) {
|
|
426
|
+
console.warn("[license] failed to wipe sessions:", err);
|
|
427
|
+
}
|
|
428
|
+
try {
|
|
429
|
+
upsertEnvVar(config.ENV_FILE_PATH, "PROMPTOWL_KEY", null);
|
|
430
|
+
console.warn(
|
|
431
|
+
`[license] revoked \u2014 removed PROMPTOWL_KEY from ${config.ENV_FILE_PATH}`
|
|
432
|
+
);
|
|
433
|
+
} catch (err) {
|
|
434
|
+
console.warn("[license] failed to strip key from .env:", err);
|
|
435
|
+
}
|
|
436
|
+
process.env.PROMPTOWL_KEY = "";
|
|
437
|
+
currentLicense = {
|
|
438
|
+
valid: false,
|
|
439
|
+
tier: "none",
|
|
440
|
+
org: null,
|
|
441
|
+
limits: null,
|
|
442
|
+
suspended: false,
|
|
443
|
+
suspendedReason: null,
|
|
444
|
+
ownerEmail: null
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
448
|
+
var suspensionFirstSeen = null;
|
|
449
|
+
var suspensionConfirmed = false;
|
|
450
|
+
var suspensionReason = null;
|
|
451
|
+
var SUSPENSION_CONFIRM_WINDOW_MS = 60 * 60 * 1e3;
|
|
452
|
+
function isSuspended() {
|
|
453
|
+
return suspensionConfirmed;
|
|
454
|
+
}
|
|
455
|
+
function getSuspensionReason() {
|
|
456
|
+
return suspensionReason;
|
|
457
|
+
}
|
|
458
|
+
async function validateLicense(opts = {}) {
|
|
459
|
+
const info = await _validateLicenseImpl(!!opts.forceFresh);
|
|
460
|
+
currentLicense = info;
|
|
461
|
+
return info;
|
|
462
|
+
}
|
|
463
|
+
async function _validateLicenseImpl(forceFresh) {
|
|
464
|
+
const key = config.PROMPTOWL_KEY;
|
|
465
|
+
if (!key) {
|
|
466
|
+
return {
|
|
467
|
+
valid: false,
|
|
468
|
+
tier: "none",
|
|
469
|
+
org: null,
|
|
470
|
+
limits: null,
|
|
471
|
+
suspended: false,
|
|
472
|
+
suspendedReason: null,
|
|
473
|
+
ownerEmail: null
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
const db = getDb();
|
|
477
|
+
const cached = db.prepare("SELECT * FROM license_cache WHERE key = ?").get(key);
|
|
478
|
+
if (cached && !forceFresh) {
|
|
479
|
+
const age = Date.now() - (/* @__PURE__ */ new Date(cached.validated_at + "Z")).getTime();
|
|
480
|
+
if (age < CACHE_TTL_MS) {
|
|
481
|
+
return {
|
|
482
|
+
valid: true,
|
|
483
|
+
tier: cached.tier,
|
|
484
|
+
org: cached.org,
|
|
485
|
+
limits: cached.limits_json ? JSON.parse(cached.limits_json) : null,
|
|
486
|
+
suspended: suspensionConfirmed,
|
|
487
|
+
suspendedReason: suspensionReason,
|
|
488
|
+
ownerEmail: cached.owner_email || null
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
try {
|
|
493
|
+
const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
|
|
494
|
+
const res = await fetch(`${promptowlUrl}/api/license/validate`, {
|
|
495
|
+
method: "POST",
|
|
496
|
+
headers: { "Content-Type": "application/json" },
|
|
497
|
+
body: JSON.stringify({ key })
|
|
498
|
+
});
|
|
499
|
+
if (!res.ok) {
|
|
500
|
+
if (cached) {
|
|
501
|
+
console.warn(
|
|
502
|
+
" PromptOwl unreachable, using cached license (grace period)"
|
|
503
|
+
);
|
|
504
|
+
return {
|
|
505
|
+
valid: true,
|
|
506
|
+
tier: cached.tier,
|
|
507
|
+
org: cached.org,
|
|
508
|
+
limits: cached.limits_json ? JSON.parse(cached.limits_json) : null,
|
|
509
|
+
suspended: suspensionConfirmed,
|
|
510
|
+
suspendedReason: suspensionReason,
|
|
511
|
+
ownerEmail: cached.owner_email || null
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
return {
|
|
515
|
+
valid: false,
|
|
516
|
+
tier: "none",
|
|
517
|
+
org: null,
|
|
518
|
+
limits: null,
|
|
519
|
+
suspended: false,
|
|
520
|
+
suspendedReason: null,
|
|
521
|
+
ownerEmail: null
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
const data = await res.json();
|
|
525
|
+
if (data.suspended === true) {
|
|
526
|
+
if (!suspensionFirstSeen) {
|
|
527
|
+
suspensionFirstSeen = Date.now();
|
|
528
|
+
suspensionReason = data.suspended_reason || "Suspended by PromptOwl";
|
|
529
|
+
console.warn(
|
|
530
|
+
`
|
|
531
|
+
WARNING: PromptOwl has flagged this server for suspension.`
|
|
532
|
+
);
|
|
533
|
+
console.warn(
|
|
534
|
+
` Reason: ${suspensionReason}`
|
|
535
|
+
);
|
|
536
|
+
console.warn(
|
|
537
|
+
` This will be confirmed in ~1 hour. If this is an error,`
|
|
538
|
+
);
|
|
539
|
+
console.warn(
|
|
540
|
+
` contact support@promptowl.ai to reverse it.
|
|
541
|
+
`
|
|
542
|
+
);
|
|
543
|
+
} else if (Date.now() - suspensionFirstSeen >= SUSPENSION_CONFIRM_WINDOW_MS) {
|
|
544
|
+
suspensionConfirmed = true;
|
|
545
|
+
suspensionReason = data.suspended_reason || "Suspended by PromptOwl";
|
|
546
|
+
console.error(
|
|
547
|
+
`
|
|
548
|
+
SERVER SUSPENDED: ${suspensionReason}`
|
|
549
|
+
);
|
|
550
|
+
console.error(
|
|
551
|
+
` Write operations are disabled. Reads still work.`
|
|
552
|
+
);
|
|
553
|
+
console.error(
|
|
554
|
+
` Contact support@promptowl.ai to resolve.
|
|
555
|
+
`
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
} else {
|
|
559
|
+
if (suspensionFirstSeen) {
|
|
560
|
+
console.log(" Suspension flag cleared by PromptOwl.");
|
|
561
|
+
}
|
|
562
|
+
suspensionFirstSeen = null;
|
|
563
|
+
suspensionConfirmed = false;
|
|
564
|
+
suspensionReason = null;
|
|
565
|
+
}
|
|
566
|
+
if (!data.valid && !data.suspended) {
|
|
567
|
+
db.prepare("DELETE FROM license_cache WHERE key = ?").run(key);
|
|
568
|
+
return {
|
|
569
|
+
valid: false,
|
|
570
|
+
tier: "none",
|
|
571
|
+
org: null,
|
|
572
|
+
limits: null,
|
|
573
|
+
suspended: false,
|
|
574
|
+
suspendedReason: null,
|
|
575
|
+
ownerEmail: data.owner_email || null
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
if (data.valid) {
|
|
579
|
+
const limitsJson = data.limits ? JSON.stringify(data.limits) : null;
|
|
580
|
+
db.prepare(
|
|
581
|
+
`INSERT OR REPLACE INTO license_cache (key, tier, org, limits_json, owner_email, validated_at)
|
|
582
|
+
VALUES (?, ?, ?, ?, ?, datetime('now'))`
|
|
583
|
+
).run(
|
|
584
|
+
key,
|
|
585
|
+
data.tier || "community",
|
|
586
|
+
data.org || null,
|
|
587
|
+
limitsJson,
|
|
588
|
+
data.owner_email || null
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
return {
|
|
592
|
+
valid: data.valid !== false,
|
|
593
|
+
tier: data.tier || "community",
|
|
594
|
+
org: data.org || null,
|
|
595
|
+
limits: data.limits || null,
|
|
596
|
+
suspended: suspensionConfirmed,
|
|
597
|
+
suspendedReason: suspensionReason,
|
|
598
|
+
ownerEmail: data.owner_email || null
|
|
599
|
+
};
|
|
600
|
+
} catch (err) {
|
|
601
|
+
if (cached) {
|
|
602
|
+
console.warn(
|
|
603
|
+
` PromptOwl validation failed (${err.message}), using cached license`
|
|
604
|
+
);
|
|
605
|
+
return {
|
|
606
|
+
valid: true,
|
|
607
|
+
tier: cached.tier,
|
|
608
|
+
org: cached.org,
|
|
609
|
+
limits: cached.limits_json ? JSON.parse(cached.limits_json) : null,
|
|
610
|
+
suspended: suspensionConfirmed,
|
|
611
|
+
suspendedReason: suspensionReason,
|
|
612
|
+
ownerEmail: cached.owner_email || null
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
return {
|
|
616
|
+
valid: false,
|
|
617
|
+
tier: "none",
|
|
618
|
+
org: null,
|
|
619
|
+
limits: null,
|
|
620
|
+
suspended: false,
|
|
621
|
+
suspendedReason: null,
|
|
622
|
+
ownerEmail: null
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
157
627
|
// src/shared/rate-limit.ts
|
|
158
628
|
var buckets = /* @__PURE__ */ new Map();
|
|
159
629
|
function tryConsume(key, cfg) {
|
|
@@ -185,6 +655,32 @@ function clientIp(c) {
|
|
|
185
655
|
if (realIp) return realIp.trim();
|
|
186
656
|
return "unknown";
|
|
187
657
|
}
|
|
658
|
+
function resolveCallerUserId(c) {
|
|
659
|
+
const sessionId = getSessionIdFromRequest(c);
|
|
660
|
+
if (sessionId) {
|
|
661
|
+
const uid = resolveSession(sessionId);
|
|
662
|
+
if (uid) return uid;
|
|
663
|
+
}
|
|
664
|
+
const key = parseBearerToken(c.req.header("Authorization"));
|
|
665
|
+
if (key) {
|
|
666
|
+
const db = getDb();
|
|
667
|
+
const row = db.prepare("SELECT user_id FROM api_keys WHERE key_hash = ?").get(hashApiKey(key));
|
|
668
|
+
if (row) return row.user_id;
|
|
669
|
+
}
|
|
670
|
+
return null;
|
|
671
|
+
}
|
|
672
|
+
function setSessionCookie(c, sessionId) {
|
|
673
|
+
c.header(
|
|
674
|
+
"Set-Cookie",
|
|
675
|
+
buildSessionCookie(sessionId, { secure: isSecureRequest(c) })
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
function clearSessionCookie(c) {
|
|
679
|
+
c.header(
|
|
680
|
+
"Set-Cookie",
|
|
681
|
+
buildClearSessionCookie({ secure: isSecureRequest(c) })
|
|
682
|
+
);
|
|
683
|
+
}
|
|
188
684
|
var authRoutes = new Hono();
|
|
189
685
|
authRoutes.post("/register", async (c) => {
|
|
190
686
|
const body = await c.req.json();
|
|
@@ -196,25 +692,34 @@ authRoutes.post("/register", async (c) => {
|
|
|
196
692
|
return c.json({ error: "Too many registration attempts, try again later" }, 429);
|
|
197
693
|
}
|
|
198
694
|
const db = getDb();
|
|
199
|
-
const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(body.email);
|
|
200
|
-
|
|
695
|
+
const existing = db.prepare("SELECT id, is_invited FROM users WHERE email = ?").get(body.email);
|
|
696
|
+
let userId;
|
|
697
|
+
const passwordHash = await hashPassword(body.password);
|
|
698
|
+
if (existing && existing.is_invited === 1) {
|
|
699
|
+
userId = existing.id;
|
|
700
|
+
db.prepare(
|
|
701
|
+
"UPDATE users SET password_hash = ?, name = COALESCE(?, name), is_invited = 0 WHERE id = ?"
|
|
702
|
+
).run(passwordHash, body.name || null, userId);
|
|
703
|
+
trackEvent("user.register", { userId, email: body.email, claimed: true });
|
|
704
|
+
} else if (existing) {
|
|
201
705
|
throw new ValidationError("Email already registered");
|
|
706
|
+
} else {
|
|
707
|
+
userId = uuid();
|
|
708
|
+
db.prepare(
|
|
709
|
+
"INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)"
|
|
710
|
+
).run(userId, body.email, body.name || null, passwordHash);
|
|
711
|
+
trackEvent("user.register", { userId, email: body.email });
|
|
202
712
|
}
|
|
203
|
-
const
|
|
204
|
-
|
|
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 });
|
|
713
|
+
const sessionId = createSession(userId, c.req.header("User-Agent"));
|
|
714
|
+
setSessionCookie(c, sessionId);
|
|
214
715
|
return c.json(
|
|
215
716
|
{
|
|
216
|
-
user: {
|
|
217
|
-
|
|
717
|
+
user: {
|
|
718
|
+
id: userId,
|
|
719
|
+
email: body.email,
|
|
720
|
+
name: body.name || null,
|
|
721
|
+
is_admin: isLicenseAdminEmail(body.email)
|
|
722
|
+
}
|
|
218
723
|
},
|
|
219
724
|
201
|
|
220
725
|
);
|
|
@@ -231,7 +736,7 @@ authRoutes.post("/login", async (c) => {
|
|
|
231
736
|
}
|
|
232
737
|
const db = getDb();
|
|
233
738
|
const user = db.prepare(
|
|
234
|
-
"SELECT id, email, name, password_hash FROM users WHERE email = ?"
|
|
739
|
+
"SELECT id, email, name, password_hash, is_admin FROM users WHERE email = ?"
|
|
235
740
|
).get(body.email);
|
|
236
741
|
const check = user ? await verifyPassword(body.password, user.password_hash) : { ok: false, needsRehash: false };
|
|
237
742
|
if (!user || !check.ok) {
|
|
@@ -247,30 +752,47 @@ authRoutes.post("/login", async (c) => {
|
|
|
247
752
|
} catch {
|
|
248
753
|
}
|
|
249
754
|
}
|
|
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
755
|
trackEvent("user.login", { userId: user.id });
|
|
262
|
-
|
|
756
|
+
const sessionId = createSession(user.id, c.req.header("User-Agent"));
|
|
757
|
+
setSessionCookie(c, sessionId);
|
|
758
|
+
return c.json({
|
|
759
|
+
user: {
|
|
760
|
+
id: user.id,
|
|
761
|
+
email: user.email,
|
|
762
|
+
name: user.name,
|
|
763
|
+
is_admin: isLicenseAdminEmail(user.email)
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
});
|
|
767
|
+
authRoutes.post("/logout", async (c) => {
|
|
768
|
+
const sessionId = getSessionIdFromRequest(c);
|
|
769
|
+
if (sessionId) deleteSession(sessionId);
|
|
770
|
+
clearSessionCookie(c);
|
|
771
|
+
return c.json({ ok: true });
|
|
263
772
|
});
|
|
264
773
|
authRoutes.post("/keys", authMiddleware, async (c) => {
|
|
265
|
-
const body = await c.req.json()
|
|
774
|
+
const body = await c.req.json().catch(
|
|
775
|
+
() => ({})
|
|
776
|
+
);
|
|
266
777
|
const db = getDb();
|
|
778
|
+
const userId = c.get("userId");
|
|
779
|
+
const existing = db.prepare("SELECT key_prefix FROM api_keys WHERE user_id = ?").get(userId);
|
|
780
|
+
if (existing) {
|
|
781
|
+
return c.json(
|
|
782
|
+
{
|
|
783
|
+
error: "An API key already exists for this user. Rotate it instead.",
|
|
784
|
+
key_prefix: existing.key_prefix
|
|
785
|
+
},
|
|
786
|
+
409
|
|
787
|
+
);
|
|
788
|
+
}
|
|
267
789
|
const apiKey = generateApiKey();
|
|
268
790
|
const keyId = uuid();
|
|
269
791
|
db.prepare(
|
|
270
792
|
"INSERT INTO api_keys (id, user_id, key_hash, key_prefix, nest_id, label) VALUES (?, ?, ?, ?, ?, ?)"
|
|
271
793
|
).run(
|
|
272
794
|
keyId,
|
|
273
|
-
|
|
795
|
+
userId,
|
|
274
796
|
hashApiKey(apiKey),
|
|
275
797
|
getKeyPrefix(apiKey),
|
|
276
798
|
body.nest_id || null,
|
|
@@ -281,6 +803,30 @@ authRoutes.post("/keys", authMiddleware, async (c) => {
|
|
|
281
803
|
201
|
|
282
804
|
);
|
|
283
805
|
});
|
|
806
|
+
authRoutes.post("/keys/rotate", authMiddleware, async (c) => {
|
|
807
|
+
const body = await c.req.json().catch(
|
|
808
|
+
() => ({})
|
|
809
|
+
);
|
|
810
|
+
const db = getDb();
|
|
811
|
+
const userId = c.get("userId");
|
|
812
|
+
const apiKey = generateApiKey();
|
|
813
|
+
const keyId = uuid();
|
|
814
|
+
const prior = db.prepare("SELECT label, nest_id FROM api_keys WHERE user_id = ?").get(userId);
|
|
815
|
+
db.transaction(() => {
|
|
816
|
+
db.prepare("DELETE FROM api_keys WHERE user_id = ?").run(userId);
|
|
817
|
+
db.prepare(
|
|
818
|
+
"INSERT INTO api_keys (id, user_id, key_hash, key_prefix, nest_id, label) VALUES (?, ?, ?, ?, ?, ?)"
|
|
819
|
+
).run(
|
|
820
|
+
keyId,
|
|
821
|
+
userId,
|
|
822
|
+
hashApiKey(apiKey),
|
|
823
|
+
getKeyPrefix(apiKey),
|
|
824
|
+
body.nest_id ?? prior?.nest_id ?? null,
|
|
825
|
+
body.label ?? prior?.label ?? null
|
|
826
|
+
);
|
|
827
|
+
})();
|
|
828
|
+
return c.json({ api_key: apiKey, key_prefix: getKeyPrefix(apiKey) });
|
|
829
|
+
});
|
|
284
830
|
authRoutes.get("/keys", authMiddleware, async (c) => {
|
|
285
831
|
const db = getDb();
|
|
286
832
|
const keys = db.prepare(
|
|
@@ -370,97 +916,127 @@ authRoutes.post("/promptowl", async (c) => {
|
|
|
370
916
|
} else {
|
|
371
917
|
trackEvent("user.login", { userId: user.id, method: "promptowl" });
|
|
372
918
|
}
|
|
919
|
+
let claimBlocked = null;
|
|
920
|
+
const lic = getCurrentLicense();
|
|
373
921
|
let isAdmin = false;
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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)) {
|
|
922
|
+
if (!lic?.valid) {
|
|
923
|
+
claimBlocked = { reason: "license_required" };
|
|
924
|
+
} else if (lic.ownerEmail && lic.ownerEmail.toLowerCase() === me.email.toLowerCase()) {
|
|
383
925
|
isAdmin = true;
|
|
384
926
|
trackEvent("admin.claim", { userId: user.id, email: user.email });
|
|
385
927
|
} else {
|
|
386
|
-
|
|
387
|
-
|
|
928
|
+
claimBlocked = {
|
|
929
|
+
reason: "email_mismatch",
|
|
930
|
+
license_owner_email: lic.ownerEmail
|
|
931
|
+
};
|
|
388
932
|
}
|
|
389
|
-
const
|
|
390
|
-
|
|
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
|
-
);
|
|
933
|
+
const sessionId = createSession(user.id, c.req.header("User-Agent"));
|
|
934
|
+
setSessionCookie(c, sessionId);
|
|
400
935
|
return c.json({
|
|
401
|
-
api_key: apiKey,
|
|
402
936
|
user: {
|
|
403
937
|
id: user.id,
|
|
404
938
|
email: user.email,
|
|
405
939
|
name: user.name,
|
|
406
940
|
is_admin: isAdmin
|
|
407
|
-
}
|
|
941
|
+
},
|
|
942
|
+
...claimBlocked ? { claim_blocked: claimBlocked } : {}
|
|
408
943
|
});
|
|
409
944
|
});
|
|
410
945
|
authRoutes.get("/admin-status", async (c) => {
|
|
411
946
|
const db = getDb();
|
|
412
|
-
const
|
|
413
|
-
const
|
|
947
|
+
const lic = getCurrentLicense();
|
|
948
|
+
const ownerEmail = lic?.valid ? lic.ownerEmail : null;
|
|
949
|
+
let admin = null;
|
|
950
|
+
if (ownerEmail) {
|
|
951
|
+
const ownerRow = db.prepare("SELECT name FROM users WHERE LOWER(email) = LOWER(?) LIMIT 1").get(ownerEmail);
|
|
952
|
+
admin = { email: ownerEmail, name: ownerRow?.name ?? null };
|
|
953
|
+
}
|
|
954
|
+
const callerId = resolveCallerUserId(c);
|
|
414
955
|
let me = null;
|
|
415
|
-
if (
|
|
416
|
-
const
|
|
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);
|
|
956
|
+
if (callerId) {
|
|
957
|
+
const row = db.prepare("SELECT email, name FROM users WHERE id = ?").get(callerId);
|
|
422
958
|
if (row) {
|
|
423
|
-
me = {
|
|
959
|
+
me = {
|
|
960
|
+
email: row.email,
|
|
961
|
+
name: row.name,
|
|
962
|
+
is_admin: isLicenseAdminEmail(row.email)
|
|
963
|
+
};
|
|
424
964
|
}
|
|
425
965
|
}
|
|
426
966
|
return c.json({
|
|
427
967
|
claimed: !!admin,
|
|
428
|
-
admin
|
|
968
|
+
admin,
|
|
429
969
|
me
|
|
430
970
|
});
|
|
431
971
|
});
|
|
972
|
+
authRoutes.post("/password", authMiddleware, async (c) => {
|
|
973
|
+
const body = await c.req.json();
|
|
974
|
+
if (!body.current || !body.next) {
|
|
975
|
+
throw new ValidationError("current and next are required");
|
|
976
|
+
}
|
|
977
|
+
if (body.next.length < 8) {
|
|
978
|
+
throw new ValidationError("new password must be at least 8 characters");
|
|
979
|
+
}
|
|
980
|
+
const db = getDb();
|
|
981
|
+
const userId = c.get("userId");
|
|
982
|
+
const user = db.prepare("SELECT password_hash FROM users WHERE id = ?").get(userId);
|
|
983
|
+
if (!user) return c.json({ error: "User not found" }, 404);
|
|
984
|
+
const check = await verifyPassword(body.current, user.password_hash);
|
|
985
|
+
if (!check.ok) return c.json({ error: "Invalid current password" }, 401);
|
|
986
|
+
const newHash = await hashPassword(body.next);
|
|
987
|
+
db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(
|
|
988
|
+
newHash,
|
|
989
|
+
userId
|
|
990
|
+
);
|
|
991
|
+
deleteAllSessionsForUser(userId);
|
|
992
|
+
clearSessionCookie(c);
|
|
993
|
+
return c.json({ ok: true });
|
|
994
|
+
});
|
|
432
995
|
authRoutes.post("/invite", async (c) => {
|
|
433
996
|
const body = await c.req.json();
|
|
434
997
|
if (!body.email) throw new ValidationError("email is required");
|
|
435
|
-
const
|
|
436
|
-
if (!
|
|
437
|
-
return c.json(
|
|
998
|
+
const callerId = resolveCallerUserId(c);
|
|
999
|
+
if (!callerId) {
|
|
1000
|
+
return c.json(
|
|
1001
|
+
{
|
|
1002
|
+
error: "Authentication required. Sign in with the admin account that owns the installed PromptOwl license to invite teammates."
|
|
1003
|
+
},
|
|
1004
|
+
401
|
|
1005
|
+
);
|
|
438
1006
|
}
|
|
439
1007
|
const db = getDb();
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
return c.json({ error: "Admin only" }, 403);
|
|
1008
|
+
if (!isLicenseAdminUserId(callerId)) {
|
|
1009
|
+
return c.json(
|
|
1010
|
+
{
|
|
1011
|
+
error: "Only the license-admin user can invite teammates. Contact the admin who installed the PromptOwl license on this server to issue invitations."
|
|
1012
|
+
},
|
|
1013
|
+
403
|
|
1014
|
+
);
|
|
448
1015
|
}
|
|
449
1016
|
let user = db.prepare("SELECT id, email FROM users WHERE email = ?").get(body.email);
|
|
450
1017
|
if (!user) {
|
|
451
1018
|
const userId = uuid();
|
|
452
1019
|
const placeholderHash = await hashPassword(uuid());
|
|
453
1020
|
db.prepare(
|
|
454
|
-
"INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?,
|
|
1021
|
+
"INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)"
|
|
455
1022
|
).run(userId, body.email, null, placeholderHash);
|
|
456
1023
|
user = { id: userId, email: body.email };
|
|
457
1024
|
}
|
|
458
1025
|
const apiKey = generateApiKey();
|
|
459
1026
|
const keyId = uuid();
|
|
460
|
-
db.
|
|
461
|
-
"
|
|
462
|
-
|
|
463
|
-
|
|
1027
|
+
db.transaction(() => {
|
|
1028
|
+
db.prepare("DELETE FROM api_keys WHERE user_id = ?").run(user.id);
|
|
1029
|
+
db.prepare(
|
|
1030
|
+
"INSERT INTO api_keys (id, user_id, key_hash, key_prefix, label) VALUES (?, ?, ?, ?, ?)"
|
|
1031
|
+
).run(
|
|
1032
|
+
keyId,
|
|
1033
|
+
user.id,
|
|
1034
|
+
hashApiKey(apiKey),
|
|
1035
|
+
getKeyPrefix(apiKey),
|
|
1036
|
+
body.label || "teammate"
|
|
1037
|
+
);
|
|
1038
|
+
})();
|
|
1039
|
+
trackEvent("admin.invite", { adminId: callerId, email: body.email });
|
|
464
1040
|
return c.json(
|
|
465
1041
|
{
|
|
466
1042
|
api_key: apiKey,
|
|
@@ -471,28 +1047,32 @@ authRoutes.post("/invite", async (c) => {
|
|
|
471
1047
|
);
|
|
472
1048
|
});
|
|
473
1049
|
authRoutes.get("/teammates", async (c) => {
|
|
474
|
-
const
|
|
475
|
-
if (!
|
|
476
|
-
return c.json(
|
|
1050
|
+
const callerId = resolveCallerUserId(c);
|
|
1051
|
+
if (!callerId) {
|
|
1052
|
+
return c.json(
|
|
1053
|
+
{
|
|
1054
|
+
error: "Authentication required. Sign in with the admin account that owns the installed PromptOwl license to view teammates."
|
|
1055
|
+
},
|
|
1056
|
+
401
|
|
1057
|
+
);
|
|
477
1058
|
}
|
|
478
1059
|
const db = getDb();
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
return c.json({ error: "Admin only" }, 403);
|
|
1060
|
+
if (!isLicenseAdminUserId(callerId)) {
|
|
1061
|
+
return c.json(
|
|
1062
|
+
{
|
|
1063
|
+
error: "Only the license-admin user can view the teammates list. Contact the admin who installed the PromptOwl license on this server."
|
|
1064
|
+
},
|
|
1065
|
+
403
|
|
1066
|
+
);
|
|
487
1067
|
}
|
|
488
1068
|
const teammates = db.prepare(
|
|
489
|
-
`SELECT u.id, u.email, u.name, u.
|
|
1069
|
+
`SELECT u.id, u.email, u.name, u.is_invited,
|
|
490
1070
|
(SELECT COUNT(*) FROM api_keys WHERE user_id = u.id) as key_count,
|
|
491
1071
|
(SELECT MAX(last_used_at) FROM api_keys WHERE user_id = u.id) as last_active
|
|
492
1072
|
FROM users u
|
|
493
|
-
WHERE u.id !=
|
|
494
|
-
ORDER BY u.
|
|
495
|
-
).all();
|
|
1073
|
+
WHERE u.id != ?
|
|
1074
|
+
ORDER BY u.created_at DESC`
|
|
1075
|
+
).all(ANON_USER_ID);
|
|
496
1076
|
const pendingStewards = db.prepare(
|
|
497
1077
|
`SELECT DISTINCT s.user_email AS email
|
|
498
1078
|
FROM stewards s
|
|
@@ -504,11 +1084,9 @@ authRoutes.get("/teammates", async (c) => {
|
|
|
504
1084
|
)
|
|
505
1085
|
ORDER BY s.user_email`
|
|
506
1086
|
).all();
|
|
1087
|
+
const enriched = teammates.map((t) => ({ ...t, is_admin: isLicenseAdminEmail(t.email) })).sort((a, b) => Number(b.is_admin) - Number(a.is_admin));
|
|
507
1088
|
return c.json({
|
|
508
|
-
teammates:
|
|
509
|
-
...t,
|
|
510
|
-
is_admin: !!t.is_admin
|
|
511
|
-
})),
|
|
1089
|
+
teammates: enriched,
|
|
512
1090
|
pending_stewards: pendingStewards.map((p) => p.email)
|
|
513
1091
|
});
|
|
514
1092
|
});
|
|
@@ -529,6 +1107,9 @@ function resolveNestPermission(nestId, userId) {
|
|
|
529
1107
|
const nest = db.prepare("SELECT user_id, visibility FROM nests WHERE id = ?").get(nestId);
|
|
530
1108
|
if (!nest) return "none";
|
|
531
1109
|
if (nest.user_id === userId) return "owner";
|
|
1110
|
+
if (nest.user_id === ANON_USER_ID && isLicenseAdminUserId(userId)) {
|
|
1111
|
+
return "owner";
|
|
1112
|
+
}
|
|
532
1113
|
const directGrant = db.prepare(
|
|
533
1114
|
"SELECT permission FROM nest_collaborators WHERE nest_id = ? AND user_id = ?"
|
|
534
1115
|
).get(nestId, userId);
|
|
@@ -567,9 +1148,10 @@ async function createNest(userId, name, description) {
|
|
|
567
1148
|
const id = uuid2();
|
|
568
1149
|
const slug = toSlug(name);
|
|
569
1150
|
const db = getDb();
|
|
1151
|
+
const visibility = userId === ANON_USER_ID ? "public" : "private";
|
|
570
1152
|
db.prepare(
|
|
571
|
-
"INSERT INTO nests (id, user_id, name, slug, description) VALUES (?, ?, ?, ?, ?)"
|
|
572
|
-
).run(id, userId, name, slug, description || null);
|
|
1153
|
+
"INSERT INTO nests (id, user_id, name, slug, description, visibility) VALUES (?, ?, ?, ?, ?, ?)"
|
|
1154
|
+
).run(id, userId, name, slug, description || null, visibility);
|
|
573
1155
|
const path = nestPath(id);
|
|
574
1156
|
mkdirSync(path, { recursive: true });
|
|
575
1157
|
const storage = new NestStorage(path);
|
|
@@ -577,14 +1159,17 @@ async function createNest(userId, name, description) {
|
|
|
577
1159
|
trackEvent("nest.create", { nestId: id, userId });
|
|
578
1160
|
return db.prepare("SELECT * FROM nests WHERE id = ?").get(id);
|
|
579
1161
|
}
|
|
580
|
-
var ANON_USER_ID = "00000000-0000-0000-0000-000000000000";
|
|
581
1162
|
function listNests(userId) {
|
|
582
1163
|
const db = getDb();
|
|
583
1164
|
if (userId === ANON_USER_ID) {
|
|
584
1165
|
return db.prepare("SELECT * FROM nests WHERE user_id = ? ORDER BY created_at DESC").all(ANON_USER_ID);
|
|
585
1166
|
}
|
|
1167
|
+
const includeAnon = config.AUTH_MODE === "open" || isLicenseAdminUserId(userId);
|
|
1168
|
+
if (!includeAnon) {
|
|
1169
|
+
return db.prepare("SELECT * FROM nests WHERE user_id = ? ORDER BY created_at DESC").all(userId);
|
|
1170
|
+
}
|
|
586
1171
|
return db.prepare(
|
|
587
|
-
"SELECT * FROM nests WHERE user_id = ? OR user_id = ? ORDER BY created_at DESC"
|
|
1172
|
+
"SELECT * FROM nests WHERE user_id = ? OR (user_id = ? AND visibility = 'public') ORDER BY created_at DESC"
|
|
588
1173
|
).all(userId, ANON_USER_ID);
|
|
589
1174
|
}
|
|
590
1175
|
function listSharedNests(userId) {
|
|
@@ -602,23 +1187,35 @@ function getNest(nestId) {
|
|
|
602
1187
|
}
|
|
603
1188
|
async function deleteNest(nestId) {
|
|
604
1189
|
const db = getDb();
|
|
605
|
-
|
|
606
|
-
|
|
1190
|
+
const wipe = db.transaction((id) => {
|
|
1191
|
+
db.prepare("DELETE FROM approved_versions WHERE nest_id = ?").run(id);
|
|
1192
|
+
db.prepare("DELETE FROM node_versions WHERE nest_id = ?").run(id);
|
|
1193
|
+
db.prepare("DELETE FROM review_requests WHERE nest_id = ?").run(id);
|
|
1194
|
+
db.prepare("DELETE FROM stewards WHERE nest_id = ?").run(id);
|
|
1195
|
+
db.prepare("DELETE FROM nest_collaborators WHERE nest_id = ?").run(id);
|
|
1196
|
+
try {
|
|
1197
|
+
db.prepare("DELETE FROM node_tag_index WHERE nest_id = ?").run(id);
|
|
1198
|
+
} catch {
|
|
1199
|
+
}
|
|
1200
|
+
db.prepare("DELETE FROM api_keys WHERE nest_id = ?").run(id);
|
|
1201
|
+
db.prepare("DELETE FROM nests WHERE id = ?").run(id);
|
|
1202
|
+
});
|
|
1203
|
+
wipe(nestId);
|
|
607
1204
|
const path = nestPath(nestId);
|
|
608
1205
|
try {
|
|
609
1206
|
rmSync(path, { recursive: true, force: true });
|
|
610
|
-
} catch {
|
|
1207
|
+
} catch (err) {
|
|
1208
|
+
console.warn(`[nests] failed to remove nest directory ${path}:`, err);
|
|
611
1209
|
}
|
|
612
1210
|
trackEvent("nest.delete", { nestId });
|
|
613
1211
|
}
|
|
614
1212
|
|
|
615
1213
|
// src/nests/routes.ts
|
|
616
|
-
var ANON_USER_ID2 = "00000000-0000-0000-0000-000000000000";
|
|
617
1214
|
function effectivePermission(nestId, userId) {
|
|
618
1215
|
if (config.AUTH_MODE === "open") {
|
|
619
1216
|
const db = getDb();
|
|
620
1217
|
const nest = db.prepare("SELECT user_id FROM nests WHERE id = ?").get(nestId);
|
|
621
|
-
if (nest && nest.user_id ===
|
|
1218
|
+
if (nest && nest.user_id === ANON_USER_ID) return "owner";
|
|
622
1219
|
}
|
|
623
1220
|
return resolveNestPermission(nestId, userId);
|
|
624
1221
|
}
|
|
@@ -627,7 +1224,23 @@ nestRoutes.get("/", async (c) => {
|
|
|
627
1224
|
const userId = c.get("userId");
|
|
628
1225
|
const owned = listNests(userId);
|
|
629
1226
|
const shared = listSharedNests(userId);
|
|
630
|
-
|
|
1227
|
+
const db = getDb();
|
|
1228
|
+
const ownerEmailStmt = db.prepare(
|
|
1229
|
+
"SELECT email FROM users WHERE id = ?"
|
|
1230
|
+
);
|
|
1231
|
+
const annotate = (n) => {
|
|
1232
|
+
const permission = effectivePermission(n.id, userId);
|
|
1233
|
+
const is_owner = permission === "owner";
|
|
1234
|
+
let owner_email = null;
|
|
1235
|
+
if (!is_owner && n.user_id !== ANON_USER_ID) {
|
|
1236
|
+
const row = ownerEmailStmt.get(n.user_id);
|
|
1237
|
+
owner_email = row?.email ?? null;
|
|
1238
|
+
}
|
|
1239
|
+
return { ...n, permission, is_owner, owner_email };
|
|
1240
|
+
};
|
|
1241
|
+
return c.json({
|
|
1242
|
+
nests: [...owned.map(annotate), ...shared.map(annotate)]
|
|
1243
|
+
});
|
|
631
1244
|
});
|
|
632
1245
|
nestRoutes.post("/", async (c) => {
|
|
633
1246
|
const body = await c.req.json();
|
|
@@ -648,10 +1261,19 @@ nestRoutes.get("/:nestId", async (c) => {
|
|
|
648
1261
|
});
|
|
649
1262
|
nestRoutes.delete("/:nestId", async (c) => {
|
|
650
1263
|
const nestId = c.req.param("nestId");
|
|
651
|
-
const
|
|
652
|
-
|
|
1264
|
+
const userId = c.get("userId");
|
|
1265
|
+
const nest = getNest(nestId);
|
|
1266
|
+
if (!nest) {
|
|
653
1267
|
throw new NotFoundError("Nest not found");
|
|
654
1268
|
}
|
|
1269
|
+
const permission = effectivePermission(nestId, userId);
|
|
1270
|
+
const isAnonOwned = nest.user_id === ANON_USER_ID;
|
|
1271
|
+
const adminCaretaker = config.AUTH_MODE !== "open" && isAnonOwned && isLicenseAdminUserId(userId);
|
|
1272
|
+
if (permission !== "owner" && !adminCaretaker) {
|
|
1273
|
+
throw new ForbiddenError(
|
|
1274
|
+
"You don't have permission to delete this nest. Only the nest owner can delete it."
|
|
1275
|
+
);
|
|
1276
|
+
}
|
|
655
1277
|
await deleteNest(nestId);
|
|
656
1278
|
return c.json({ deleted: true });
|
|
657
1279
|
});
|
|
@@ -668,12 +1290,15 @@ nestRoutes.get("/:nestId/settings", async (c) => {
|
|
|
668
1290
|
nestRoutes.patch("/:nestId/settings", async (c) => {
|
|
669
1291
|
const nestId = c.req.param("nestId");
|
|
670
1292
|
const userId = c.get("userId");
|
|
671
|
-
const
|
|
672
|
-
const userRow = db.prepare("SELECT is_admin FROM users WHERE id = ?").get(userId);
|
|
673
|
-
const isServerAdmin = !!userRow?.is_admin;
|
|
1293
|
+
const isServerAdmin = isLicenseAdminUserId(userId);
|
|
674
1294
|
const permission = effectivePermission(nestId, userId);
|
|
675
1295
|
if (!isServerAdmin && permission !== "owner") {
|
|
676
|
-
return c.json(
|
|
1296
|
+
return c.json(
|
|
1297
|
+
{
|
|
1298
|
+
error: "Only the nest owner or the server license-admin can update nest settings."
|
|
1299
|
+
},
|
|
1300
|
+
403
|
|
1301
|
+
);
|
|
677
1302
|
}
|
|
678
1303
|
const body = await c.req.json();
|
|
679
1304
|
if (typeof body.stewardship_enabled === "boolean") {
|
|
@@ -711,7 +1336,7 @@ sharingRoutes.post("/collaborators", async (c) => {
|
|
|
711
1336
|
const { hashPassword: hashPassword2 } = await import("./keys-YV33AJK3.js");
|
|
712
1337
|
userId = uuid3();
|
|
713
1338
|
db.prepare(
|
|
714
|
-
"INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?,
|
|
1339
|
+
"INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)"
|
|
715
1340
|
).run(userId, body.email, null, await hashPassword2(uuid3()));
|
|
716
1341
|
} else {
|
|
717
1342
|
userId = user.id;
|
|
@@ -765,32 +1390,26 @@ sharingRoutes.patch("/visibility", async (c) => {
|
|
|
765
1390
|
return c.json({ visibility: body.visibility });
|
|
766
1391
|
});
|
|
767
1392
|
|
|
1393
|
+
// src/governance/access-guard.ts
|
|
1394
|
+
function resolveCallerEmail(userId) {
|
|
1395
|
+
if (!userId) return "admin@localhost";
|
|
1396
|
+
const db = getDb();
|
|
1397
|
+
const row = db.prepare("SELECT email FROM users WHERE id = ?").get(userId);
|
|
1398
|
+
return row?.email || "admin@localhost";
|
|
1399
|
+
}
|
|
1400
|
+
function canReadNode(nestId, nodeId, userEmail) {
|
|
1401
|
+
if (!isStewardshipEnabled(nestId)) return true;
|
|
1402
|
+
return canUserAccess(nestId, nodeId, userEmail).allowed;
|
|
1403
|
+
}
|
|
1404
|
+
function filterAccessible(nestId, userEmail, nodes) {
|
|
1405
|
+
if (!isStewardshipEnabled(nestId)) return nodes;
|
|
1406
|
+
return nodes.filter((n) => canUserAccess(nestId, n.id, userEmail).allowed);
|
|
1407
|
+
}
|
|
1408
|
+
|
|
768
1409
|
// src/nodes/routes.ts
|
|
769
1410
|
import { Hono as Hono4 } from "hono";
|
|
770
1411
|
import { serializeDocument } from "@promptowl/contextnest-engine";
|
|
771
1412
|
|
|
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
1413
|
// src/governance/tag-index-service.ts
|
|
795
1414
|
function normalizeTag(raw) {
|
|
796
1415
|
return raw.trim().replace(/^#+/, "").toLowerCase();
|
|
@@ -821,20 +1440,220 @@ function removeNodeFromTagIndex(nestId, nodeId) {
|
|
|
821
1440
|
).run(nestId, nodeId);
|
|
822
1441
|
}
|
|
823
1442
|
|
|
824
|
-
// src/governance/
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
1443
|
+
// src/governance/external-edit-service.ts
|
|
1444
|
+
import { readFile } from "fs/promises";
|
|
1445
|
+
import { join as join2 } from "path";
|
|
1446
|
+
import {
|
|
1447
|
+
detectDrift,
|
|
1448
|
+
stageSuggestion,
|
|
1449
|
+
approveSuggestion,
|
|
1450
|
+
rejectSuggestion,
|
|
1451
|
+
listSuggestions,
|
|
1452
|
+
readSuggestion,
|
|
1453
|
+
VersionManager
|
|
1454
|
+
} from "@promptowl/contextnest-engine";
|
|
1455
|
+
var communityRbac = {
|
|
1456
|
+
isCzar: () => false,
|
|
1457
|
+
canIngest: () => true,
|
|
1458
|
+
isDocOwner: () => true
|
|
1459
|
+
};
|
|
1460
|
+
function docPath(nestId, documentId) {
|
|
1461
|
+
return join2(config.DATA_ROOT, "nests", nestId, `${documentId}.md`);
|
|
830
1462
|
}
|
|
831
|
-
function
|
|
832
|
-
|
|
833
|
-
|
|
1463
|
+
async function readRaw(nestId, documentId) {
|
|
1464
|
+
try {
|
|
1465
|
+
return await readFile(docPath(nestId, documentId), "utf-8");
|
|
1466
|
+
} catch {
|
|
1467
|
+
return null;
|
|
1468
|
+
}
|
|
834
1469
|
}
|
|
835
|
-
function
|
|
836
|
-
|
|
837
|
-
|
|
1470
|
+
async function loadChainHead(storage, documentId) {
|
|
1471
|
+
const history = await storage.readHistory(documentId);
|
|
1472
|
+
if (!history || history.versions.length === 0) return null;
|
|
1473
|
+
const latest = history.versions[history.versions.length - 1];
|
|
1474
|
+
try {
|
|
1475
|
+
const content = await new VersionManager(storage).reconstructVersion(
|
|
1476
|
+
documentId,
|
|
1477
|
+
latest.version
|
|
1478
|
+
);
|
|
1479
|
+
return { version: latest.version, content };
|
|
1480
|
+
} catch {
|
|
1481
|
+
return null;
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
async function scanDocumentForDrift(nestId, documentId, actor = "system:scanner") {
|
|
1485
|
+
const res = await scanDocumentForDriftInternal(nestId, documentId, actor);
|
|
1486
|
+
return res?.meta ?? null;
|
|
1487
|
+
}
|
|
1488
|
+
async function scanDocumentForDriftInternal(nestId, documentId, actor) {
|
|
1489
|
+
const { storage } = engineCache.get(nestId);
|
|
1490
|
+
const node = await storage.readDocument(documentId).catch(() => null);
|
|
1491
|
+
if (!node) return null;
|
|
1492
|
+
const raw = await readRaw(nestId, documentId);
|
|
1493
|
+
if (raw == null) return null;
|
|
1494
|
+
const drift = detectDrift(raw, node.frontmatter.checksum);
|
|
1495
|
+
if (!drift.drifted) return null;
|
|
1496
|
+
const approved = await loadChainHead(storage, documentId);
|
|
1497
|
+
if (!approved) return null;
|
|
1498
|
+
const existing = await listSuggestions(storage, documentId);
|
|
1499
|
+
const dup = existing.find((s) => s.proposed_hash === drift.actualHash);
|
|
1500
|
+
if (dup) return { meta: dup, created: false };
|
|
1501
|
+
const result = await stageSuggestion({
|
|
1502
|
+
storage,
|
|
1503
|
+
documentId,
|
|
1504
|
+
approvedRawContent: approved.content,
|
|
1505
|
+
proposedRawContent: raw,
|
|
1506
|
+
source: "out-of-band-edit",
|
|
1507
|
+
actor,
|
|
1508
|
+
docTier: "standard"
|
|
1509
|
+
});
|
|
1510
|
+
return { meta: result.meta, created: true };
|
|
1511
|
+
}
|
|
1512
|
+
async function scanNestForDrift(nestId, actor = "system:scanner") {
|
|
1513
|
+
const { storage } = engineCache.get(nestId);
|
|
1514
|
+
const docs = await storage.discoverDocuments();
|
|
1515
|
+
const results = await Promise.all(
|
|
1516
|
+
docs.map((doc) => scanDocumentForDriftInternal(nestId, doc.id, actor))
|
|
1517
|
+
);
|
|
1518
|
+
const staged = results.filter((r) => r?.created).length;
|
|
1519
|
+
return { scanned: docs.length, staged };
|
|
1520
|
+
}
|
|
1521
|
+
async function getPendingChange(nestId, documentId) {
|
|
1522
|
+
const { storage } = engineCache.get(nestId);
|
|
1523
|
+
const list = await listSuggestions(storage, documentId);
|
|
1524
|
+
if (list.length === 0) return null;
|
|
1525
|
+
const latest = list[list.length - 1];
|
|
1526
|
+
return {
|
|
1527
|
+
suggestion_id: latest.suggestion_id,
|
|
1528
|
+
detected_at: latest.detected_at,
|
|
1529
|
+
source: latest.source,
|
|
1530
|
+
proposed_hash: latest.proposed_hash
|
|
1531
|
+
};
|
|
1532
|
+
}
|
|
1533
|
+
async function listNestExternalEdits(nestId) {
|
|
1534
|
+
const { storage } = engineCache.get(nestId);
|
|
1535
|
+
const docs = await storage.discoverDocuments();
|
|
1536
|
+
const lists = await Promise.all(
|
|
1537
|
+
docs.map((doc) => listSuggestions(storage, doc.id))
|
|
1538
|
+
);
|
|
1539
|
+
const entries = lists.flat().map((meta) => ({
|
|
1540
|
+
suggestion_id: meta.suggestion_id,
|
|
1541
|
+
nest_id: nestId,
|
|
1542
|
+
document_id: meta.document_id,
|
|
1543
|
+
source: meta.source,
|
|
1544
|
+
detected_at: meta.detected_at,
|
|
1545
|
+
actor: meta.actor,
|
|
1546
|
+
target_hash: meta.target_hash,
|
|
1547
|
+
proposed_hash: meta.proposed_hash,
|
|
1548
|
+
note: meta.note
|
|
1549
|
+
}));
|
|
1550
|
+
return entries.sort((a, b) => b.detected_at.localeCompare(a.detected_at));
|
|
1551
|
+
}
|
|
1552
|
+
async function getExternalEditDetail(nestId, documentId, suggestionId) {
|
|
1553
|
+
const { storage } = engineCache.get(nestId);
|
|
1554
|
+
const found = await readSuggestion(storage, documentId, suggestionId);
|
|
1555
|
+
if (!found) return null;
|
|
1556
|
+
return {
|
|
1557
|
+
suggestion_id: found.meta.suggestion_id,
|
|
1558
|
+
nest_id: nestId,
|
|
1559
|
+
document_id: found.meta.document_id,
|
|
1560
|
+
source: found.meta.source,
|
|
1561
|
+
detected_at: found.meta.detected_at,
|
|
1562
|
+
actor: found.meta.actor,
|
|
1563
|
+
target_hash: found.meta.target_hash,
|
|
1564
|
+
proposed_hash: found.meta.proposed_hash,
|
|
1565
|
+
note: found.meta.note,
|
|
1566
|
+
patch: found.patch
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
1569
|
+
async function approveExternalEdit(input) {
|
|
1570
|
+
const { storage } = engineCache.get(input.nestId);
|
|
1571
|
+
let result;
|
|
1572
|
+
try {
|
|
1573
|
+
result = await approveSuggestion({
|
|
1574
|
+
storage,
|
|
1575
|
+
rbac: communityRbac,
|
|
1576
|
+
documentId: input.documentId,
|
|
1577
|
+
suggestionId: input.suggestionId,
|
|
1578
|
+
actor: input.actor,
|
|
1579
|
+
zone: "default",
|
|
1580
|
+
comment: input.comment
|
|
1581
|
+
});
|
|
1582
|
+
} catch (err) {
|
|
1583
|
+
console.error(
|
|
1584
|
+
`[external-edit] approveSuggestion failed for ${input.nestId}/${input.documentId} suggestion=${input.suggestionId}:`,
|
|
1585
|
+
err
|
|
1586
|
+
);
|
|
1587
|
+
throw err;
|
|
1588
|
+
}
|
|
1589
|
+
try {
|
|
1590
|
+
const node = await storage.readDocument(input.documentId);
|
|
1591
|
+
const versionNum = result.versionEntry.version;
|
|
1592
|
+
const tags = node.frontmatter.tags || [];
|
|
1593
|
+
const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-TFEYNPH7.js");
|
|
1594
|
+
createVersion2({
|
|
1595
|
+
nestId: input.nestId,
|
|
1596
|
+
nodeId: input.documentId,
|
|
1597
|
+
version: versionNum,
|
|
1598
|
+
content: node.body || "",
|
|
1599
|
+
author: input.actor,
|
|
1600
|
+
status: "published",
|
|
1601
|
+
tags,
|
|
1602
|
+
changeNote: input.comment || "External edit approved"
|
|
1603
|
+
});
|
|
1604
|
+
setApprovedVersion2(input.nestId, input.documentId, versionNum, input.actor);
|
|
1605
|
+
} catch (err) {
|
|
1606
|
+
console.error(
|
|
1607
|
+
`[external-edit] failed to mirror approved version into node_versions for ${input.nestId}/${input.documentId}:`,
|
|
1608
|
+
err
|
|
1609
|
+
);
|
|
1610
|
+
}
|
|
1611
|
+
return result;
|
|
1612
|
+
}
|
|
1613
|
+
async function rejectExternalEdit(input) {
|
|
1614
|
+
const { storage } = engineCache.get(input.nestId);
|
|
1615
|
+
const result = await rejectSuggestion({
|
|
1616
|
+
storage,
|
|
1617
|
+
rbac: communityRbac,
|
|
1618
|
+
documentId: input.documentId,
|
|
1619
|
+
suggestionId: input.suggestionId,
|
|
1620
|
+
actor: input.actor,
|
|
1621
|
+
zone: "default",
|
|
1622
|
+
reason: input.reason
|
|
1623
|
+
});
|
|
1624
|
+
try {
|
|
1625
|
+
const approved = await loadChainHead(storage, input.documentId);
|
|
1626
|
+
if (approved) {
|
|
1627
|
+
await storage.writeDocument(input.documentId, approved.content);
|
|
1628
|
+
}
|
|
1629
|
+
} catch (err) {
|
|
1630
|
+
console.error(
|
|
1631
|
+
`[external-edit] revert-on-reject failed for ${input.nestId}/${input.documentId}:`,
|
|
1632
|
+
err
|
|
1633
|
+
);
|
|
1634
|
+
}
|
|
1635
|
+
return result;
|
|
1636
|
+
}
|
|
1637
|
+
var scannerTimer = null;
|
|
1638
|
+
async function scanAllNests() {
|
|
1639
|
+
const db = getDb();
|
|
1640
|
+
const rows = db.prepare("SELECT id FROM nests").all();
|
|
1641
|
+
await Promise.all(
|
|
1642
|
+
rows.map(
|
|
1643
|
+
({ id }) => scanNestForDrift(id).catch(
|
|
1644
|
+
(err) => console.error(`[external-edit] scan failed for nest ${id}:`, err)
|
|
1645
|
+
)
|
|
1646
|
+
)
|
|
1647
|
+
);
|
|
1648
|
+
}
|
|
1649
|
+
function startDriftScanner(intervalMs = 3e4) {
|
|
1650
|
+
if (scannerTimer) return;
|
|
1651
|
+
scannerTimer = setInterval(() => {
|
|
1652
|
+
scanAllNests().catch(
|
|
1653
|
+
(err) => console.error("[external-edit] scanner tick failed:", err)
|
|
1654
|
+
);
|
|
1655
|
+
}, intervalMs);
|
|
1656
|
+
scannerTimer.unref?.();
|
|
838
1657
|
}
|
|
839
1658
|
|
|
840
1659
|
// src/nodes/routes.ts
|
|
@@ -845,13 +1664,17 @@ function toNodeResponse(node) {
|
|
|
845
1664
|
title: node.frontmatter.title,
|
|
846
1665
|
type: node.frontmatter.type || "document",
|
|
847
1666
|
tags: node.frontmatter.tags || [],
|
|
1667
|
+
// Widen to string so callers can layer review-workflow states like
|
|
1668
|
+
// "pending_review" / "rejected" on top of the on-disk frontmatter
|
|
1669
|
+
// status. The engine's Status enum only knows draft/approved.
|
|
848
1670
|
status: node.frontmatter.status || "draft",
|
|
849
1671
|
version: node.frontmatter.version || 1,
|
|
850
1672
|
author: node.frontmatter.author,
|
|
851
1673
|
description: node.frontmatter.description,
|
|
852
1674
|
created_at: node.frontmatter.created_at,
|
|
853
1675
|
updated_at: node.frontmatter.updated_at,
|
|
854
|
-
content: node.body
|
|
1676
|
+
content: node.body,
|
|
1677
|
+
pendingChange: node.pendingChange ?? void 0
|
|
855
1678
|
};
|
|
856
1679
|
}
|
|
857
1680
|
nodeRoutes.get("/", async (c) => {
|
|
@@ -860,9 +1683,29 @@ nodeRoutes.get("/", async (c) => {
|
|
|
860
1683
|
const documents = await storage.discoverDocuments();
|
|
861
1684
|
const userEmail = resolveCallerEmail(c.get("userId"));
|
|
862
1685
|
const accessible = filterAccessible(nestId, userEmail, documents);
|
|
1686
|
+
const pendingByDoc = /* @__PURE__ */ new Map();
|
|
1687
|
+
await Promise.all(
|
|
1688
|
+
accessible.map(async (doc) => {
|
|
1689
|
+
try {
|
|
1690
|
+
pendingByDoc.set(doc.id, await getPendingChange(nestId, doc.id));
|
|
1691
|
+
} catch {
|
|
1692
|
+
pendingByDoc.set(doc.id, null);
|
|
1693
|
+
}
|
|
1694
|
+
})
|
|
1695
|
+
);
|
|
863
1696
|
return c.json({
|
|
864
1697
|
count: accessible.length,
|
|
865
|
-
nodes: accessible.map(
|
|
1698
|
+
nodes: accessible.map((doc) => {
|
|
1699
|
+
const r = toNodeResponse(doc);
|
|
1700
|
+
const pending = pendingByDoc.get(doc.id);
|
|
1701
|
+
if (pending) {
|
|
1702
|
+
r.pendingChange = pending;
|
|
1703
|
+
r.status = "external_edit_pending";
|
|
1704
|
+
} else {
|
|
1705
|
+
r.status = getDisplayStatus(nestId, r.id);
|
|
1706
|
+
}
|
|
1707
|
+
return r;
|
|
1708
|
+
})
|
|
866
1709
|
});
|
|
867
1710
|
});
|
|
868
1711
|
nodeRoutes.post("/", async (c) => {
|
|
@@ -871,14 +1714,15 @@ nodeRoutes.post("/", async (c) => {
|
|
|
871
1714
|
throw new ValidationError("title and content are required");
|
|
872
1715
|
}
|
|
873
1716
|
const nestId = c.req.param("nestId");
|
|
874
|
-
const { storage } = engineCache.get(nestId);
|
|
1717
|
+
const { storage, versions: versionManager } = engineCache.get(nestId);
|
|
875
1718
|
const slug = body.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
876
1719
|
const id = `nodes/${slug}`;
|
|
877
1720
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
878
1721
|
const tags = body.tags?.map((t) => t.startsWith("#") ? t : `#${t}`) || [];
|
|
879
1722
|
const hasStewards = isStewardshipEnabled(nestId);
|
|
880
1723
|
const initialStatus = hasStewards ? "draft" : "approved";
|
|
881
|
-
const
|
|
1724
|
+
const initialVersion = hasStewards ? 1 : 0;
|
|
1725
|
+
let node = {
|
|
882
1726
|
id,
|
|
883
1727
|
filePath: "",
|
|
884
1728
|
frontmatter: {
|
|
@@ -886,7 +1730,7 @@ nodeRoutes.post("/", async (c) => {
|
|
|
886
1730
|
type: body.type || "document",
|
|
887
1731
|
tags,
|
|
888
1732
|
status: body.status || initialStatus,
|
|
889
|
-
version:
|
|
1733
|
+
version: initialVersion,
|
|
890
1734
|
created_at: now,
|
|
891
1735
|
updated_at: now,
|
|
892
1736
|
metadata: {
|
|
@@ -901,17 +1745,51 @@ nodeRoutes.post("/", async (c) => {
|
|
|
901
1745
|
await storage.writeDocument(id, serialized);
|
|
902
1746
|
syncNodeTags(nestId, id, tags);
|
|
903
1747
|
const authorEmail = getUserEmail(c);
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
1748
|
+
if (hasStewards) {
|
|
1749
|
+
try {
|
|
1750
|
+
await versionManager.createVersion(node, authorEmail);
|
|
1751
|
+
} catch (err) {
|
|
1752
|
+
console.error("VersionManager.createVersion failed (node create)", err);
|
|
1753
|
+
}
|
|
1754
|
+
createVersion({
|
|
1755
|
+
nestId,
|
|
1756
|
+
nodeId: id,
|
|
1757
|
+
version: 1,
|
|
1758
|
+
content: body.content,
|
|
1759
|
+
author: authorEmail,
|
|
1760
|
+
status: "draft",
|
|
1761
|
+
tags
|
|
1762
|
+
});
|
|
1763
|
+
} else {
|
|
1764
|
+
try {
|
|
1765
|
+
const result = await safePublishDocument(storage, id, {
|
|
1766
|
+
editedBy: authorEmail,
|
|
1767
|
+
note: "Auto-published on create (no stewards configured)"
|
|
1768
|
+
});
|
|
1769
|
+
const publishedVersion = result.node.frontmatter.version || 2;
|
|
1770
|
+
createVersion({
|
|
1771
|
+
nestId,
|
|
1772
|
+
nodeId: id,
|
|
1773
|
+
version: publishedVersion,
|
|
1774
|
+
content: result.node.body || "",
|
|
1775
|
+
author: authorEmail,
|
|
1776
|
+
status: "published",
|
|
1777
|
+
tags
|
|
1778
|
+
});
|
|
1779
|
+
setApprovedVersion(nestId, id, publishedVersion, authorEmail);
|
|
1780
|
+
node = result.node;
|
|
1781
|
+
} catch (err) {
|
|
1782
|
+
console.error("publishDocument failed (node create auto-publish)", err);
|
|
1783
|
+
createVersion({
|
|
1784
|
+
nestId,
|
|
1785
|
+
nodeId: id,
|
|
1786
|
+
version: 1,
|
|
1787
|
+
content: body.content,
|
|
1788
|
+
author: authorEmail,
|
|
1789
|
+
status: "draft",
|
|
1790
|
+
tags
|
|
1791
|
+
});
|
|
1792
|
+
}
|
|
915
1793
|
}
|
|
916
1794
|
trackEvent("node.create", { nestId, nodeId: id });
|
|
917
1795
|
const resolved = resolveStewardsForNode(nestId, id);
|
|
@@ -927,7 +1805,7 @@ nodeRoutes.post("/", async (c) => {
|
|
|
927
1805
|
nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
|
|
928
1806
|
const nestId = c.req.param("nestId");
|
|
929
1807
|
const nodeId = c.req.param("nodeId");
|
|
930
|
-
const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-
|
|
1808
|
+
const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-C5D2O7ZE.js");
|
|
931
1809
|
const { stewards, fallbackToOwner, ownerEmail } = resolveStewardsWithFallback2(
|
|
932
1810
|
nestId,
|
|
933
1811
|
nodeId
|
|
@@ -939,8 +1817,7 @@ nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
|
|
|
939
1817
|
role: r.steward.role,
|
|
940
1818
|
scope: r.steward.scope,
|
|
941
1819
|
source: r.source,
|
|
942
|
-
priority: r.priority
|
|
943
|
-
canApprove: r.steward.canApprove
|
|
1820
|
+
priority: r.priority
|
|
944
1821
|
})),
|
|
945
1822
|
fallbackToOwner,
|
|
946
1823
|
ownerEmail
|
|
@@ -949,11 +1826,30 @@ nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
|
|
|
949
1826
|
nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
|
|
950
1827
|
const nestId = c.req.param("nestId");
|
|
951
1828
|
const nodeId = c.req.param("nodeId");
|
|
952
|
-
const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-
|
|
1829
|
+
const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-TFEYNPH7.js");
|
|
953
1830
|
const allVersions = getVersions2(nestId, nodeId);
|
|
954
1831
|
const approved = getApprovedVersion2(nestId, nodeId);
|
|
1832
|
+
const db = getDb();
|
|
1833
|
+
const resolutions = db.prepare(
|
|
1834
|
+
`SELECT version, status, resolved_by, resolved_at
|
|
1835
|
+
FROM review_requests
|
|
1836
|
+
WHERE nest_id = ? AND node_id = ?
|
|
1837
|
+
AND status IN ('approved', 'rejected')
|
|
1838
|
+
AND resolved_by IS NOT NULL
|
|
1839
|
+
ORDER BY resolved_at DESC`
|
|
1840
|
+
).all(nestId, nodeId);
|
|
1841
|
+
const byVersion = /* @__PURE__ */ new Map();
|
|
1842
|
+
for (const r of resolutions) {
|
|
1843
|
+
if (!byVersion.has(r.version)) {
|
|
1844
|
+
byVersion.set(r.version, { status: r.status, resolvedBy: r.resolved_by });
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
const enriched = allVersions.map((v) => {
|
|
1848
|
+
const r = byVersion.get(v.version);
|
|
1849
|
+
return r ? { ...v, resolvedBy: r.resolvedBy, resolutionStatus: r.status } : v;
|
|
1850
|
+
});
|
|
955
1851
|
return c.json({
|
|
956
|
-
versions:
|
|
1852
|
+
versions: enriched,
|
|
957
1853
|
approvedVersion: approved,
|
|
958
1854
|
currentVersion: allVersions[0]?.version || 0
|
|
959
1855
|
});
|
|
@@ -961,7 +1857,7 @@ nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
|
|
|
961
1857
|
nodeRoutes.get("/:nodeId{.+?}/reviews", async (c) => {
|
|
962
1858
|
const nestId = c.req.param("nestId");
|
|
963
1859
|
const nodeId = c.req.param("nodeId");
|
|
964
|
-
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-
|
|
1860
|
+
const { getReviewHistory: getReviewHistory2 } = await import("./review-service-4WS3XL6K.js");
|
|
965
1861
|
const history = getReviewHistory2(nestId, nodeId);
|
|
966
1862
|
return c.json({ reviews: history });
|
|
967
1863
|
});
|
|
@@ -976,17 +1872,29 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
|
|
|
976
1872
|
403
|
|
977
1873
|
);
|
|
978
1874
|
}
|
|
1875
|
+
let node;
|
|
979
1876
|
try {
|
|
980
|
-
|
|
981
|
-
return c.json({ node: toNodeResponse(node) });
|
|
1877
|
+
node = await storage.readDocument(nodeId, { verifyChecksum: true });
|
|
982
1878
|
} catch {
|
|
983
1879
|
throw new NotFoundError(`Node not found: ${nodeId}`);
|
|
984
1880
|
}
|
|
1881
|
+
if (node.pendingChange) {
|
|
1882
|
+
try {
|
|
1883
|
+
await scanDocumentForDrift(nestId, nodeId, userEmail || "system:read");
|
|
1884
|
+
const refreshed = await getPendingChange(nestId, nodeId);
|
|
1885
|
+
if (refreshed) node.pendingChange = refreshed;
|
|
1886
|
+
} catch (err) {
|
|
1887
|
+
console.error("[external-edit] stage-on-read failed:", err);
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
const response = toNodeResponse(node);
|
|
1891
|
+
response.status = node.pendingChange ? "external_edit_pending" : getDisplayStatus(nestId, nodeId);
|
|
1892
|
+
return c.json({ node: response });
|
|
985
1893
|
});
|
|
986
1894
|
nodeRoutes.patch("/:nodeId{.+}", async (c) => {
|
|
987
1895
|
const nestId = c.req.param("nestId");
|
|
988
1896
|
const nodeId = c.req.param("nodeId");
|
|
989
|
-
const { storage } = engineCache.get(nestId);
|
|
1897
|
+
const { storage, versions: versionManager } = engineCache.get(nestId);
|
|
990
1898
|
const body = await c.req.json();
|
|
991
1899
|
const baseVersionHeader = c.req.header("X-Base-Version");
|
|
992
1900
|
if (baseVersionHeader) {
|
|
@@ -1035,40 +1943,99 @@ nodeRoutes.patch("/:nodeId{.+}", async (c) => {
|
|
|
1035
1943
|
frontmatter: { ...node.frontmatter, title: body.title }
|
|
1036
1944
|
};
|
|
1037
1945
|
}
|
|
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
1946
|
const authorEmail = getUserEmail(c);
|
|
1055
1947
|
const hasStewards = isStewardshipEnabled(nestId);
|
|
1056
1948
|
const currentTags = node.frontmatter.tags || [];
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
nestId,
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1949
|
+
const { cancelReview: cancelReview2, getPendingReview: getPendingReview2 } = await import("./review-service-4WS3XL6K.js");
|
|
1950
|
+
if (getPendingReview2(nestId, nodeId)) {
|
|
1951
|
+
cancelReview2({ nestId, nodeId, cancelledBy: authorEmail });
|
|
1952
|
+
}
|
|
1953
|
+
let responseVersion;
|
|
1954
|
+
if (hasStewards) {
|
|
1955
|
+
const currentVersion = getCurrentVersion(nestId, nodeId);
|
|
1956
|
+
const newVersion = currentVersion + 1;
|
|
1957
|
+
node = {
|
|
1958
|
+
...node,
|
|
1959
|
+
frontmatter: {
|
|
1960
|
+
...node.frontmatter,
|
|
1961
|
+
version: newVersion,
|
|
1962
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1963
|
+
// Strip the stale published-state checksum — the body just
|
|
1964
|
+
// changed, so leaving the old hash would make the next GET (with
|
|
1965
|
+
// verifyChecksum: true) flag the file as an external edit even
|
|
1966
|
+
// though *this* app wrote it. Engine treats absent checksum as
|
|
1967
|
+
// "no baseline" and skips drift detection until publish runs.
|
|
1968
|
+
checksum: void 0
|
|
1969
|
+
}
|
|
1970
|
+
};
|
|
1971
|
+
const fm = Object.fromEntries(
|
|
1972
|
+
Object.entries(node.frontmatter).filter(([, v]) => v !== void 0)
|
|
1973
|
+
);
|
|
1974
|
+
node = { ...node, frontmatter: fm };
|
|
1975
|
+
await storage.writeDocument(nodeId, serializeDocument(node));
|
|
1976
|
+
syncNodeTags(nestId, nodeId, currentTags);
|
|
1977
|
+
try {
|
|
1978
|
+
await versionManager.createVersion(node, authorEmail, {
|
|
1979
|
+
note: body.changeNote
|
|
1980
|
+
});
|
|
1981
|
+
} catch (err) {
|
|
1982
|
+
console.error("VersionManager.createVersion failed (node patch)", err);
|
|
1983
|
+
}
|
|
1984
|
+
createVersion({
|
|
1985
|
+
nestId,
|
|
1986
|
+
nodeId,
|
|
1987
|
+
version: newVersion,
|
|
1988
|
+
content: node.body || "",
|
|
1989
|
+
author: authorEmail,
|
|
1990
|
+
status: "draft",
|
|
1991
|
+
tags: currentTags,
|
|
1992
|
+
changeNote: body.changeNote
|
|
1993
|
+
});
|
|
1994
|
+
responseVersion = newVersion;
|
|
1995
|
+
} else {
|
|
1996
|
+
node = {
|
|
1997
|
+
...node,
|
|
1998
|
+
frontmatter: {
|
|
1999
|
+
...node.frontmatter,
|
|
2000
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2001
|
+
// Drop the prior published-state checksum before the interim
|
|
2002
|
+
// write — publish recomputes it from the new body, but if
|
|
2003
|
+
// publish errors out the file would otherwise be left with new
|
|
2004
|
+
// body + stale checksum and drift on next read.
|
|
2005
|
+
checksum: void 0
|
|
2006
|
+
}
|
|
2007
|
+
};
|
|
2008
|
+
const fm = Object.fromEntries(
|
|
2009
|
+
Object.entries(node.frontmatter).filter(([, v]) => v !== void 0)
|
|
2010
|
+
);
|
|
2011
|
+
node = { ...node, frontmatter: fm };
|
|
2012
|
+
await storage.writeDocument(nodeId, serializeDocument(node));
|
|
2013
|
+
syncNodeTags(nestId, nodeId, currentTags);
|
|
2014
|
+
let publishedVersion = (node.frontmatter.version || 0) + 1;
|
|
2015
|
+
try {
|
|
2016
|
+
const result = await safePublishDocument(storage, nodeId, {
|
|
2017
|
+
editedBy: authorEmail,
|
|
2018
|
+
note: body.changeNote || "Auto-published on edit (no stewards)"
|
|
2019
|
+
});
|
|
2020
|
+
publishedVersion = result.node.frontmatter.version || publishedVersion;
|
|
2021
|
+
node = result.node;
|
|
2022
|
+
} catch (err) {
|
|
2023
|
+
console.error("publishDocument failed (node patch auto-publish)", err);
|
|
2024
|
+
}
|
|
2025
|
+
createVersion({
|
|
2026
|
+
nestId,
|
|
2027
|
+
nodeId,
|
|
2028
|
+
version: publishedVersion,
|
|
2029
|
+
content: node.body || "",
|
|
2030
|
+
author: authorEmail,
|
|
2031
|
+
status: "published",
|
|
2032
|
+
tags: currentTags,
|
|
2033
|
+
changeNote: body.changeNote
|
|
2034
|
+
});
|
|
2035
|
+
setApprovedVersion(nestId, nodeId, publishedVersion, authorEmail);
|
|
2036
|
+
responseVersion = publishedVersion;
|
|
1070
2037
|
}
|
|
1071
|
-
return c.json({ node: toNodeResponse(node), version:
|
|
2038
|
+
return c.json({ node: toNodeResponse(node), version: responseVersion });
|
|
1072
2039
|
});
|
|
1073
2040
|
nodeRoutes.delete("/:nodeId{.+}", async (c) => {
|
|
1074
2041
|
const nestId = c.req.param("nestId");
|
|
@@ -1080,6 +2047,18 @@ nodeRoutes.delete("/:nodeId{.+}", async (c) => {
|
|
|
1080
2047
|
throw new NotFoundError(`Node not found: ${nodeId}`);
|
|
1081
2048
|
}
|
|
1082
2049
|
removeNodeFromTagIndex(nestId, nodeId);
|
|
2050
|
+
const db = getDb();
|
|
2051
|
+
db.transaction(() => {
|
|
2052
|
+
db.prepare(
|
|
2053
|
+
"DELETE FROM node_versions WHERE nest_id = ? AND node_id = ?"
|
|
2054
|
+
).run(nestId, nodeId);
|
|
2055
|
+
db.prepare(
|
|
2056
|
+
"DELETE FROM review_requests WHERE nest_id = ? AND node_id = ?"
|
|
2057
|
+
).run(nestId, nodeId);
|
|
2058
|
+
db.prepare(
|
|
2059
|
+
"DELETE FROM approved_versions WHERE nest_id = ? AND node_id = ?"
|
|
2060
|
+
).run(nestId, nodeId);
|
|
2061
|
+
})();
|
|
1083
2062
|
trackEvent("node.delete", { nestId, nodeId });
|
|
1084
2063
|
return c.json({ deleted: true });
|
|
1085
2064
|
});
|
|
@@ -1743,21 +2722,32 @@ var TOOL_DEFINITIONS = [
|
|
|
1743
2722
|
},
|
|
1744
2723
|
{
|
|
1745
2724
|
name: "context_assign_steward",
|
|
1746
|
-
description: "Assign a data steward to govern a scope (nest, tag,
|
|
2725
|
+
description: "Assign a data steward to govern a scope (nest, tag, or specific document). Stewards review and approve changes before they go live.",
|
|
1747
2726
|
inputSchema: {
|
|
1748
2727
|
type: "object",
|
|
1749
2728
|
properties: {
|
|
1750
2729
|
email: { type: "string", description: "Email of the person to assign as steward" },
|
|
1751
|
-
scope: { type: "string", description: "Scope: nest (all docs), tag,
|
|
1752
|
-
target: { type: "string", description: "Scope target: tag name (e.g. #architecture)
|
|
2730
|
+
scope: { type: "string", description: "Scope: nest (all docs), tag, or document" },
|
|
2731
|
+
target: { type: "string", description: "Scope target: tag name (e.g. #architecture) or document title" },
|
|
1753
2732
|
role: { type: "string", description: "Role: reviewer (default), editor, or viewer" }
|
|
1754
2733
|
},
|
|
1755
2734
|
required: ["email", "scope"]
|
|
1756
2735
|
}
|
|
1757
2736
|
}
|
|
1758
2737
|
];
|
|
2738
|
+
async function resolveLlmBody(ctx, node) {
|
|
2739
|
+
if (!isStewardshipEnabled(ctx.nestId)) return node.body || "";
|
|
2740
|
+
const approved = getApprovedVersion(ctx.nestId, node.id);
|
|
2741
|
+
if (approved == null) return null;
|
|
2742
|
+
try {
|
|
2743
|
+
return await ctx.versionManager.reconstructVersion(node.id, approved);
|
|
2744
|
+
} catch (err) {
|
|
2745
|
+
console.error("reconstructVersion failed", node.id, approved, err);
|
|
2746
|
+
return null;
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
1759
2749
|
async function handleToolCall(toolName, args, ctx) {
|
|
1760
|
-
const { storage, queryEngine, nestId, userEmail } = ctx;
|
|
2750
|
+
const { storage, queryEngine, versionManager, nestId, userEmail } = ctx;
|
|
1761
2751
|
switch (toolName) {
|
|
1762
2752
|
case "context_init": {
|
|
1763
2753
|
const content = await storage.readContextMd();
|
|
@@ -1798,14 +2788,23 @@ ${nodeList}`;
|
|
|
1798
2788
|
case "context_search": {
|
|
1799
2789
|
const docs = await storage.discoverDocuments();
|
|
1800
2790
|
const terms = (args.query || "").toLowerCase().split(/\s+/).filter(Boolean);
|
|
1801
|
-
const
|
|
1802
|
-
|
|
2791
|
+
const enriched = await Promise.all(
|
|
2792
|
+
docs.map(async (n) => ({ node: n, body: await resolveLlmBody(ctx, n) }))
|
|
2793
|
+
);
|
|
2794
|
+
const visible = enriched.filter((e) => e.body !== null);
|
|
2795
|
+
const matches = visible.filter(({ node: n, body }) => {
|
|
2796
|
+
const hay = [
|
|
2797
|
+
n.frontmatter.title,
|
|
2798
|
+
body || "",
|
|
2799
|
+
n.frontmatter.type || "",
|
|
2800
|
+
...n.frontmatter.tags || []
|
|
2801
|
+
].join(" ").toLowerCase();
|
|
1803
2802
|
return terms.every((t) => hay.includes(t));
|
|
1804
2803
|
});
|
|
1805
2804
|
if (!matches.length) return `No nodes matched search: "${args.query}"`;
|
|
1806
2805
|
const results = matches.map(
|
|
1807
|
-
(n, i) => `${i + 1}. **${n.frontmatter.title}** [${n.frontmatter.type || "document"}] ${(n.frontmatter.tags || []).join(" ")}
|
|
1808
|
-
${(
|
|
2806
|
+
({ node: n, body }, i) => `${i + 1}. **${n.frontmatter.title}** [${n.frontmatter.type || "document"}] ${(n.frontmatter.tags || []).join(" ")}
|
|
2807
|
+
${(body || "").slice(0, 200).replace(/\n/g, " ")}`
|
|
1809
2808
|
).join("\n\n");
|
|
1810
2809
|
return `Found ${matches.length} node(s) matching "${args.query}":
|
|
1811
2810
|
|
|
@@ -1833,6 +2832,10 @@ ${list}`;
|
|
|
1833
2832
|
node = docs.find((n) => n.id === args.id);
|
|
1834
2833
|
}
|
|
1835
2834
|
if (!node) return `Node not found: ${args.title || args.id}`;
|
|
2835
|
+
const body = await resolveLlmBody(ctx, node);
|
|
2836
|
+
if (body === null) {
|
|
2837
|
+
return `Node "${node.frontmatter.title}" has no approved version yet \u2014 not available to AI.`;
|
|
2838
|
+
}
|
|
1836
2839
|
const meta = [
|
|
1837
2840
|
`**Title:** ${node.frontmatter.title}`,
|
|
1838
2841
|
`**Type:** ${node.frontmatter.type || "document"}`,
|
|
@@ -1842,7 +2845,7 @@ ${list}`;
|
|
|
1842
2845
|
|
|
1843
2846
|
---
|
|
1844
2847
|
|
|
1845
|
-
${
|
|
2848
|
+
${body || "(no content)"}`;
|
|
1846
2849
|
}
|
|
1847
2850
|
case "context_list": {
|
|
1848
2851
|
let docs = await storage.discoverDocuments();
|
|
@@ -1852,6 +2855,11 @@ ${node.body || "(no content)"}`;
|
|
|
1852
2855
|
const tag = args.tag.startsWith("#") ? args.tag : `#${args.tag}`;
|
|
1853
2856
|
docs = docs.filter((n) => n.frontmatter.tags?.includes(tag));
|
|
1854
2857
|
}
|
|
2858
|
+
if (isStewardshipEnabled(nestId)) {
|
|
2859
|
+
docs = docs.filter(
|
|
2860
|
+
(n) => getApprovedVersion(nestId, n.id) != null
|
|
2861
|
+
);
|
|
2862
|
+
}
|
|
1855
2863
|
docs = docs.slice(0, args.limit || 50);
|
|
1856
2864
|
if (!docs.length) return "No nodes found with the given filters.";
|
|
1857
2865
|
const list = docs.map(
|
|
@@ -1904,6 +2912,11 @@ ${n.body || ""}`;
|
|
|
1904
2912
|
};
|
|
1905
2913
|
await storage.writeDocument(id, serializeDocument3(node));
|
|
1906
2914
|
syncNodeTags(nestId, id, tags);
|
|
2915
|
+
try {
|
|
2916
|
+
await versionManager.createVersion(node, userEmail);
|
|
2917
|
+
} catch (err) {
|
|
2918
|
+
console.error("VersionManager.createVersion failed (mcp create)", err);
|
|
2919
|
+
}
|
|
1907
2920
|
createVersion({
|
|
1908
2921
|
nestId,
|
|
1909
2922
|
nodeId: id,
|
|
@@ -1934,16 +2947,45 @@ ${n.body || ""}`;
|
|
|
1934
2947
|
);
|
|
1935
2948
|
tags = [.../* @__PURE__ */ new Set([...tags, ...newTags])];
|
|
1936
2949
|
}
|
|
2950
|
+
const prevVersion = getCurrentVersion(nestId, node.id);
|
|
2951
|
+
const newVersion = prevVersion + 1;
|
|
2952
|
+
const hasStewards = isStewardshipEnabled(nestId);
|
|
1937
2953
|
const updated = {
|
|
1938
2954
|
...node,
|
|
1939
2955
|
body,
|
|
1940
2956
|
frontmatter: {
|
|
1941
2957
|
...node.frontmatter,
|
|
1942
2958
|
tags,
|
|
2959
|
+
version: newVersion,
|
|
1943
2960
|
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1944
2961
|
}
|
|
1945
2962
|
};
|
|
1946
2963
|
await storage.writeDocument(node.id, serializeDocument3(updated));
|
|
2964
|
+
try {
|
|
2965
|
+
await versionManager.createVersion(updated, userEmail);
|
|
2966
|
+
} catch (err) {
|
|
2967
|
+
console.error("VersionManager.createVersion failed (mcp update)", err);
|
|
2968
|
+
}
|
|
2969
|
+
syncNodeTags(nestId, node.id, tags);
|
|
2970
|
+
createVersion({
|
|
2971
|
+
nestId,
|
|
2972
|
+
nodeId: node.id,
|
|
2973
|
+
version: newVersion,
|
|
2974
|
+
content: body,
|
|
2975
|
+
author: userEmail,
|
|
2976
|
+
status: hasStewards ? "draft" : "approved",
|
|
2977
|
+
tags
|
|
2978
|
+
});
|
|
2979
|
+
if (getPendingReview(nestId, node.id)) {
|
|
2980
|
+
cancelReview({
|
|
2981
|
+
nestId,
|
|
2982
|
+
nodeId: node.id,
|
|
2983
|
+
cancelledBy: userEmail
|
|
2984
|
+
});
|
|
2985
|
+
}
|
|
2986
|
+
if (!hasStewards) {
|
|
2987
|
+
setApprovedVersion(nestId, node.id, newVersion, userEmail);
|
|
2988
|
+
}
|
|
1947
2989
|
return `Updated node: **${node.frontmatter.title}**`;
|
|
1948
2990
|
}
|
|
1949
2991
|
// ─── Governance Tool Handlers ──────────────────────────────────────
|
|
@@ -1959,7 +3001,7 @@ ${n.body || ""}`;
|
|
|
1959
3001
|
return `No stewards configured for "${args.title}". Changes are auto-approved.`;
|
|
1960
3002
|
}
|
|
1961
3003
|
const list = resolved.map(
|
|
1962
|
-
(r, i) => `${i + 1}. **${r.steward.userEmail}** \u2014 ${r.steward.role} (${r.source})${r.steward.
|
|
3004
|
+
(r, i) => `${i + 1}. **${r.steward.userEmail}** \u2014 ${r.steward.role} (${r.source})${r.steward.role === "reviewer" ? " \u2713 can approve" : ""}`
|
|
1963
3005
|
).join("\n");
|
|
1964
3006
|
return `# Stewards for "${args.title}"
|
|
1965
3007
|
|
|
@@ -1971,12 +3013,12 @@ ${list}`;
|
|
|
1971
3013
|
}
|
|
1972
3014
|
const byScope = {};
|
|
1973
3015
|
for (const s of allStewards) {
|
|
1974
|
-
const key = s.scope === "nest" ? "Nest-level" : s.scope === "tag" ? `Tag: ${s.tagName}` :
|
|
3016
|
+
const key = s.scope === "nest" ? "Nest-level" : s.scope === "tag" ? `Tag: ${s.tagName}` : `Document: ${s.nodePattern}`;
|
|
1975
3017
|
(byScope[key] = byScope[key] || []).push(s);
|
|
1976
3018
|
}
|
|
1977
3019
|
const sections = Object.entries(byScope).map(
|
|
1978
3020
|
([scope, stewards]) => `## ${scope}
|
|
1979
|
-
${stewards.map((s) => `- **${s.userEmail}** (${s.role})${s.
|
|
3021
|
+
${stewards.map((s) => `- **${s.userEmail}** (${s.role})${s.role === "reviewer" ? " \u2014 can approve" : ""}`).join("\n")}`
|
|
1980
3022
|
).join("\n\n");
|
|
1981
3023
|
return `# Data Stewards
|
|
1982
3024
|
|
|
@@ -2039,7 +3081,7 @@ ${resolved.map((r) => `- ${r.steward.userEmail} (${r.source})`).join("\n")}` : "
|
|
|
2039
3081
|
if (!node) return `Node not found: ${args.title}`;
|
|
2040
3082
|
const currentVersion = getCurrentVersion(ctx.nestId, node.id);
|
|
2041
3083
|
try {
|
|
2042
|
-
const request = approve({
|
|
3084
|
+
const request = await approve({
|
|
2043
3085
|
nestId: ctx.nestId,
|
|
2044
3086
|
nodeId: node.id,
|
|
2045
3087
|
version: currentVersion,
|
|
@@ -2094,19 +3136,17 @@ ${list}`;
|
|
|
2094
3136
|
}
|
|
2095
3137
|
case "context_assign_steward": {
|
|
2096
3138
|
const scope = args.scope;
|
|
2097
|
-
if (!["nest", "tag", "
|
|
2098
|
-
return `Invalid scope "${args.scope}". Use: nest, tag,
|
|
3139
|
+
if (!["nest", "tag", "document"].includes(scope)) {
|
|
3140
|
+
return `Invalid scope "${args.scope}". Use: nest, tag, or document.`;
|
|
2099
3141
|
}
|
|
2100
3142
|
try {
|
|
2101
3143
|
assignSteward({
|
|
2102
3144
|
nestId: ctx.nestId,
|
|
2103
3145
|
scope,
|
|
2104
|
-
nodePattern: scope === "
|
|
3146
|
+
nodePattern: scope === "document" ? args.target : void 0,
|
|
2105
3147
|
tagName: scope === "tag" ? args.target : void 0,
|
|
2106
3148
|
userEmail: args.email,
|
|
2107
3149
|
role: args.role || "reviewer",
|
|
2108
|
-
canApprove: true,
|
|
2109
|
-
canReject: true,
|
|
2110
3150
|
assignedBy: ctx.userEmail,
|
|
2111
3151
|
assignedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2112
3152
|
isActive: true
|
|
@@ -2153,6 +3193,7 @@ function createMcpServerForNest(nestId, userEmail) {
|
|
|
2153
3193
|
const text = await handleToolCall(tool.name, args, {
|
|
2154
3194
|
storage: engine.storage,
|
|
2155
3195
|
queryEngine: engine.query,
|
|
3196
|
+
versionManager: engine.versions,
|
|
2156
3197
|
nestId,
|
|
2157
3198
|
userEmail
|
|
2158
3199
|
});
|
|
@@ -2184,7 +3225,7 @@ mcpRoutes.all("/", async (c) => {
|
|
|
2184
3225
|
import { Hono as Hono7 } from "hono";
|
|
2185
3226
|
|
|
2186
3227
|
// src/governance/stewards-parser.ts
|
|
2187
|
-
import { readFileSync, existsSync } from "fs";
|
|
3228
|
+
import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
|
|
2188
3229
|
import { join as join3 } from "path";
|
|
2189
3230
|
function parseStewardsYaml(content) {
|
|
2190
3231
|
const result = { version: 1 };
|
|
@@ -2196,9 +3237,6 @@ function parseStewardsYaml(content) {
|
|
|
2196
3237
|
if (!currentSection || currentEntries.length === 0) return;
|
|
2197
3238
|
if (currentSection === "nest") {
|
|
2198
3239
|
result.nest = [...result.nest || [], ...currentEntries];
|
|
2199
|
-
} else if (currentSection === "folders" && currentTarget) {
|
|
2200
|
-
result.folders = result.folders || {};
|
|
2201
|
-
result.folders[currentTarget] = currentEntries;
|
|
2202
3240
|
} else if (currentSection === "tags" && currentTarget) {
|
|
2203
3241
|
result.tags = result.tags || {};
|
|
2204
3242
|
result.tags[currentTarget] = currentEntries;
|
|
@@ -2218,12 +3256,12 @@ function parseStewardsYaml(content) {
|
|
|
2218
3256
|
if (key === "version") continue;
|
|
2219
3257
|
if (key === "nest" || key === "data_room") {
|
|
2220
3258
|
currentSection = "nest";
|
|
2221
|
-
} else if (key === "folders") {
|
|
2222
|
-
currentSection = "folders";
|
|
2223
3259
|
} else if (key === "tags") {
|
|
2224
3260
|
currentSection = "tags";
|
|
2225
3261
|
} else if (key === "documents") {
|
|
2226
3262
|
currentSection = "documents";
|
|
3263
|
+
} else if (key === "folders") {
|
|
3264
|
+
currentSection = null;
|
|
2227
3265
|
}
|
|
2228
3266
|
continue;
|
|
2229
3267
|
}
|
|
@@ -2249,12 +3287,6 @@ function parseEntry(str) {
|
|
|
2249
3287
|
const entry = { email: emailMatch[1] };
|
|
2250
3288
|
const roleMatch = str.match(/role:\s*["']?(\w+)["']?/);
|
|
2251
3289
|
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
3290
|
return entry;
|
|
2259
3291
|
}
|
|
2260
3292
|
function loadStewardsConfig(nestId) {
|
|
@@ -2266,8 +3298,8 @@ function loadStewardsConfig(nestId) {
|
|
|
2266
3298
|
join3(nestPath2, ".context", "stewards.yaml")
|
|
2267
3299
|
];
|
|
2268
3300
|
for (const candidatePath of candidates) {
|
|
2269
|
-
if (
|
|
2270
|
-
const content =
|
|
3301
|
+
if (existsSync2(candidatePath)) {
|
|
3302
|
+
const content = readFileSync2(candidatePath, "utf-8");
|
|
2271
3303
|
return parseStewardsYaml(content);
|
|
2272
3304
|
}
|
|
2273
3305
|
}
|
|
@@ -2292,12 +3324,16 @@ governanceRoutes.post("/stewards", async (c) => {
|
|
|
2292
3324
|
const body = await c.req.json();
|
|
2293
3325
|
const assignedBy = getUserEmail3(c);
|
|
2294
3326
|
if (!body.scope) throw new ValidationError("scope is required");
|
|
3327
|
+
if (body.scope === "folder") {
|
|
3328
|
+
throw new ValidationError(
|
|
3329
|
+
"folder scope is no longer supported \u2014 use 'nest' or 'document'"
|
|
3330
|
+
);
|
|
3331
|
+
}
|
|
2295
3332
|
if (Array.isArray(body.users)) {
|
|
2296
|
-
const created2 = createStewardRecord({
|
|
3333
|
+
const created2 = await createStewardRecord({
|
|
2297
3334
|
nestId,
|
|
2298
3335
|
scope: body.scope,
|
|
2299
3336
|
documentId: body.documentId,
|
|
2300
|
-
folderPath: body.folderPath,
|
|
2301
3337
|
tagName: body.tagName,
|
|
2302
3338
|
users: body.users,
|
|
2303
3339
|
assignedBy
|
|
@@ -2307,18 +3343,15 @@ governanceRoutes.post("/stewards", async (c) => {
|
|
|
2307
3343
|
if (!body.email) {
|
|
2308
3344
|
throw new ValidationError("users[] or email is required");
|
|
2309
3345
|
}
|
|
2310
|
-
const created = createStewardRecord({
|
|
3346
|
+
const created = await createStewardRecord({
|
|
2311
3347
|
nestId,
|
|
2312
3348
|
scope: body.scope,
|
|
2313
3349
|
documentId: body.scope === "document" ? body.nodePattern : void 0,
|
|
2314
|
-
folderPath: body.scope === "folder" ? body.nodePattern : void 0,
|
|
2315
3350
|
tagName: body.scope === "tag" ? body.tagName : void 0,
|
|
2316
3351
|
users: [
|
|
2317
3352
|
{
|
|
2318
3353
|
email: body.email,
|
|
2319
|
-
role: body.role
|
|
2320
|
-
canApprove: body.canApprove,
|
|
2321
|
-
canReject: body.canReject
|
|
3354
|
+
role: body.role
|
|
2322
3355
|
}
|
|
2323
3356
|
],
|
|
2324
3357
|
assignedBy
|
|
@@ -2352,6 +3385,21 @@ governanceRoutes.get("/review-queue", async (c) => {
|
|
|
2352
3385
|
});
|
|
2353
3386
|
return c.json(result);
|
|
2354
3387
|
});
|
|
3388
|
+
governanceRoutes.get("/external-edits", async (c) => {
|
|
3389
|
+
const nestId = c.req.param("nestId");
|
|
3390
|
+
const refresh = c.req.query("refresh") === "true";
|
|
3391
|
+
if (refresh) {
|
|
3392
|
+
await scanNestForDrift(nestId, "user:refresh");
|
|
3393
|
+
}
|
|
3394
|
+
const entries = await listNestExternalEdits(nestId);
|
|
3395
|
+
return c.json({ entries, total: entries.length });
|
|
3396
|
+
});
|
|
3397
|
+
governanceRoutes.post("/external-edits/scan", async (c) => {
|
|
3398
|
+
const nestId = c.req.param("nestId");
|
|
3399
|
+
const actor = getUserEmail3(c);
|
|
3400
|
+
const result = await scanNestForDrift(nestId, actor);
|
|
3401
|
+
return c.json(result);
|
|
3402
|
+
});
|
|
2355
3403
|
var governanceNodeRoutes = new Hono7();
|
|
2356
3404
|
governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
|
|
2357
3405
|
const nestId = c.req.param("nestId");
|
|
@@ -2364,8 +3412,7 @@ governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
|
|
|
2364
3412
|
role: r.steward.role,
|
|
2365
3413
|
scope: r.steward.scope,
|
|
2366
3414
|
source: r.source,
|
|
2367
|
-
priority: r.priority
|
|
2368
|
-
canApprove: r.steward.canApprove
|
|
3415
|
+
priority: r.priority
|
|
2369
3416
|
})),
|
|
2370
3417
|
fallbackToOwner,
|
|
2371
3418
|
ownerEmail
|
|
@@ -2397,14 +3444,23 @@ governanceNodeRoutes.post("/:nodeId{.+}/submit-review", async (c) => {
|
|
|
2397
3444
|
throw new ValidationError("Node has no versions to review");
|
|
2398
3445
|
}
|
|
2399
3446
|
const userEmail = getUserEmail3(c);
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
3447
|
+
let request;
|
|
3448
|
+
try {
|
|
3449
|
+
request = submitForReview({
|
|
3450
|
+
nestId,
|
|
3451
|
+
nodeId,
|
|
3452
|
+
version: currentVersion,
|
|
3453
|
+
requestedBy: userEmail,
|
|
3454
|
+
note: body.note,
|
|
3455
|
+
priority: body.priority
|
|
3456
|
+
});
|
|
3457
|
+
} catch (err) {
|
|
3458
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3459
|
+
if (/already pending/i.test(msg)) {
|
|
3460
|
+
throw new ConflictError(msg);
|
|
3461
|
+
}
|
|
3462
|
+
throw err;
|
|
3463
|
+
}
|
|
2408
3464
|
const resolved = resolveStewardsForNode(nestId, nodeId);
|
|
2409
3465
|
return c.json(
|
|
2410
3466
|
{
|
|
@@ -2425,7 +3481,7 @@ governanceNodeRoutes.post("/:nodeId{.+}/approve", async (c) => {
|
|
|
2425
3481
|
const userEmail = getUserEmail3(c);
|
|
2426
3482
|
const isAdmin = isSuperAdmin(userEmail);
|
|
2427
3483
|
try {
|
|
2428
|
-
const request = approve({
|
|
3484
|
+
const request = await approve({
|
|
2429
3485
|
nestId,
|
|
2430
3486
|
nodeId,
|
|
2431
3487
|
version: getCurrentVersion(nestId, nodeId),
|
|
@@ -2477,6 +3533,90 @@ governanceNodeRoutes.get("/:nodeId{.+}/can-edit", async (c) => {
|
|
|
2477
3533
|
const userEmail = getUserEmail3(c);
|
|
2478
3534
|
return c.json(canUserEdit(nestId, nodeId, userEmail));
|
|
2479
3535
|
});
|
|
3536
|
+
governanceNodeRoutes.get("/:nodeId{.+?}/external-edits", async (c) => {
|
|
3537
|
+
const nestId = c.req.param("nestId");
|
|
3538
|
+
const nodeId = c.req.param("nodeId");
|
|
3539
|
+
const pending = await getPendingChange(nestId, nodeId);
|
|
3540
|
+
return c.json({ pending });
|
|
3541
|
+
});
|
|
3542
|
+
governanceNodeRoutes.get(
|
|
3543
|
+
"/:nodeId{.+?}/external-edits/:suggestionId",
|
|
3544
|
+
async (c) => {
|
|
3545
|
+
const nestId = c.req.param("nestId");
|
|
3546
|
+
const nodeId = c.req.param("nodeId");
|
|
3547
|
+
const suggestionId = c.req.param("suggestionId");
|
|
3548
|
+
const detail = await getExternalEditDetail(
|
|
3549
|
+
nestId,
|
|
3550
|
+
nodeId,
|
|
3551
|
+
suggestionId
|
|
3552
|
+
);
|
|
3553
|
+
if (!detail) {
|
|
3554
|
+
return c.json({ error: "Suggestion not found" }, 404);
|
|
3555
|
+
}
|
|
3556
|
+
return c.json({ entry: detail });
|
|
3557
|
+
}
|
|
3558
|
+
);
|
|
3559
|
+
governanceNodeRoutes.post(
|
|
3560
|
+
"/:nodeId{.+?}/external-edits/:suggestionId/approve",
|
|
3561
|
+
async (c) => {
|
|
3562
|
+
const nestId = c.req.param("nestId");
|
|
3563
|
+
const nodeId = c.req.param("nodeId");
|
|
3564
|
+
const suggestionId = c.req.param("suggestionId");
|
|
3565
|
+
const body = await c.req.json().catch(() => ({}));
|
|
3566
|
+
const actor = getUserEmail3(c);
|
|
3567
|
+
try {
|
|
3568
|
+
const result = await approveExternalEdit({
|
|
3569
|
+
nestId,
|
|
3570
|
+
documentId: nodeId,
|
|
3571
|
+
suggestionId,
|
|
3572
|
+
actor,
|
|
3573
|
+
comment: body.comment
|
|
3574
|
+
});
|
|
3575
|
+
return c.json({
|
|
3576
|
+
approved: true,
|
|
3577
|
+
version: result.versionEntry.version,
|
|
3578
|
+
chainEvent: result.chainEvent.event_id
|
|
3579
|
+
});
|
|
3580
|
+
} catch (err) {
|
|
3581
|
+
console.error(
|
|
3582
|
+
`[external-edit-route] approve failed nest=${nestId} node=${nodeId} suggestion=${suggestionId}`,
|
|
3583
|
+
{ message: err?.message, name: err?.name, stack: err?.stack }
|
|
3584
|
+
);
|
|
3585
|
+
return c.json(
|
|
3586
|
+
{ error: err.message, name: err?.name },
|
|
3587
|
+
400
|
|
3588
|
+
);
|
|
3589
|
+
}
|
|
3590
|
+
}
|
|
3591
|
+
);
|
|
3592
|
+
governanceNodeRoutes.post(
|
|
3593
|
+
"/:nodeId{.+?}/external-edits/:suggestionId/reject",
|
|
3594
|
+
async (c) => {
|
|
3595
|
+
const nestId = c.req.param("nestId");
|
|
3596
|
+
const nodeId = c.req.param("nodeId");
|
|
3597
|
+
const suggestionId = c.req.param("suggestionId");
|
|
3598
|
+
const body = await c.req.json().catch(() => ({}));
|
|
3599
|
+
if (!body.reason) {
|
|
3600
|
+
throw new ValidationError("Rejection reason is required");
|
|
3601
|
+
}
|
|
3602
|
+
const actor = getUserEmail3(c);
|
|
3603
|
+
try {
|
|
3604
|
+
const result = await rejectExternalEdit({
|
|
3605
|
+
nestId,
|
|
3606
|
+
documentId: nodeId,
|
|
3607
|
+
suggestionId,
|
|
3608
|
+
actor,
|
|
3609
|
+
reason: body.reason
|
|
3610
|
+
});
|
|
3611
|
+
return c.json({
|
|
3612
|
+
rejected: true,
|
|
3613
|
+
chainEvent: result.chainEvent.event_id
|
|
3614
|
+
});
|
|
3615
|
+
} catch (err) {
|
|
3616
|
+
return c.json({ error: err.message }, 400);
|
|
3617
|
+
}
|
|
3618
|
+
}
|
|
3619
|
+
);
|
|
2480
3620
|
governanceNodeRoutes.post("/:nodeId{.+}/cancel-review", async (c) => {
|
|
2481
3621
|
const nestId = c.req.param("nestId");
|
|
2482
3622
|
const nodeId = c.req.param("nodeId");
|
|
@@ -2497,186 +3637,29 @@ function getUserEmail3(c) {
|
|
|
2497
3637
|
|
|
2498
3638
|
// src/auth/anonymous.ts
|
|
2499
3639
|
import bcrypt from "bcryptjs";
|
|
2500
|
-
var ANON_USER_ID3 = "00000000-0000-0000-0000-000000000000";
|
|
2501
|
-
var ANON_EMAIL = "admin@localhost";
|
|
2502
3640
|
function ensureAnonymousUser() {
|
|
2503
3641
|
const db = getDb();
|
|
2504
|
-
const exists = db.prepare("SELECT id FROM users WHERE id = ?").get(
|
|
3642
|
+
const exists = db.prepare("SELECT id FROM users WHERE id = ?").get(ANON_USER_ID);
|
|
2505
3643
|
if (!exists) {
|
|
2506
3644
|
const placeholder = bcrypt.hashSync("anon-no-login", 4);
|
|
2507
3645
|
db.prepare(
|
|
2508
3646
|
"INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)"
|
|
2509
|
-
).run(
|
|
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
|
-
};
|
|
3647
|
+
).run(ANON_USER_ID, ANON_EMAIL, "Admin", placeholder);
|
|
2672
3648
|
}
|
|
3649
|
+
return ANON_USER_ID;
|
|
2673
3650
|
}
|
|
2674
3651
|
|
|
2675
3652
|
// src/app.ts
|
|
2676
3653
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
2677
3654
|
import { fileURLToPath } from "url";
|
|
2678
3655
|
import { dirname, join as join4, relative } from "path";
|
|
2679
|
-
|
|
3656
|
+
import { existsSync as existsSync3 } from "fs";
|
|
3657
|
+
var HERE = dirname(fileURLToPath(import.meta.url));
|
|
3658
|
+
var UI_DIR_CANDIDATES = [
|
|
3659
|
+
join4(HERE, "web3"),
|
|
3660
|
+
join4(process.cwd(), "dist", "web3")
|
|
3661
|
+
];
|
|
3662
|
+
var UI_DIR_ABS = UI_DIR_CANDIDATES.find((p) => existsSync3(p)) || UI_DIR_CANDIDATES[0];
|
|
2680
3663
|
var UI_DIR_REL = relative(process.cwd(), UI_DIR_ABS) || ".";
|
|
2681
3664
|
var openModeMiddleware = createMiddleware2(async (c, next) => {
|
|
2682
3665
|
const anonId = ensureAnonymousUser();
|
|
@@ -2685,8 +3668,9 @@ var openModeMiddleware = createMiddleware2(async (c, next) => {
|
|
|
2685
3668
|
await next();
|
|
2686
3669
|
});
|
|
2687
3670
|
var flexAuthMiddleware = createMiddleware2(async (c, next) => {
|
|
2688
|
-
const
|
|
2689
|
-
|
|
3671
|
+
const hasBearer = c.req.header("Authorization")?.startsWith("Bearer cnst_");
|
|
3672
|
+
const hasCookie = !!c.req.header("Cookie")?.includes("cnst_session=");
|
|
3673
|
+
if (hasBearer || hasCookie) {
|
|
2690
3674
|
return authMiddleware(c, next);
|
|
2691
3675
|
}
|
|
2692
3676
|
if (config.AUTH_MODE === "open") {
|
|
@@ -2695,11 +3679,10 @@ var flexAuthMiddleware = createMiddleware2(async (c, next) => {
|
|
|
2695
3679
|
c.set("nestScope", null);
|
|
2696
3680
|
return next();
|
|
2697
3681
|
}
|
|
2698
|
-
return c.json({ error: "Missing or invalid
|
|
3682
|
+
return c.json({ error: "Missing or invalid credentials" }, 401);
|
|
2699
3683
|
});
|
|
2700
3684
|
function createApp() {
|
|
2701
3685
|
const app = new Hono8();
|
|
2702
|
-
app.use("*", logger());
|
|
2703
3686
|
const corsOrigins = config.CORS_ORIGINS;
|
|
2704
3687
|
app.use(
|
|
2705
3688
|
"*",
|
|
@@ -2730,7 +3713,101 @@ function createApp() {
|
|
|
2730
3713
|
...isSuspended() && { suspended_reason: getSuspensionReason() }
|
|
2731
3714
|
})
|
|
2732
3715
|
);
|
|
3716
|
+
app.use("/auth/invite", async (c, next) => {
|
|
3717
|
+
if (!getCurrentLicense()?.valid) {
|
|
3718
|
+
return c.json(
|
|
3719
|
+
{
|
|
3720
|
+
error: "License required",
|
|
3721
|
+
reason: "Install a valid PromptOwl license key before inviting teammates."
|
|
3722
|
+
},
|
|
3723
|
+
503
|
|
3724
|
+
);
|
|
3725
|
+
}
|
|
3726
|
+
return next();
|
|
3727
|
+
});
|
|
3728
|
+
app.use("/auth/teammates", async (c, next) => {
|
|
3729
|
+
if (!getCurrentLicense()?.valid) {
|
|
3730
|
+
return c.json(
|
|
3731
|
+
{
|
|
3732
|
+
error: "License required",
|
|
3733
|
+
reason: "Install a valid PromptOwl license key to view teammates."
|
|
3734
|
+
},
|
|
3735
|
+
503
|
|
3736
|
+
);
|
|
3737
|
+
}
|
|
3738
|
+
return next();
|
|
3739
|
+
});
|
|
2733
3740
|
app.route("/auth", authRoutes);
|
|
3741
|
+
app.get("/license/status", (c) => {
|
|
3742
|
+
const lic = getCurrentLicense();
|
|
3743
|
+
return c.json({
|
|
3744
|
+
valid: !!lic?.valid,
|
|
3745
|
+
tier: lic?.tier || "none",
|
|
3746
|
+
org: lic?.org || null,
|
|
3747
|
+
owner_email: lic?.ownerEmail || null,
|
|
3748
|
+
suspended: !!lic?.suspended,
|
|
3749
|
+
suspended_reason: lic?.suspendedReason || null,
|
|
3750
|
+
limits: lic?.limits || null
|
|
3751
|
+
});
|
|
3752
|
+
});
|
|
3753
|
+
app.post("/license/install", async (c) => {
|
|
3754
|
+
let body;
|
|
3755
|
+
try {
|
|
3756
|
+
body = await c.req.json();
|
|
3757
|
+
} catch {
|
|
3758
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
3759
|
+
}
|
|
3760
|
+
const key = body?.key?.trim();
|
|
3761
|
+
if (!key || !key.startsWith("pk_")) {
|
|
3762
|
+
return c.json(
|
|
3763
|
+
{ error: "Invalid license key. Must start with pk_." },
|
|
3764
|
+
400
|
|
3765
|
+
);
|
|
3766
|
+
}
|
|
3767
|
+
try {
|
|
3768
|
+
const info = await installLicenseKey(key);
|
|
3769
|
+
if (!info.valid) {
|
|
3770
|
+
return c.json(
|
|
3771
|
+
{
|
|
3772
|
+
valid: false,
|
|
3773
|
+
error: "PromptOwl rejected this license key. It wasn't saved. Verify the key is correct and active, then try again."
|
|
3774
|
+
},
|
|
3775
|
+
400
|
|
3776
|
+
);
|
|
3777
|
+
}
|
|
3778
|
+
return c.json({
|
|
3779
|
+
valid: true,
|
|
3780
|
+
tier: info.tier,
|
|
3781
|
+
org: info.org,
|
|
3782
|
+
limits: info.limits
|
|
3783
|
+
});
|
|
3784
|
+
} catch (err) {
|
|
3785
|
+
const msg = err instanceof Error ? err.message : "Failed to install key";
|
|
3786
|
+
return c.json({ error: msg }, 500);
|
|
3787
|
+
}
|
|
3788
|
+
});
|
|
3789
|
+
app.use("/stats", flexAuthMiddleware);
|
|
3790
|
+
app.get("/stats", async (c) => {
|
|
3791
|
+
const db = getDb();
|
|
3792
|
+
const userId = c.get("userId");
|
|
3793
|
+
const userEmail = resolveCallerEmail(userId);
|
|
3794
|
+
const visibleNests = [...listNests(userId), ...listSharedNests(userId)];
|
|
3795
|
+
let documents = 0;
|
|
3796
|
+
for (const nest of visibleNests) {
|
|
3797
|
+
try {
|
|
3798
|
+
const { storage } = engineCache.get(nest.id);
|
|
3799
|
+
const docs = await storage.discoverDocuments();
|
|
3800
|
+
documents += filterAccessible(nest.id, userEmail, docs).length;
|
|
3801
|
+
} catch {
|
|
3802
|
+
}
|
|
3803
|
+
}
|
|
3804
|
+
const usersRow = db.prepare("SELECT COUNT(*) as c FROM users").get();
|
|
3805
|
+
return c.json({
|
|
3806
|
+
nests: visibleNests.length,
|
|
3807
|
+
documents,
|
|
3808
|
+
users: usersRow.c
|
|
3809
|
+
});
|
|
3810
|
+
});
|
|
2734
3811
|
const nestsApp = new Hono8();
|
|
2735
3812
|
nestsApp.use("*", flexAuthMiddleware);
|
|
2736
3813
|
nestsApp.use("*", async (c, next) => {
|
|
@@ -2753,6 +3830,24 @@ function createApp() {
|
|
|
2753
3830
|
503
|
|
2754
3831
|
);
|
|
2755
3832
|
}
|
|
3833
|
+
{
|
|
3834
|
+
const path2 = c.req.path;
|
|
3835
|
+
const isGovernance = path2.includes("/stewards") || path2.includes("/review-queue") || path2.includes("/versions") || path2.includes("/reviews") || path2.includes("/submit-review") || path2.includes("/cancel-review") || path2.includes("/approve") || path2.includes("/reject") || path2.includes("/can-access") || path2.includes("/can-approve") || path2.includes("/can-edit");
|
|
3836
|
+
const needsLicense = c.req.method !== "GET" || isGovernance;
|
|
3837
|
+
if (needsLicense) {
|
|
3838
|
+
const lic = getCurrentLicense();
|
|
3839
|
+
if (!lic?.valid) {
|
|
3840
|
+
return c.json(
|
|
3841
|
+
{
|
|
3842
|
+
error: "License required",
|
|
3843
|
+
reason: "Install a PromptOwl license key via the setup screen or POST /license/install.",
|
|
3844
|
+
setup_url: "/setup"
|
|
3845
|
+
},
|
|
3846
|
+
503
|
|
3847
|
+
);
|
|
3848
|
+
}
|
|
3849
|
+
}
|
|
3850
|
+
}
|
|
2756
3851
|
if (config.AUTH_MODE === "open") {
|
|
2757
3852
|
c.set("nestPermission", "owner");
|
|
2758
3853
|
return next();
|
|
@@ -2763,13 +3858,21 @@ function createApp() {
|
|
|
2763
3858
|
}
|
|
2764
3859
|
let required = "read";
|
|
2765
3860
|
const path = c.req.path;
|
|
3861
|
+
const isStewardActionPath = path.includes("/approve") || path.includes("/reject") || path.includes("/submit-review") || path.includes("/cancel-review");
|
|
2766
3862
|
if (path.includes("/collaborators") || path.includes("/visibility")) {
|
|
2767
3863
|
required = "admin";
|
|
2768
|
-
} else if (c.req.method !== "GET") {
|
|
3864
|
+
} else if (c.req.method !== "GET" && !isStewardActionPath) {
|
|
2769
3865
|
required = "write";
|
|
2770
3866
|
}
|
|
2771
3867
|
if (permissionLevel(permission) < permissionLevel(required)) {
|
|
2772
|
-
return c.json(
|
|
3868
|
+
return c.json(
|
|
3869
|
+
{
|
|
3870
|
+
error: `You don't have access to perform this action on this nest. Required permission: '${required}', your permission: '${permission}'. Ask the nest owner or a server admin to grant you ${required} access.`,
|
|
3871
|
+
required_permission: required,
|
|
3872
|
+
your_permission: permission
|
|
3873
|
+
},
|
|
3874
|
+
403
|
|
3875
|
+
);
|
|
2773
3876
|
}
|
|
2774
3877
|
c.set("nestPermission", permission);
|
|
2775
3878
|
return next();
|
|
@@ -2788,15 +3891,180 @@ function createApp() {
|
|
|
2788
3891
|
if (err instanceof AppError) {
|
|
2789
3892
|
return c.json({ error: err.message }, err.statusCode);
|
|
2790
3893
|
}
|
|
3894
|
+
const e = err;
|
|
3895
|
+
const code = e.code;
|
|
3896
|
+
const msg = e.message || "";
|
|
3897
|
+
if (code === "SQLITE_CONSTRAINT_UNIQUE") {
|
|
3898
|
+
let friendly = "That conflicts with existing data. Please change a value and try again.";
|
|
3899
|
+
if (msg.includes("node_versions")) {
|
|
3900
|
+
friendly = "A version record for this node already exists. The document may have been recreated with the same name \u2014 pick a different title or remove the old node first.";
|
|
3901
|
+
} else if (msg.includes("review_requests")) {
|
|
3902
|
+
friendly = "A review request for this node already exists.";
|
|
3903
|
+
} else if (msg.includes("nests")) {
|
|
3904
|
+
friendly = "A nest with that name already exists.";
|
|
3905
|
+
} else if (msg.includes("users")) {
|
|
3906
|
+
friendly = "An account with that email already exists.";
|
|
3907
|
+
} else if (msg.includes("stewards")) {
|
|
3908
|
+
friendly = "That steward is already assigned with the same scope.";
|
|
3909
|
+
}
|
|
3910
|
+
return c.json({ error: friendly }, 409);
|
|
3911
|
+
}
|
|
3912
|
+
if (code === "SQLITE_CONSTRAINT_FOREIGNKEY") {
|
|
3913
|
+
return c.json(
|
|
3914
|
+
{
|
|
3915
|
+
error: "Required related record is missing. Ensure the parent resource still exists."
|
|
3916
|
+
},
|
|
3917
|
+
409
|
|
3918
|
+
);
|
|
3919
|
+
}
|
|
3920
|
+
if (code === "SQLITE_CONSTRAINT_CHECK") {
|
|
3921
|
+
return c.json(
|
|
3922
|
+
{
|
|
3923
|
+
error: "Provided value isn't allowed for one of the fields. Double-check your input."
|
|
3924
|
+
},
|
|
3925
|
+
400
|
|
3926
|
+
);
|
|
3927
|
+
}
|
|
3928
|
+
if (code === "SQLITE_CONSTRAINT_NOTNULL") {
|
|
3929
|
+
return c.json(
|
|
3930
|
+
{ error: "A required field is missing. Please fill it in and retry." },
|
|
3931
|
+
400
|
|
3932
|
+
);
|
|
3933
|
+
}
|
|
3934
|
+
if (code === "ENOENT") {
|
|
3935
|
+
return c.json(
|
|
3936
|
+
{ error: "The requested file or folder doesn't exist." },
|
|
3937
|
+
404
|
|
3938
|
+
);
|
|
3939
|
+
}
|
|
3940
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
3941
|
+
return c.json(
|
|
3942
|
+
{ error: "Server doesn't have permission to access that file." },
|
|
3943
|
+
500
|
|
3944
|
+
);
|
|
3945
|
+
}
|
|
3946
|
+
if (code === "ENOSPC") {
|
|
3947
|
+
return c.json(
|
|
3948
|
+
{ error: "Out of disk space on the server. Free up space and retry." },
|
|
3949
|
+
507
|
|
3950
|
+
);
|
|
3951
|
+
}
|
|
3952
|
+
if (err instanceof SyntaxError && msg.toLowerCase().includes("json")) {
|
|
3953
|
+
return c.json({ error: "Request body isn't valid JSON." }, 400);
|
|
3954
|
+
}
|
|
2791
3955
|
console.error("Unhandled error:", err);
|
|
2792
|
-
return c.json(
|
|
3956
|
+
return c.json(
|
|
3957
|
+
{
|
|
3958
|
+
error: "Something went wrong on the server. Try again \u2014 if it keeps failing, check the server log."
|
|
3959
|
+
},
|
|
3960
|
+
500
|
|
3961
|
+
);
|
|
2793
3962
|
});
|
|
2794
3963
|
return app;
|
|
2795
3964
|
}
|
|
2796
3965
|
|
|
3966
|
+
// src/db/backfill.ts
|
|
3967
|
+
import { NestStorage as NestStorage2 } from "@promptowl/contextnest-engine";
|
|
3968
|
+
import { join as join5 } from "path";
|
|
3969
|
+
var MIGRATION_ID = "005_backfill_node_versions_from_history";
|
|
3970
|
+
async function backfillNodeVersionsFromHistory(db) {
|
|
3971
|
+
const already = db.prepare("SELECT id FROM schema_migrations WHERE id = ?").get(MIGRATION_ID);
|
|
3972
|
+
if (already) return;
|
|
3973
|
+
const nests = db.prepare("SELECT id FROM nests").all();
|
|
3974
|
+
const insert = db.prepare(
|
|
3975
|
+
`INSERT OR IGNORE INTO node_versions
|
|
3976
|
+
(nest_id, node_id, version, content_hash, author, status, change_note, created_at)
|
|
3977
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
3978
|
+
);
|
|
3979
|
+
const approvedPin = db.prepare(
|
|
3980
|
+
`INSERT OR REPLACE INTO approved_versions
|
|
3981
|
+
(nest_id, node_id, approved_version, approved_by, approved_at)
|
|
3982
|
+
VALUES (?, ?, ?, ?, COALESCE(
|
|
3983
|
+
(SELECT approved_at FROM approved_versions WHERE nest_id = ? AND node_id = ?),
|
|
3984
|
+
datetime('now')))`
|
|
3985
|
+
);
|
|
3986
|
+
let totalInserted = 0;
|
|
3987
|
+
let totalDocs = 0;
|
|
3988
|
+
for (const { id: nestId } of nests) {
|
|
3989
|
+
const nestPath2 = join5(config.DATA_ROOT, "nests", nestId);
|
|
3990
|
+
const storage = new NestStorage2(nestPath2);
|
|
3991
|
+
let docs;
|
|
3992
|
+
try {
|
|
3993
|
+
docs = await storage.discoverDocuments();
|
|
3994
|
+
} catch (err) {
|
|
3995
|
+
console.warn(
|
|
3996
|
+
`[backfill] discoverDocuments failed for nest ${nestId}:`,
|
|
3997
|
+
err.message
|
|
3998
|
+
);
|
|
3999
|
+
continue;
|
|
4000
|
+
}
|
|
4001
|
+
for (const doc of docs) {
|
|
4002
|
+
totalDocs += 1;
|
|
4003
|
+
let history;
|
|
4004
|
+
try {
|
|
4005
|
+
history = await storage.readHistory(doc.id);
|
|
4006
|
+
} catch {
|
|
4007
|
+
history = null;
|
|
4008
|
+
}
|
|
4009
|
+
if (!history || history.versions.length === 0) continue;
|
|
4010
|
+
const existing = db.prepare(
|
|
4011
|
+
`SELECT version FROM node_versions WHERE nest_id = ? AND node_id = ?`
|
|
4012
|
+
).all(nestId, doc.id);
|
|
4013
|
+
const known = new Set(existing.map((r) => r.version));
|
|
4014
|
+
const tagsJson = doc.frontmatter.tags ? JSON.stringify(doc.frontmatter.tags) : null;
|
|
4015
|
+
const latestVersion = history.versions[history.versions.length - 1].version;
|
|
4016
|
+
for (const entry of history.versions) {
|
|
4017
|
+
if (known.has(entry.version)) continue;
|
|
4018
|
+
insert.run(
|
|
4019
|
+
nestId,
|
|
4020
|
+
doc.id,
|
|
4021
|
+
entry.version,
|
|
4022
|
+
entry.content_hash || "",
|
|
4023
|
+
entry.edited_by || "system:backfill",
|
|
4024
|
+
"approved",
|
|
4025
|
+
entry.note || null,
|
|
4026
|
+
entry.edited_at || (/* @__PURE__ */ new Date()).toISOString()
|
|
4027
|
+
);
|
|
4028
|
+
totalInserted += 1;
|
|
4029
|
+
}
|
|
4030
|
+
const pin = db.prepare(
|
|
4031
|
+
`SELECT approved_version FROM approved_versions WHERE nest_id = ? AND node_id = ?`
|
|
4032
|
+
).get(nestId, doc.id);
|
|
4033
|
+
if (!pin || pin.approved_version < latestVersion) {
|
|
4034
|
+
approvedPin.run(
|
|
4035
|
+
nestId,
|
|
4036
|
+
doc.id,
|
|
4037
|
+
latestVersion,
|
|
4038
|
+
history.versions[history.versions.length - 1].edited_by || "system:backfill",
|
|
4039
|
+
nestId,
|
|
4040
|
+
doc.id
|
|
4041
|
+
);
|
|
4042
|
+
}
|
|
4043
|
+
if (tagsJson) {
|
|
4044
|
+
const updateTags = db.prepare(
|
|
4045
|
+
`UPDATE node_versions SET tags_json = ?
|
|
4046
|
+
WHERE nest_id = ? AND node_id = ? AND version = ? AND tags_json IS NULL`
|
|
4047
|
+
);
|
|
4048
|
+
updateTags.run(tagsJson, nestId, doc.id, latestVersion);
|
|
4049
|
+
}
|
|
4050
|
+
}
|
|
4051
|
+
}
|
|
4052
|
+
db.prepare("INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)").run(
|
|
4053
|
+
MIGRATION_ID
|
|
4054
|
+
);
|
|
4055
|
+
console.log(
|
|
4056
|
+
`[backfill] node_versions: scanned ${totalDocs} docs across ${nests.length} nests, inserted ${totalInserted} rows`
|
|
4057
|
+
);
|
|
4058
|
+
}
|
|
4059
|
+
|
|
2797
4060
|
// src/index.ts
|
|
2798
4061
|
async function main() {
|
|
2799
|
-
getDb();
|
|
4062
|
+
const db = getDb();
|
|
4063
|
+
try {
|
|
4064
|
+
await backfillNodeVersionsFromHistory(db);
|
|
4065
|
+
} catch (err) {
|
|
4066
|
+
console.error("[backfill] node_versions backfill failed:", err);
|
|
4067
|
+
}
|
|
2800
4068
|
const accessCfg = loadAccessConfig();
|
|
2801
4069
|
if (accessCfg) {
|
|
2802
4070
|
console.log(` Loaded access.yaml (mode: ${accessCfg.mode || "open"})`);
|
|
@@ -2805,16 +4073,24 @@ async function main() {
|
|
|
2805
4073
|
if (!license.valid) {
|
|
2806
4074
|
console.warn(`
|
|
2807
4075
|
WARNING: No valid PromptOwl license key found.
|
|
2808
|
-
|
|
4076
|
+
Server boots in SETUP MODE \u2014 writes return 503 and admin features
|
|
4077
|
+
(stewards, teammates, governance) are locked until a key is installed.
|
|
2809
4078
|
|
|
2810
|
-
To
|
|
4079
|
+
To activate:
|
|
2811
4080
|
1. Sign up at https://app.promptowl.ai
|
|
2812
|
-
2.
|
|
2813
|
-
3.
|
|
2814
|
-
4.
|
|
4081
|
+
2. Open Overview > Community License
|
|
4082
|
+
3. Generate a key
|
|
4083
|
+
4. Paste it into the setup screen (UI) or POST /license/install,
|
|
4084
|
+
or set PROMPTOWL_KEY=pk_... in your environment and restart.
|
|
2815
4085
|
`);
|
|
2816
4086
|
}
|
|
2817
4087
|
const app = createApp();
|
|
4088
|
+
startLicenseWatcher();
|
|
4089
|
+
startLicenseSafetyPoll();
|
|
4090
|
+
const driftScanIntervalMs = Number(process.env.DRIFT_SCAN_INTERVAL_MS) || 3e4;
|
|
4091
|
+
if (driftScanIntervalMs > 0) {
|
|
4092
|
+
startDriftScanner(driftScanIntervalMs);
|
|
4093
|
+
}
|
|
2818
4094
|
startTelemetryLoop();
|
|
2819
4095
|
trackEvent("server.start", {
|
|
2820
4096
|
tier: license.tier,
|