@promptowl/contextnest-community 0.1.0-alpha.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -12,17 +12,19 @@ import {
12
12
  createVersion,
13
13
  getApprovedVersion,
14
14
  getCurrentVersion,
15
+ getDisplayStatus,
15
16
  getVersions,
16
17
  setApprovedVersion
17
- } from "./chunk-P6NG56CO.js";
18
+ } from "./chunk-BLOPZDPL.js";
18
19
  import {
19
20
  approve,
20
21
  cancelReview,
22
+ getPendingReview,
21
23
  getReviewHistory,
22
24
  getReviewQueue,
23
25
  reject,
24
26
  submitForReview
25
- } from "./chunk-DJFEV4ET.js";
27
+ } from "./chunk-XDCW4HTW.js";
26
28
  import {
27
29
  assignSteward,
28
30
  canUserAccess,
@@ -37,11 +39,11 @@ import {
37
39
  resolveStewardsForNode,
38
40
  resolveStewardsWithFallback,
39
41
  syncFromConfig
40
- } from "./chunk-Q2DCOS7V.js";
42
+ } from "./chunk-2FXVMVZJ.js";
41
43
  import {
42
44
  config,
43
45
  getDb
44
- } from "./chunk-USIDOGVJ.js";
46
+ } from "./chunk-2TW25QEA.js";
45
47
 
46
48
  // src/index.ts
47
49
  import { serve } from "@hono/node-server";
@@ -49,7 +51,6 @@ import { serve } from "@hono/node-server";
49
51
  // src/app.ts
50
52
  import { Hono as Hono8 } from "hono";
51
53
  import { createMiddleware as createMiddleware2 } from "hono/factory";
52
- import { logger } from "hono/logger";
53
54
  import { cors } from "hono/cors";
54
55
 
55
56
  // src/auth/routes.ts
@@ -58,23 +59,113 @@ import { v4 as uuid } from "uuid";
58
59
 
59
60
  // src/auth/middleware.ts
60
61
  import { createMiddleware } from "hono/factory";
61
- var authMiddleware = createMiddleware(async (c, next) => {
62
- const key = parseBearerToken(c.req.header("Authorization"));
63
- if (!key) {
64
- return c.json({ error: "Missing or invalid API key" }, 401);
65
- }
66
- const keyHash = hashApiKey(key);
62
+
63
+ // src/auth/sessions.ts
64
+ import { randomBytes } from "crypto";
65
+ var SESSION_COOKIE = "cnst_session";
66
+ var SESSION_TTL_DAYS = 30;
67
+ var SESSION_TTL_SECS = SESSION_TTL_DAYS * 24 * 60 * 60;
68
+ function newSessionId() {
69
+ return randomBytes(32).toString("hex");
70
+ }
71
+ function expiryIso(ttlSeconds = SESSION_TTL_SECS) {
72
+ return new Date(Date.now() + ttlSeconds * 1e3).toISOString();
73
+ }
74
+ function createSession(userId, userAgent) {
75
+ const db = getDb();
76
+ const id = newSessionId();
77
+ db.prepare(
78
+ `INSERT INTO sessions (id, user_id, expires_at, user_agent)
79
+ VALUES (?, ?, ?, ?)`
80
+ ).run(id, userId, expiryIso(), userAgent || null);
81
+ return id;
82
+ }
83
+ function resolveSession(id) {
67
84
  const db = getDb();
68
- const record = db.prepare("SELECT user_id, nest_id FROM api_keys WHERE key_hash = ?").get(keyHash);
69
- if (!record) {
70
- return c.json({ error: "Invalid API key" }, 401);
85
+ const row = db.prepare(
86
+ `SELECT user_id, expires_at FROM sessions WHERE id = ?`
87
+ ).get(id);
88
+ if (!row) return null;
89
+ if (new Date(row.expires_at).getTime() <= Date.now()) {
90
+ db.prepare("DELETE FROM sessions WHERE id = ?").run(id);
91
+ return null;
71
92
  }
72
93
  db.prepare(
73
- "UPDATE api_keys SET last_used_at = datetime('now') WHERE key_hash = ?"
74
- ).run(keyHash);
75
- c.set("userId", record.user_id);
76
- c.set("nestScope", record.nest_id);
77
- await next();
94
+ "UPDATE sessions SET last_seen_at = datetime('now') WHERE id = ?"
95
+ ).run(id);
96
+ return row.user_id;
97
+ }
98
+ function deleteSession(id) {
99
+ const db = getDb();
100
+ db.prepare("DELETE FROM sessions WHERE id = ?").run(id);
101
+ }
102
+ function deleteAllSessionsForUser(userId) {
103
+ const db = getDb();
104
+ db.prepare("DELETE FROM sessions WHERE user_id = ?").run(userId);
105
+ }
106
+ function getSessionIdFromRequest(c) {
107
+ const cookieHeader = c.req.header("Cookie");
108
+ if (!cookieHeader) return null;
109
+ for (const part of cookieHeader.split(";")) {
110
+ const [k, ...rest] = part.trim().split("=");
111
+ if (k === SESSION_COOKIE) {
112
+ const v = rest.join("=").trim();
113
+ return /^[0-9a-f]{64}$/.test(v) ? v : null;
114
+ }
115
+ }
116
+ return null;
117
+ }
118
+ function buildSessionCookie(id, opts = {}) {
119
+ const maxAge = opts.maxAgeSeconds ?? SESSION_TTL_SECS;
120
+ const parts = [
121
+ `${SESSION_COOKIE}=${id}`,
122
+ "HttpOnly",
123
+ "Path=/",
124
+ "SameSite=Lax",
125
+ `Max-Age=${maxAge}`
126
+ ];
127
+ if (opts.secure) parts.push("Secure");
128
+ return parts.join("; ");
129
+ }
130
+ function buildClearSessionCookie(opts = {}) {
131
+ return buildSessionCookie("", { secure: opts.secure, maxAgeSeconds: 0 });
132
+ }
133
+ function isSecureRequest(c) {
134
+ const proto = c.req.header("x-forwarded-proto");
135
+ if (proto) return proto.split(",")[0].trim() === "https";
136
+ try {
137
+ return new URL(c.req.url).protocol === "https:";
138
+ } catch {
139
+ return false;
140
+ }
141
+ }
142
+
143
+ // src/auth/middleware.ts
144
+ var authMiddleware = createMiddleware(async (c, next) => {
145
+ const db = getDb();
146
+ const sessionId = getSessionIdFromRequest(c);
147
+ if (sessionId) {
148
+ const userId = resolveSession(sessionId);
149
+ if (userId) {
150
+ c.set("userId", userId);
151
+ c.set("nestScope", null);
152
+ return next();
153
+ }
154
+ }
155
+ const key = parseBearerToken(c.req.header("Authorization"));
156
+ if (key) {
157
+ const keyHash = hashApiKey(key);
158
+ const record = db.prepare("SELECT user_id, nest_id FROM api_keys WHERE key_hash = ?").get(keyHash);
159
+ if (record) {
160
+ db.prepare(
161
+ "UPDATE api_keys SET last_used_at = datetime('now') WHERE key_hash = ?"
162
+ ).run(keyHash);
163
+ c.set("userId", record.user_id);
164
+ c.set("nestScope", record.nest_id);
165
+ return next();
166
+ }
167
+ }
168
+ return c.json({ error: "Missing or invalid credentials" }, 401);
78
169
  });
79
170
 
80
171
  // src/shared/errors.ts
@@ -84,6 +175,7 @@ var AppError = class extends Error {
84
175
  this.statusCode = statusCode;
85
176
  this.name = "AppError";
86
177
  }
178
+ statusCode;
87
179
  };
88
180
  var NotFoundError = class extends AppError {
89
181
  constructor(message = "Not found") {
@@ -97,6 +189,12 @@ var ValidationError = class extends AppError {
97
189
  this.name = "ValidationError";
98
190
  }
99
191
  };
192
+ var ConflictError = class extends AppError {
193
+ constructor(message) {
194
+ super(409, message);
195
+ this.name = "ConflictError";
196
+ }
197
+ };
100
198
 
101
199
  // src/telemetry/tracker.ts
102
200
  function trackEvent(event, data) {
@@ -154,6 +252,338 @@ function startTelemetryLoop() {
154
252
  );
155
253
  }
156
254
 
255
+ // src/auth/license.ts
256
+ import { existsSync, readFileSync, writeFileSync } from "fs";
257
+ var currentLicense = null;
258
+ function getCurrentLicense() {
259
+ return currentLicense;
260
+ }
261
+ function isLicenseAdminEmail(email) {
262
+ if (!email) return false;
263
+ const lic = currentLicense;
264
+ if (!lic?.valid || !lic.ownerEmail) return false;
265
+ return lic.ownerEmail.toLowerCase() === email.toLowerCase();
266
+ }
267
+ function isLicenseAdminUserId(userId) {
268
+ try {
269
+ const row = getDb().prepare("SELECT email FROM users WHERE id = ?").get(userId);
270
+ return isLicenseAdminEmail(row?.email);
271
+ } catch {
272
+ return false;
273
+ }
274
+ }
275
+ function upsertEnvVar(filePath, varName, value) {
276
+ const prefix = `${varName}=`;
277
+ let lines = [];
278
+ if (existsSync(filePath)) {
279
+ lines = readFileSync(filePath, "utf8").split(/\r?\n/);
280
+ }
281
+ const filtered = lines.filter((line) => !line.trimStart().startsWith(prefix));
282
+ if (value !== null) {
283
+ filtered.push(`${prefix}${value}`);
284
+ }
285
+ while (filtered.length && filtered[filtered.length - 1] === "") {
286
+ filtered.pop();
287
+ }
288
+ writeFileSync(filePath, filtered.join("\n") + "\n", "utf8");
289
+ }
290
+ async function installLicenseKey(key) {
291
+ const trimmed = key.trim();
292
+ if (!trimmed.startsWith("pk_")) {
293
+ throw new Error("Invalid license key format. Must start with pk_.");
294
+ }
295
+ const previousKey = process.env.PROMPTOWL_KEY || "";
296
+ process.env.PROMPTOWL_KEY = trimmed;
297
+ const info = await validateLicense({ forceFresh: true });
298
+ if (!info.valid) {
299
+ process.env.PROMPTOWL_KEY = previousKey;
300
+ if (previousKey) {
301
+ await validateLicense({ forceFresh: true });
302
+ }
303
+ return info;
304
+ }
305
+ try {
306
+ upsertEnvVar(config.ENV_FILE_PATH, "PROMPTOWL_KEY", trimmed);
307
+ } catch (err) {
308
+ console.warn("[license] failed to write .env:", err);
309
+ }
310
+ startLicenseWatcher();
311
+ return info;
312
+ }
313
+ var watcherActive = false;
314
+ var watcherAbort = null;
315
+ var WATCHER_BACKOFF_MIN_MS = 2 * 1e3;
316
+ var WATCHER_BACKOFF_MAX_MS = 60 * 1e3;
317
+ function startLicenseWatcher() {
318
+ if (watcherActive) {
319
+ watcherAbort?.abort();
320
+ return;
321
+ }
322
+ watcherActive = true;
323
+ void runLicenseWatcher();
324
+ }
325
+ async function runLicenseWatcher() {
326
+ let backoff = WATCHER_BACKOFF_MIN_MS;
327
+ while (watcherActive) {
328
+ const key = config.PROMPTOWL_KEY;
329
+ if (!key) {
330
+ await sleep(WATCHER_BACKOFF_MAX_MS);
331
+ continue;
332
+ }
333
+ try {
334
+ watcherAbort = new AbortController();
335
+ const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
336
+ const res = await fetch(`${promptowlUrl}/api/license/listen`, {
337
+ method: "POST",
338
+ headers: { "Content-Type": "application/json" },
339
+ body: JSON.stringify({
340
+ key,
341
+ since_updated_at: currentLicense ? (/* @__PURE__ */ new Date()).toISOString() : void 0
342
+ }),
343
+ signal: watcherAbort.signal
344
+ });
345
+ if (!res.ok) {
346
+ throw new Error(`listen returned ${res.status}`);
347
+ }
348
+ const data = await res.json();
349
+ backoff = WATCHER_BACKOFF_MIN_MS;
350
+ if (data.event && data.event !== "no_change") {
351
+ console.log(
352
+ `[license] event from PromptOwl: ${data.event} \u2014 revalidating`
353
+ );
354
+ const wasValid = !!currentLicense?.valid;
355
+ await validateLicense({ forceFresh: true });
356
+ const isValid = !!currentLicense?.valid;
357
+ if (wasValid && !isValid) {
358
+ handleLicenseRevoked();
359
+ }
360
+ }
361
+ } catch (err) {
362
+ if (err.name === "AbortError") {
363
+ continue;
364
+ }
365
+ console.warn(
366
+ `[license] watcher error: ${err.message}; backing off ${backoff}ms`
367
+ );
368
+ await sleep(backoff);
369
+ backoff = Math.min(backoff * 2, WATCHER_BACKOFF_MAX_MS);
370
+ } finally {
371
+ watcherAbort = null;
372
+ }
373
+ }
374
+ }
375
+ function sleep(ms) {
376
+ return new Promise((r) => setTimeout(r, ms));
377
+ }
378
+ function handleLicenseRevoked() {
379
+ try {
380
+ const db = getDb();
381
+ const result = db.prepare("DELETE FROM sessions").run();
382
+ console.warn(
383
+ `[license] revoked \u2014 wiped ${result.changes} active session(s).`
384
+ );
385
+ } catch (err) {
386
+ console.warn("[license] failed to wipe sessions:", err);
387
+ }
388
+ try {
389
+ upsertEnvVar(config.ENV_FILE_PATH, "PROMPTOWL_KEY", null);
390
+ console.warn(
391
+ `[license] revoked \u2014 removed PROMPTOWL_KEY from ${config.ENV_FILE_PATH}`
392
+ );
393
+ } catch (err) {
394
+ console.warn("[license] failed to strip key from .env:", err);
395
+ }
396
+ process.env.PROMPTOWL_KEY = "";
397
+ currentLicense = {
398
+ valid: false,
399
+ tier: "none",
400
+ org: null,
401
+ limits: null,
402
+ suspended: false,
403
+ suspendedReason: null,
404
+ ownerEmail: null
405
+ };
406
+ }
407
+ var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
408
+ var suspensionFirstSeen = null;
409
+ var suspensionConfirmed = false;
410
+ var suspensionReason = null;
411
+ var SUSPENSION_CONFIRM_WINDOW_MS = 60 * 60 * 1e3;
412
+ function isSuspended() {
413
+ return suspensionConfirmed;
414
+ }
415
+ function getSuspensionReason() {
416
+ return suspensionReason;
417
+ }
418
+ async function validateLicense(opts = {}) {
419
+ const info = await _validateLicenseImpl(!!opts.forceFresh);
420
+ currentLicense = info;
421
+ return info;
422
+ }
423
+ async function _validateLicenseImpl(forceFresh) {
424
+ const key = config.PROMPTOWL_KEY;
425
+ if (!key) {
426
+ return {
427
+ valid: false,
428
+ tier: "none",
429
+ org: null,
430
+ limits: null,
431
+ suspended: false,
432
+ suspendedReason: null,
433
+ ownerEmail: null
434
+ };
435
+ }
436
+ const db = getDb();
437
+ const cached = db.prepare("SELECT * FROM license_cache WHERE key = ?").get(key);
438
+ if (cached && !forceFresh) {
439
+ const age = Date.now() - (/* @__PURE__ */ new Date(cached.validated_at + "Z")).getTime();
440
+ if (age < CACHE_TTL_MS) {
441
+ return {
442
+ valid: true,
443
+ tier: cached.tier,
444
+ org: cached.org,
445
+ limits: cached.limits_json ? JSON.parse(cached.limits_json) : null,
446
+ suspended: suspensionConfirmed,
447
+ suspendedReason: suspensionReason,
448
+ ownerEmail: cached.owner_email || null
449
+ };
450
+ }
451
+ }
452
+ try {
453
+ const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
454
+ const res = await fetch(`${promptowlUrl}/api/license/validate`, {
455
+ method: "POST",
456
+ headers: { "Content-Type": "application/json" },
457
+ body: JSON.stringify({ key })
458
+ });
459
+ if (!res.ok) {
460
+ if (cached) {
461
+ console.warn(
462
+ " PromptOwl unreachable, using cached license (grace period)"
463
+ );
464
+ return {
465
+ valid: true,
466
+ tier: cached.tier,
467
+ org: cached.org,
468
+ limits: cached.limits_json ? JSON.parse(cached.limits_json) : null,
469
+ suspended: suspensionConfirmed,
470
+ suspendedReason: suspensionReason,
471
+ ownerEmail: cached.owner_email || null
472
+ };
473
+ }
474
+ return {
475
+ valid: false,
476
+ tier: "none",
477
+ org: null,
478
+ limits: null,
479
+ suspended: false,
480
+ suspendedReason: null,
481
+ ownerEmail: null
482
+ };
483
+ }
484
+ const data = await res.json();
485
+ if (data.suspended === true) {
486
+ if (!suspensionFirstSeen) {
487
+ suspensionFirstSeen = Date.now();
488
+ suspensionReason = data.suspended_reason || "Suspended by PromptOwl";
489
+ console.warn(
490
+ `
491
+ WARNING: PromptOwl has flagged this server for suspension.`
492
+ );
493
+ console.warn(
494
+ ` Reason: ${suspensionReason}`
495
+ );
496
+ console.warn(
497
+ ` This will be confirmed in ~1 hour. If this is an error,`
498
+ );
499
+ console.warn(
500
+ ` contact support@promptowl.ai to reverse it.
501
+ `
502
+ );
503
+ } else if (Date.now() - suspensionFirstSeen >= SUSPENSION_CONFIRM_WINDOW_MS) {
504
+ suspensionConfirmed = true;
505
+ suspensionReason = data.suspended_reason || "Suspended by PromptOwl";
506
+ console.error(
507
+ `
508
+ SERVER SUSPENDED: ${suspensionReason}`
509
+ );
510
+ console.error(
511
+ ` Write operations are disabled. Reads still work.`
512
+ );
513
+ console.error(
514
+ ` Contact support@promptowl.ai to resolve.
515
+ `
516
+ );
517
+ }
518
+ } else {
519
+ if (suspensionFirstSeen) {
520
+ console.log(" Suspension flag cleared by PromptOwl.");
521
+ }
522
+ suspensionFirstSeen = null;
523
+ suspensionConfirmed = false;
524
+ suspensionReason = null;
525
+ }
526
+ if (!data.valid && !data.suspended) {
527
+ db.prepare("DELETE FROM license_cache WHERE key = ?").run(key);
528
+ return {
529
+ valid: false,
530
+ tier: "none",
531
+ org: null,
532
+ limits: null,
533
+ suspended: false,
534
+ suspendedReason: null,
535
+ ownerEmail: data.owner_email || null
536
+ };
537
+ }
538
+ if (data.valid) {
539
+ const limitsJson = data.limits ? JSON.stringify(data.limits) : null;
540
+ db.prepare(
541
+ `INSERT OR REPLACE INTO license_cache (key, tier, org, limits_json, owner_email, validated_at)
542
+ VALUES (?, ?, ?, ?, ?, datetime('now'))`
543
+ ).run(
544
+ key,
545
+ data.tier || "community",
546
+ data.org || null,
547
+ limitsJson,
548
+ data.owner_email || null
549
+ );
550
+ }
551
+ return {
552
+ valid: data.valid !== false,
553
+ tier: data.tier || "community",
554
+ org: data.org || null,
555
+ limits: data.limits || null,
556
+ suspended: suspensionConfirmed,
557
+ suspendedReason: suspensionReason,
558
+ ownerEmail: data.owner_email || null
559
+ };
560
+ } catch (err) {
561
+ if (cached) {
562
+ console.warn(
563
+ ` PromptOwl validation failed (${err.message}), using cached license`
564
+ );
565
+ return {
566
+ valid: true,
567
+ tier: cached.tier,
568
+ org: cached.org,
569
+ limits: cached.limits_json ? JSON.parse(cached.limits_json) : null,
570
+ suspended: suspensionConfirmed,
571
+ suspendedReason: suspensionReason,
572
+ ownerEmail: cached.owner_email || null
573
+ };
574
+ }
575
+ return {
576
+ valid: false,
577
+ tier: "none",
578
+ org: null,
579
+ limits: null,
580
+ suspended: false,
581
+ suspendedReason: null,
582
+ ownerEmail: null
583
+ };
584
+ }
585
+ }
586
+
157
587
  // src/shared/rate-limit.ts
158
588
  var buckets = /* @__PURE__ */ new Map();
159
589
  function tryConsume(key, cfg) {
@@ -185,6 +615,32 @@ function clientIp(c) {
185
615
  if (realIp) return realIp.trim();
186
616
  return "unknown";
187
617
  }
618
+ function resolveCallerUserId(c) {
619
+ const sessionId = getSessionIdFromRequest(c);
620
+ if (sessionId) {
621
+ const uid = resolveSession(sessionId);
622
+ if (uid) return uid;
623
+ }
624
+ const key = parseBearerToken(c.req.header("Authorization"));
625
+ if (key) {
626
+ const db = getDb();
627
+ const row = db.prepare("SELECT user_id FROM api_keys WHERE key_hash = ?").get(hashApiKey(key));
628
+ if (row) return row.user_id;
629
+ }
630
+ return null;
631
+ }
632
+ function setSessionCookie(c, sessionId) {
633
+ c.header(
634
+ "Set-Cookie",
635
+ buildSessionCookie(sessionId, { secure: isSecureRequest(c) })
636
+ );
637
+ }
638
+ function clearSessionCookie(c) {
639
+ c.header(
640
+ "Set-Cookie",
641
+ buildClearSessionCookie({ secure: isSecureRequest(c) })
642
+ );
643
+ }
188
644
  var authRoutes = new Hono();
189
645
  authRoutes.post("/register", async (c) => {
190
646
  const body = await c.req.json();
@@ -196,25 +652,34 @@ authRoutes.post("/register", async (c) => {
196
652
  return c.json({ error: "Too many registration attempts, try again later" }, 429);
197
653
  }
198
654
  const db = getDb();
199
- const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(body.email);
200
- if (existing) {
655
+ const existing = db.prepare("SELECT id, is_invited FROM users WHERE email = ?").get(body.email);
656
+ let userId;
657
+ const passwordHash = await hashPassword(body.password);
658
+ if (existing && existing.is_invited === 1) {
659
+ userId = existing.id;
660
+ db.prepare(
661
+ "UPDATE users SET password_hash = ?, name = COALESCE(?, name), is_invited = 0 WHERE id = ?"
662
+ ).run(passwordHash, body.name || null, userId);
663
+ trackEvent("user.register", { userId, email: body.email, claimed: true });
664
+ } else if (existing) {
201
665
  throw new ValidationError("Email already registered");
666
+ } else {
667
+ userId = uuid();
668
+ db.prepare(
669
+ "INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)"
670
+ ).run(userId, body.email, body.name || null, passwordHash);
671
+ trackEvent("user.register", { userId, email: body.email });
202
672
  }
203
- const userId = uuid();
204
- const passwordHash = await hashPassword(body.password);
205
- db.prepare(
206
- "INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)"
207
- ).run(userId, body.email, body.name || null, passwordHash);
208
- const apiKey = generateApiKey();
209
- const keyId = uuid();
210
- db.prepare(
211
- "INSERT INTO api_keys (id, user_id, key_hash, key_prefix, label) VALUES (?, ?, ?, ?, ?)"
212
- ).run(keyId, userId, hashApiKey(apiKey), getKeyPrefix(apiKey), "default");
213
- trackEvent("user.register", { userId, email: body.email });
673
+ const sessionId = createSession(userId, c.req.header("User-Agent"));
674
+ setSessionCookie(c, sessionId);
214
675
  return c.json(
215
676
  {
216
- user: { id: userId, email: body.email, name: body.name || null },
217
- api_key: apiKey
677
+ user: {
678
+ id: userId,
679
+ email: body.email,
680
+ name: body.name || null,
681
+ is_admin: isLicenseAdminEmail(body.email)
682
+ }
218
683
  },
219
684
  201
220
685
  );
@@ -231,7 +696,7 @@ authRoutes.post("/login", async (c) => {
231
696
  }
232
697
  const db = getDb();
233
698
  const user = db.prepare(
234
- "SELECT id, email, name, password_hash FROM users WHERE email = ?"
699
+ "SELECT id, email, name, password_hash, is_admin FROM users WHERE email = ?"
235
700
  ).get(body.email);
236
701
  const check = user ? await verifyPassword(body.password, user.password_hash) : { ok: false, needsRehash: false };
237
702
  if (!user || !check.ok) {
@@ -247,30 +712,47 @@ authRoutes.post("/login", async (c) => {
247
712
  } catch {
248
713
  }
249
714
  }
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
715
  trackEvent("user.login", { userId: user.id });
262
- return c.json({ api_key: apiKey });
716
+ const sessionId = createSession(user.id, c.req.header("User-Agent"));
717
+ setSessionCookie(c, sessionId);
718
+ return c.json({
719
+ user: {
720
+ id: user.id,
721
+ email: user.email,
722
+ name: user.name,
723
+ is_admin: isLicenseAdminEmail(user.email)
724
+ }
725
+ });
726
+ });
727
+ authRoutes.post("/logout", async (c) => {
728
+ const sessionId = getSessionIdFromRequest(c);
729
+ if (sessionId) deleteSession(sessionId);
730
+ clearSessionCookie(c);
731
+ return c.json({ ok: true });
263
732
  });
264
733
  authRoutes.post("/keys", authMiddleware, async (c) => {
265
- const body = await c.req.json();
734
+ const body = await c.req.json().catch(
735
+ () => ({})
736
+ );
266
737
  const db = getDb();
738
+ const userId = c.get("userId");
739
+ const existing = db.prepare("SELECT key_prefix FROM api_keys WHERE user_id = ?").get(userId);
740
+ if (existing) {
741
+ return c.json(
742
+ {
743
+ error: "An API key already exists for this user. Rotate it instead.",
744
+ key_prefix: existing.key_prefix
745
+ },
746
+ 409
747
+ );
748
+ }
267
749
  const apiKey = generateApiKey();
268
750
  const keyId = uuid();
269
751
  db.prepare(
270
752
  "INSERT INTO api_keys (id, user_id, key_hash, key_prefix, nest_id, label) VALUES (?, ?, ?, ?, ?, ?)"
271
753
  ).run(
272
754
  keyId,
273
- c.get("userId"),
755
+ userId,
274
756
  hashApiKey(apiKey),
275
757
  getKeyPrefix(apiKey),
276
758
  body.nest_id || null,
@@ -281,6 +763,30 @@ authRoutes.post("/keys", authMiddleware, async (c) => {
281
763
  201
282
764
  );
283
765
  });
766
+ authRoutes.post("/keys/rotate", authMiddleware, async (c) => {
767
+ const body = await c.req.json().catch(
768
+ () => ({})
769
+ );
770
+ const db = getDb();
771
+ const userId = c.get("userId");
772
+ const apiKey = generateApiKey();
773
+ const keyId = uuid();
774
+ const prior = db.prepare("SELECT label, nest_id FROM api_keys WHERE user_id = ?").get(userId);
775
+ db.transaction(() => {
776
+ db.prepare("DELETE FROM api_keys WHERE user_id = ?").run(userId);
777
+ db.prepare(
778
+ "INSERT INTO api_keys (id, user_id, key_hash, key_prefix, nest_id, label) VALUES (?, ?, ?, ?, ?, ?)"
779
+ ).run(
780
+ keyId,
781
+ userId,
782
+ hashApiKey(apiKey),
783
+ getKeyPrefix(apiKey),
784
+ body.nest_id ?? prior?.nest_id ?? null,
785
+ body.label ?? prior?.label ?? null
786
+ );
787
+ })();
788
+ return c.json({ api_key: apiKey, key_prefix: getKeyPrefix(apiKey) });
789
+ });
284
790
  authRoutes.get("/keys", authMiddleware, async (c) => {
285
791
  const db = getDb();
286
792
  const keys = db.prepare(
@@ -370,97 +876,127 @@ authRoutes.post("/promptowl", async (c) => {
370
876
  } else {
371
877
  trackEvent("user.login", { userId: user.id, method: "promptowl" });
372
878
  }
879
+ let claimBlocked = null;
880
+ const lic = getCurrentLicense();
373
881
  let isAdmin = false;
374
- const claim = db.transaction((userId) => {
375
- const result = db.prepare(
376
- `UPDATE users SET is_admin = 1
377
- WHERE id = ?
378
- AND NOT EXISTS (SELECT 1 FROM users WHERE is_admin = 1)`
379
- ).run(userId);
380
- return result.changes > 0;
381
- });
382
- if (claim(user.id)) {
882
+ if (!lic?.valid) {
883
+ claimBlocked = { reason: "license_required" };
884
+ } else if (lic.ownerEmail && lic.ownerEmail.toLowerCase() === me.email.toLowerCase()) {
383
885
  isAdmin = true;
384
886
  trackEvent("admin.claim", { userId: user.id, email: user.email });
385
887
  } else {
386
- const userRow = db.prepare("SELECT is_admin FROM users WHERE id = ?").get(user.id);
387
- isAdmin = !!userRow?.is_admin;
888
+ claimBlocked = {
889
+ reason: "email_mismatch",
890
+ license_owner_email: lic.ownerEmail
891
+ };
388
892
  }
389
- const apiKey = generateApiKey();
390
- const keyId = uuid();
391
- db.prepare(
392
- "INSERT INTO api_keys (id, user_id, key_hash, key_prefix, label) VALUES (?, ?, ?, ?, ?)"
393
- ).run(
394
- keyId,
395
- user.id,
396
- hashApiKey(apiKey),
397
- getKeyPrefix(apiKey),
398
- "promptowl"
399
- );
893
+ const sessionId = createSession(user.id, c.req.header("User-Agent"));
894
+ setSessionCookie(c, sessionId);
400
895
  return c.json({
401
- api_key: apiKey,
402
896
  user: {
403
897
  id: user.id,
404
898
  email: user.email,
405
899
  name: user.name,
406
900
  is_admin: isAdmin
407
- }
901
+ },
902
+ ...claimBlocked ? { claim_blocked: claimBlocked } : {}
408
903
  });
409
904
  });
410
905
  authRoutes.get("/admin-status", async (c) => {
411
906
  const db = getDb();
412
- const admin = db.prepare("SELECT email, name FROM users WHERE is_admin = 1 LIMIT 1").get();
413
- const callerKey = parseBearerToken(c.req.header("Authorization"));
907
+ const lic = getCurrentLicense();
908
+ const ownerEmail = lic?.valid ? lic.ownerEmail : null;
909
+ let admin = null;
910
+ if (ownerEmail) {
911
+ const ownerRow = db.prepare("SELECT name FROM users WHERE LOWER(email) = LOWER(?) LIMIT 1").get(ownerEmail);
912
+ admin = { email: ownerEmail, name: ownerRow?.name ?? null };
913
+ }
914
+ const callerId = resolveCallerUserId(c);
414
915
  let me = null;
415
- if (callerKey) {
416
- const keyHash = hashApiKey(callerKey);
417
- const row = db.prepare(
418
- `SELECT u.email, u.is_admin FROM api_keys k
419
- JOIN users u ON u.id = k.user_id
420
- WHERE k.key_hash = ?`
421
- ).get(keyHash);
916
+ if (callerId) {
917
+ const row = db.prepare("SELECT email, name FROM users WHERE id = ?").get(callerId);
422
918
  if (row) {
423
- me = { email: row.email, is_admin: !!row.is_admin };
919
+ me = {
920
+ email: row.email,
921
+ name: row.name,
922
+ is_admin: isLicenseAdminEmail(row.email)
923
+ };
424
924
  }
425
925
  }
426
926
  return c.json({
427
927
  claimed: !!admin,
428
- admin: admin ? { email: admin.email, name: admin.name } : null,
928
+ admin,
429
929
  me
430
930
  });
431
931
  });
932
+ authRoutes.post("/password", authMiddleware, async (c) => {
933
+ const body = await c.req.json();
934
+ if (!body.current || !body.next) {
935
+ throw new ValidationError("current and next are required");
936
+ }
937
+ if (body.next.length < 8) {
938
+ throw new ValidationError("new password must be at least 8 characters");
939
+ }
940
+ const db = getDb();
941
+ const userId = c.get("userId");
942
+ const user = db.prepare("SELECT password_hash FROM users WHERE id = ?").get(userId);
943
+ if (!user) return c.json({ error: "User not found" }, 404);
944
+ const check = await verifyPassword(body.current, user.password_hash);
945
+ if (!check.ok) return c.json({ error: "Invalid current password" }, 401);
946
+ const newHash = await hashPassword(body.next);
947
+ db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(
948
+ newHash,
949
+ userId
950
+ );
951
+ deleteAllSessionsForUser(userId);
952
+ clearSessionCookie(c);
953
+ return c.json({ ok: true });
954
+ });
432
955
  authRoutes.post("/invite", async (c) => {
433
956
  const body = await c.req.json();
434
957
  if (!body.email) throw new ValidationError("email is required");
435
- const inviteKey = parseBearerToken(c.req.header("Authorization"));
436
- if (!inviteKey) {
437
- return c.json({ error: "Admin authentication required" }, 401);
958
+ const callerId = resolveCallerUserId(c);
959
+ if (!callerId) {
960
+ return c.json(
961
+ {
962
+ error: "Authentication required. Sign in with the admin account that owns the installed PromptOwl license to invite teammates."
963
+ },
964
+ 401
965
+ );
438
966
  }
439
967
  const db = getDb();
440
- const keyHash = hashApiKey(inviteKey);
441
- const caller = db.prepare(
442
- `SELECT u.id, u.is_admin FROM api_keys k
443
- JOIN users u ON u.id = k.user_id
444
- WHERE k.key_hash = ?`
445
- ).get(keyHash);
446
- if (!caller || !caller.is_admin) {
447
- return c.json({ error: "Admin only" }, 403);
968
+ if (!isLicenseAdminUserId(callerId)) {
969
+ return c.json(
970
+ {
971
+ error: "Only the license-admin user can invite teammates. Contact the admin who installed the PromptOwl license on this server to issue invitations."
972
+ },
973
+ 403
974
+ );
448
975
  }
449
976
  let user = db.prepare("SELECT id, email FROM users WHERE email = ?").get(body.email);
450
977
  if (!user) {
451
978
  const userId = uuid();
452
979
  const placeholderHash = await hashPassword(uuid());
453
980
  db.prepare(
454
- "INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)"
981
+ "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)"
455
982
  ).run(userId, body.email, null, placeholderHash);
456
983
  user = { id: userId, email: body.email };
457
984
  }
458
985
  const apiKey = generateApiKey();
459
986
  const keyId = uuid();
460
- db.prepare(
461
- "INSERT INTO api_keys (id, user_id, key_hash, key_prefix, label) VALUES (?, ?, ?, ?, ?)"
462
- ).run(keyId, user.id, hashApiKey(apiKey), getKeyPrefix(apiKey), body.label || "teammate");
463
- trackEvent("admin.invite", { adminId: caller.id, email: body.email });
987
+ db.transaction(() => {
988
+ db.prepare("DELETE FROM api_keys WHERE user_id = ?").run(user.id);
989
+ db.prepare(
990
+ "INSERT INTO api_keys (id, user_id, key_hash, key_prefix, label) VALUES (?, ?, ?, ?, ?)"
991
+ ).run(
992
+ keyId,
993
+ user.id,
994
+ hashApiKey(apiKey),
995
+ getKeyPrefix(apiKey),
996
+ body.label || "teammate"
997
+ );
998
+ })();
999
+ trackEvent("admin.invite", { adminId: callerId, email: body.email });
464
1000
  return c.json(
465
1001
  {
466
1002
  api_key: apiKey,
@@ -471,27 +1007,31 @@ authRoutes.post("/invite", async (c) => {
471
1007
  );
472
1008
  });
473
1009
  authRoutes.get("/teammates", async (c) => {
474
- const teammatesKey = parseBearerToken(c.req.header("Authorization"));
475
- if (!teammatesKey) {
476
- return c.json({ error: "Admin authentication required" }, 401);
1010
+ const callerId = resolveCallerUserId(c);
1011
+ if (!callerId) {
1012
+ return c.json(
1013
+ {
1014
+ error: "Authentication required. Sign in with the admin account that owns the installed PromptOwl license to view teammates."
1015
+ },
1016
+ 401
1017
+ );
477
1018
  }
478
1019
  const db = getDb();
479
- const keyHash = hashApiKey(teammatesKey);
480
- const caller = db.prepare(
481
- `SELECT u.is_admin FROM api_keys k
482
- JOIN users u ON u.id = k.user_id
483
- WHERE k.key_hash = ?`
484
- ).get(keyHash);
485
- if (!caller || !caller.is_admin) {
486
- return c.json({ error: "Admin only" }, 403);
1020
+ if (!isLicenseAdminUserId(callerId)) {
1021
+ return c.json(
1022
+ {
1023
+ error: "Only the license-admin user can view the teammates list. Contact the admin who installed the PromptOwl license on this server."
1024
+ },
1025
+ 403
1026
+ );
487
1027
  }
488
1028
  const teammates = db.prepare(
489
- `SELECT u.id, u.email, u.name, u.is_admin,
1029
+ `SELECT u.id, u.email, u.name, u.is_invited,
490
1030
  (SELECT COUNT(*) FROM api_keys WHERE user_id = u.id) as key_count,
491
1031
  (SELECT MAX(last_used_at) FROM api_keys WHERE user_id = u.id) as last_active
492
1032
  FROM users u
493
1033
  WHERE u.id != '00000000-0000-0000-0000-000000000000'
494
- ORDER BY u.is_admin DESC, u.created_at DESC`
1034
+ ORDER BY u.created_at DESC`
495
1035
  ).all();
496
1036
  const pendingStewards = db.prepare(
497
1037
  `SELECT DISTINCT s.user_email AS email
@@ -504,11 +1044,9 @@ authRoutes.get("/teammates", async (c) => {
504
1044
  )
505
1045
  ORDER BY s.user_email`
506
1046
  ).all();
1047
+ const enriched = teammates.map((t) => ({ ...t, is_admin: isLicenseAdminEmail(t.email) })).sort((a, b) => Number(b.is_admin) - Number(a.is_admin));
507
1048
  return c.json({
508
- teammates: teammates.map((t) => ({
509
- ...t,
510
- is_admin: !!t.is_admin
511
- })),
1049
+ teammates: enriched,
512
1050
  pending_stewards: pendingStewards.map((p) => p.email)
513
1051
  });
514
1052
  });
@@ -668,12 +1206,15 @@ nestRoutes.get("/:nestId/settings", async (c) => {
668
1206
  nestRoutes.patch("/:nestId/settings", async (c) => {
669
1207
  const nestId = c.req.param("nestId");
670
1208
  const userId = c.get("userId");
671
- const db = getDb();
672
- const userRow = db.prepare("SELECT is_admin FROM users WHERE id = ?").get(userId);
673
- const isServerAdmin = !!userRow?.is_admin;
1209
+ const isServerAdmin = isLicenseAdminUserId(userId);
674
1210
  const permission = effectivePermission(nestId, userId);
675
1211
  if (!isServerAdmin && permission !== "owner") {
676
- return c.json({ error: "Admin only" }, 403);
1212
+ return c.json(
1213
+ {
1214
+ error: "Only the nest owner or the server license-admin can update nest settings."
1215
+ },
1216
+ 403
1217
+ );
677
1218
  }
678
1219
  const body = await c.req.json();
679
1220
  if (typeof body.stewardship_enabled === "boolean") {
@@ -711,7 +1252,7 @@ sharingRoutes.post("/collaborators", async (c) => {
711
1252
  const { hashPassword: hashPassword2 } = await import("./keys-YV33AJK3.js");
712
1253
  userId = uuid3();
713
1254
  db.prepare(
714
- "INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)"
1255
+ "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)"
715
1256
  ).run(userId, body.email, null, await hashPassword2(uuid3()));
716
1257
  } else {
717
1258
  userId = user.id;
@@ -771,7 +1312,11 @@ import { serializeDocument } from "@promptowl/contextnest-engine";
771
1312
 
772
1313
  // src/nodes/engine.ts
773
1314
  import { join as join2 } from "path";
774
- import { NestStorage as NestStorage2, GraphQueryEngine } from "@promptowl/contextnest-engine";
1315
+ import {
1316
+ NestStorage as NestStorage2,
1317
+ GraphQueryEngine,
1318
+ VersionManager
1319
+ } from "@promptowl/contextnest-engine";
775
1320
  var NestEngineCache = class {
776
1321
  cache = /* @__PURE__ */ new Map();
777
1322
  get(nestId) {
@@ -780,7 +1325,8 @@ var NestEngineCache = class {
780
1325
  const nestPath2 = join2(config.DATA_ROOT, "nests", nestId);
781
1326
  const storage = new NestStorage2(nestPath2);
782
1327
  const query = new GraphQueryEngine(storage);
783
- engine = { storage, query };
1328
+ const versions = new VersionManager(storage);
1329
+ engine = { storage, query, versions };
784
1330
  this.cache.set(nestId, engine);
785
1331
  }
786
1332
  return engine;
@@ -845,6 +1391,9 @@ function toNodeResponse(node) {
845
1391
  title: node.frontmatter.title,
846
1392
  type: node.frontmatter.type || "document",
847
1393
  tags: node.frontmatter.tags || [],
1394
+ // Widen to string so callers can layer review-workflow states like
1395
+ // "pending_review" / "rejected" on top of the on-disk frontmatter
1396
+ // status. The engine's Status enum only knows draft/approved.
848
1397
  status: node.frontmatter.status || "draft",
849
1398
  version: node.frontmatter.version || 1,
850
1399
  author: node.frontmatter.author,
@@ -862,7 +1411,11 @@ nodeRoutes.get("/", async (c) => {
862
1411
  const accessible = filterAccessible(nestId, userEmail, documents);
863
1412
  return c.json({
864
1413
  count: accessible.length,
865
- nodes: accessible.map(toNodeResponse)
1414
+ nodes: accessible.map((doc) => {
1415
+ const r = toNodeResponse(doc);
1416
+ r.status = getDisplayStatus(nestId, r.id);
1417
+ return r;
1418
+ })
866
1419
  });
867
1420
  });
868
1421
  nodeRoutes.post("/", async (c) => {
@@ -871,7 +1424,7 @@ nodeRoutes.post("/", async (c) => {
871
1424
  throw new ValidationError("title and content are required");
872
1425
  }
873
1426
  const nestId = c.req.param("nestId");
874
- const { storage } = engineCache.get(nestId);
1427
+ const { storage, versions: versionManager } = engineCache.get(nestId);
875
1428
  const slug = body.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
876
1429
  const id = `nodes/${slug}`;
877
1430
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -901,6 +1454,11 @@ nodeRoutes.post("/", async (c) => {
901
1454
  await storage.writeDocument(id, serialized);
902
1455
  syncNodeTags(nestId, id, tags);
903
1456
  const authorEmail = getUserEmail(c);
1457
+ try {
1458
+ await versionManager.createVersion(node, authorEmail);
1459
+ } catch (err) {
1460
+ console.error("VersionManager.createVersion failed (node create)", err);
1461
+ }
904
1462
  createVersion({
905
1463
  nestId,
906
1464
  nodeId: id,
@@ -927,7 +1485,7 @@ nodeRoutes.post("/", async (c) => {
927
1485
  nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
928
1486
  const nestId = c.req.param("nestId");
929
1487
  const nodeId = c.req.param("nodeId");
930
- const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-NC67XBYO.js");
1488
+ const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-ZJATH6OM.js");
931
1489
  const { stewards, fallbackToOwner, ownerEmail } = resolveStewardsWithFallback2(
932
1490
  nestId,
933
1491
  nodeId
@@ -949,11 +1507,30 @@ nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
949
1507
  nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
950
1508
  const nestId = c.req.param("nestId");
951
1509
  const nodeId = c.req.param("nodeId");
952
- const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-Z6FYJRAG.js");
1510
+ const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-2MZJGE3H.js");
953
1511
  const allVersions = getVersions2(nestId, nodeId);
954
1512
  const approved = getApprovedVersion2(nestId, nodeId);
1513
+ const db = getDb();
1514
+ const resolutions = db.prepare(
1515
+ `SELECT version, status, resolved_by, resolved_at
1516
+ FROM review_requests
1517
+ WHERE nest_id = ? AND node_id = ?
1518
+ AND status IN ('approved', 'rejected')
1519
+ AND resolved_by IS NOT NULL
1520
+ ORDER BY resolved_at DESC`
1521
+ ).all(nestId, nodeId);
1522
+ const byVersion = /* @__PURE__ */ new Map();
1523
+ for (const r of resolutions) {
1524
+ if (!byVersion.has(r.version)) {
1525
+ byVersion.set(r.version, { status: r.status, resolvedBy: r.resolved_by });
1526
+ }
1527
+ }
1528
+ const enriched = allVersions.map((v) => {
1529
+ const r = byVersion.get(v.version);
1530
+ return r ? { ...v, resolvedBy: r.resolvedBy, resolutionStatus: r.status } : v;
1531
+ });
955
1532
  return c.json({
956
- versions: allVersions,
1533
+ versions: enriched,
957
1534
  approvedVersion: approved,
958
1535
  currentVersion: allVersions[0]?.version || 0
959
1536
  });
@@ -961,7 +1538,7 @@ nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
961
1538
  nodeRoutes.get("/:nodeId{.+?}/reviews", async (c) => {
962
1539
  const nestId = c.req.param("nestId");
963
1540
  const nodeId = c.req.param("nodeId");
964
- const { getReviewHistory: getReviewHistory2 } = await import("./review-service-5CLVZKAR.js");
1541
+ const { getReviewHistory: getReviewHistory2 } = await import("./review-service-2JHZHZWJ.js");
965
1542
  const history = getReviewHistory2(nestId, nodeId);
966
1543
  return c.json({ reviews: history });
967
1544
  });
@@ -976,17 +1553,20 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
976
1553
  403
977
1554
  );
978
1555
  }
1556
+ let node;
979
1557
  try {
980
- const node = await storage.readDocument(nodeId);
981
- return c.json({ node: toNodeResponse(node) });
1558
+ node = await storage.readDocument(nodeId);
982
1559
  } catch {
983
1560
  throw new NotFoundError(`Node not found: ${nodeId}`);
984
1561
  }
1562
+ const response = toNodeResponse(node);
1563
+ response.status = getDisplayStatus(nestId, nodeId);
1564
+ return c.json({ node: response });
985
1565
  });
986
1566
  nodeRoutes.patch("/:nodeId{.+}", async (c) => {
987
1567
  const nestId = c.req.param("nestId");
988
1568
  const nodeId = c.req.param("nodeId");
989
- const { storage } = engineCache.get(nestId);
1569
+ const { storage, versions: versionManager } = engineCache.get(nestId);
990
1570
  const body = await c.req.json();
991
1571
  const baseVersionHeader = c.req.header("X-Base-Version");
992
1572
  if (baseVersionHeader) {
@@ -1055,6 +1635,13 @@ nodeRoutes.patch("/:nodeId{.+}", async (c) => {
1055
1635
  const hasStewards = isStewardshipEnabled(nestId);
1056
1636
  const currentTags = node.frontmatter.tags || [];
1057
1637
  syncNodeTags(nestId, nodeId, currentTags);
1638
+ try {
1639
+ await versionManager.createVersion(node, authorEmail, {
1640
+ note: body.changeNote
1641
+ });
1642
+ } catch (err) {
1643
+ console.error("VersionManager.createVersion failed (node patch)", err);
1644
+ }
1058
1645
  createVersion({
1059
1646
  nestId,
1060
1647
  nodeId,
@@ -1065,6 +1652,10 @@ nodeRoutes.patch("/:nodeId{.+}", async (c) => {
1065
1652
  tags: currentTags,
1066
1653
  changeNote: body.changeNote
1067
1654
  });
1655
+ const { cancelReview: cancelReview2, getPendingReview: getPendingReview2 } = await import("./review-service-2JHZHZWJ.js");
1656
+ if (getPendingReview2(nestId, nodeId)) {
1657
+ cancelReview2({ nestId, nodeId, cancelledBy: authorEmail });
1658
+ }
1068
1659
  if (!hasStewards) {
1069
1660
  setApprovedVersion(nestId, nodeId, newVersion, authorEmail);
1070
1661
  }
@@ -1080,6 +1671,18 @@ nodeRoutes.delete("/:nodeId{.+}", async (c) => {
1080
1671
  throw new NotFoundError(`Node not found: ${nodeId}`);
1081
1672
  }
1082
1673
  removeNodeFromTagIndex(nestId, nodeId);
1674
+ const db = getDb();
1675
+ db.transaction(() => {
1676
+ db.prepare(
1677
+ "DELETE FROM node_versions WHERE nest_id = ? AND node_id = ?"
1678
+ ).run(nestId, nodeId);
1679
+ db.prepare(
1680
+ "DELETE FROM review_requests WHERE nest_id = ? AND node_id = ?"
1681
+ ).run(nestId, nodeId);
1682
+ db.prepare(
1683
+ "DELETE FROM approved_versions WHERE nest_id = ? AND node_id = ?"
1684
+ ).run(nestId, nodeId);
1685
+ })();
1083
1686
  trackEvent("node.delete", { nestId, nodeId });
1084
1687
  return c.json({ deleted: true });
1085
1688
  });
@@ -1756,8 +2359,19 @@ var TOOL_DEFINITIONS = [
1756
2359
  }
1757
2360
  }
1758
2361
  ];
2362
+ async function resolveLlmBody(ctx, node) {
2363
+ if (!isStewardshipEnabled(ctx.nestId)) return node.body || "";
2364
+ const approved = getApprovedVersion(ctx.nestId, node.id);
2365
+ if (approved == null) return null;
2366
+ try {
2367
+ return await ctx.versionManager.reconstructVersion(node.id, approved);
2368
+ } catch (err) {
2369
+ console.error("reconstructVersion failed", node.id, approved, err);
2370
+ return null;
2371
+ }
2372
+ }
1759
2373
  async function handleToolCall(toolName, args, ctx) {
1760
- const { storage, queryEngine, nestId, userEmail } = ctx;
2374
+ const { storage, queryEngine, versionManager, nestId, userEmail } = ctx;
1761
2375
  switch (toolName) {
1762
2376
  case "context_init": {
1763
2377
  const content = await storage.readContextMd();
@@ -1798,14 +2412,23 @@ ${nodeList}`;
1798
2412
  case "context_search": {
1799
2413
  const docs = await storage.discoverDocuments();
1800
2414
  const terms = (args.query || "").toLowerCase().split(/\s+/).filter(Boolean);
1801
- const matches = docs.filter((n) => {
1802
- const hay = [n.frontmatter.title, n.body || "", n.frontmatter.type || "", ...n.frontmatter.tags || []].join(" ").toLowerCase();
2415
+ const enriched = await Promise.all(
2416
+ docs.map(async (n) => ({ node: n, body: await resolveLlmBody(ctx, n) }))
2417
+ );
2418
+ const visible = enriched.filter((e) => e.body !== null);
2419
+ const matches = visible.filter(({ node: n, body }) => {
2420
+ const hay = [
2421
+ n.frontmatter.title,
2422
+ body || "",
2423
+ n.frontmatter.type || "",
2424
+ ...n.frontmatter.tags || []
2425
+ ].join(" ").toLowerCase();
1803
2426
  return terms.every((t) => hay.includes(t));
1804
2427
  });
1805
2428
  if (!matches.length) return `No nodes matched search: "${args.query}"`;
1806
2429
  const results = matches.map(
1807
- (n, i) => `${i + 1}. **${n.frontmatter.title}** [${n.frontmatter.type || "document"}] ${(n.frontmatter.tags || []).join(" ")}
1808
- ${(n.body || "").slice(0, 200).replace(/\n/g, " ")}`
2430
+ ({ node: n, body }, i) => `${i + 1}. **${n.frontmatter.title}** [${n.frontmatter.type || "document"}] ${(n.frontmatter.tags || []).join(" ")}
2431
+ ${(body || "").slice(0, 200).replace(/\n/g, " ")}`
1809
2432
  ).join("\n\n");
1810
2433
  return `Found ${matches.length} node(s) matching "${args.query}":
1811
2434
 
@@ -1833,6 +2456,10 @@ ${list}`;
1833
2456
  node = docs.find((n) => n.id === args.id);
1834
2457
  }
1835
2458
  if (!node) return `Node not found: ${args.title || args.id}`;
2459
+ const body = await resolveLlmBody(ctx, node);
2460
+ if (body === null) {
2461
+ return `Node "${node.frontmatter.title}" has no approved version yet \u2014 not available to AI.`;
2462
+ }
1836
2463
  const meta = [
1837
2464
  `**Title:** ${node.frontmatter.title}`,
1838
2465
  `**Type:** ${node.frontmatter.type || "document"}`,
@@ -1842,7 +2469,7 @@ ${list}`;
1842
2469
 
1843
2470
  ---
1844
2471
 
1845
- ${node.body || "(no content)"}`;
2472
+ ${body || "(no content)"}`;
1846
2473
  }
1847
2474
  case "context_list": {
1848
2475
  let docs = await storage.discoverDocuments();
@@ -1852,6 +2479,11 @@ ${node.body || "(no content)"}`;
1852
2479
  const tag = args.tag.startsWith("#") ? args.tag : `#${args.tag}`;
1853
2480
  docs = docs.filter((n) => n.frontmatter.tags?.includes(tag));
1854
2481
  }
2482
+ if (isStewardshipEnabled(nestId)) {
2483
+ docs = docs.filter(
2484
+ (n) => getApprovedVersion(nestId, n.id) != null
2485
+ );
2486
+ }
1855
2487
  docs = docs.slice(0, args.limit || 50);
1856
2488
  if (!docs.length) return "No nodes found with the given filters.";
1857
2489
  const list = docs.map(
@@ -1904,6 +2536,11 @@ ${n.body || ""}`;
1904
2536
  };
1905
2537
  await storage.writeDocument(id, serializeDocument3(node));
1906
2538
  syncNodeTags(nestId, id, tags);
2539
+ try {
2540
+ await versionManager.createVersion(node, userEmail);
2541
+ } catch (err) {
2542
+ console.error("VersionManager.createVersion failed (mcp create)", err);
2543
+ }
1907
2544
  createVersion({
1908
2545
  nestId,
1909
2546
  nodeId: id,
@@ -1934,16 +2571,45 @@ ${n.body || ""}`;
1934
2571
  );
1935
2572
  tags = [.../* @__PURE__ */ new Set([...tags, ...newTags])];
1936
2573
  }
2574
+ const prevVersion = getCurrentVersion(nestId, node.id);
2575
+ const newVersion = prevVersion + 1;
2576
+ const hasStewards = isStewardshipEnabled(nestId);
1937
2577
  const updated = {
1938
2578
  ...node,
1939
2579
  body,
1940
2580
  frontmatter: {
1941
2581
  ...node.frontmatter,
1942
2582
  tags,
2583
+ version: newVersion,
1943
2584
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
1944
2585
  }
1945
2586
  };
1946
2587
  await storage.writeDocument(node.id, serializeDocument3(updated));
2588
+ try {
2589
+ await versionManager.createVersion(updated, userEmail);
2590
+ } catch (err) {
2591
+ console.error("VersionManager.createVersion failed (mcp update)", err);
2592
+ }
2593
+ syncNodeTags(nestId, node.id, tags);
2594
+ createVersion({
2595
+ nestId,
2596
+ nodeId: node.id,
2597
+ version: newVersion,
2598
+ content: body,
2599
+ author: userEmail,
2600
+ status: hasStewards ? "draft" : "approved",
2601
+ tags
2602
+ });
2603
+ if (getPendingReview(nestId, node.id)) {
2604
+ cancelReview({
2605
+ nestId,
2606
+ nodeId: node.id,
2607
+ cancelledBy: userEmail
2608
+ });
2609
+ }
2610
+ if (!hasStewards) {
2611
+ setApprovedVersion(nestId, node.id, newVersion, userEmail);
2612
+ }
1947
2613
  return `Updated node: **${node.frontmatter.title}**`;
1948
2614
  }
1949
2615
  // ─── Governance Tool Handlers ──────────────────────────────────────
@@ -2153,6 +2819,7 @@ function createMcpServerForNest(nestId, userEmail) {
2153
2819
  const text = await handleToolCall(tool.name, args, {
2154
2820
  storage: engine.storage,
2155
2821
  queryEngine: engine.query,
2822
+ versionManager: engine.versions,
2156
2823
  nestId,
2157
2824
  userEmail
2158
2825
  });
@@ -2184,7 +2851,7 @@ mcpRoutes.all("/", async (c) => {
2184
2851
  import { Hono as Hono7 } from "hono";
2185
2852
 
2186
2853
  // src/governance/stewards-parser.ts
2187
- import { readFileSync, existsSync } from "fs";
2854
+ import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
2188
2855
  import { join as join3 } from "path";
2189
2856
  function parseStewardsYaml(content) {
2190
2857
  const result = { version: 1 };
@@ -2266,8 +2933,8 @@ function loadStewardsConfig(nestId) {
2266
2933
  join3(nestPath2, ".context", "stewards.yaml")
2267
2934
  ];
2268
2935
  for (const candidatePath of candidates) {
2269
- if (existsSync(candidatePath)) {
2270
- const content = readFileSync(candidatePath, "utf-8");
2936
+ if (existsSync2(candidatePath)) {
2937
+ const content = readFileSync2(candidatePath, "utf-8");
2271
2938
  return parseStewardsYaml(content);
2272
2939
  }
2273
2940
  }
@@ -2293,7 +2960,7 @@ governanceRoutes.post("/stewards", async (c) => {
2293
2960
  const assignedBy = getUserEmail3(c);
2294
2961
  if (!body.scope) throw new ValidationError("scope is required");
2295
2962
  if (Array.isArray(body.users)) {
2296
- const created2 = createStewardRecord({
2963
+ const created2 = await createStewardRecord({
2297
2964
  nestId,
2298
2965
  scope: body.scope,
2299
2966
  documentId: body.documentId,
@@ -2307,7 +2974,7 @@ governanceRoutes.post("/stewards", async (c) => {
2307
2974
  if (!body.email) {
2308
2975
  throw new ValidationError("users[] or email is required");
2309
2976
  }
2310
- const created = createStewardRecord({
2977
+ const created = await createStewardRecord({
2311
2978
  nestId,
2312
2979
  scope: body.scope,
2313
2980
  documentId: body.scope === "document" ? body.nodePattern : void 0,
@@ -2397,14 +3064,23 @@ governanceNodeRoutes.post("/:nodeId{.+}/submit-review", async (c) => {
2397
3064
  throw new ValidationError("Node has no versions to review");
2398
3065
  }
2399
3066
  const userEmail = getUserEmail3(c);
2400
- const request = submitForReview({
2401
- nestId,
2402
- nodeId,
2403
- version: currentVersion,
2404
- requestedBy: userEmail,
2405
- note: body.note,
2406
- priority: body.priority
2407
- });
3067
+ let request;
3068
+ try {
3069
+ request = submitForReview({
3070
+ nestId,
3071
+ nodeId,
3072
+ version: currentVersion,
3073
+ requestedBy: userEmail,
3074
+ note: body.note,
3075
+ priority: body.priority
3076
+ });
3077
+ } catch (err) {
3078
+ const msg = err instanceof Error ? err.message : String(err);
3079
+ if (/already pending/i.test(msg)) {
3080
+ throw new ConflictError(msg);
3081
+ }
3082
+ throw err;
3083
+ }
2408
3084
  const resolved = resolveStewardsForNode(nestId, nodeId);
2409
3085
  return c.json(
2410
3086
  {
@@ -2511,169 +3187,18 @@ function ensureAnonymousUser() {
2511
3187
  return ANON_USER_ID3;
2512
3188
  }
2513
3189
 
2514
- // src/auth/license.ts
2515
- var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
2516
- var suspensionFirstSeen = null;
2517
- var suspensionConfirmed = false;
2518
- var suspensionReason = null;
2519
- var SUSPENSION_CONFIRM_WINDOW_MS = 60 * 60 * 1e3;
2520
- function isSuspended() {
2521
- return suspensionConfirmed;
2522
- }
2523
- function getSuspensionReason() {
2524
- return suspensionReason;
2525
- }
2526
- async function validateLicense() {
2527
- const key = config.PROMPTOWL_KEY;
2528
- if (!key) {
2529
- return {
2530
- valid: false,
2531
- tier: "none",
2532
- org: null,
2533
- limits: null,
2534
- suspended: false,
2535
- suspendedReason: null
2536
- };
2537
- }
2538
- const db = getDb();
2539
- const cached = db.prepare("SELECT * FROM license_cache WHERE key = ?").get(key);
2540
- if (cached) {
2541
- const age = Date.now() - (/* @__PURE__ */ new Date(cached.validated_at + "Z")).getTime();
2542
- if (age < CACHE_TTL_MS) {
2543
- return {
2544
- valid: true,
2545
- tier: cached.tier,
2546
- org: cached.org,
2547
- limits: cached.limits_json ? JSON.parse(cached.limits_json) : null,
2548
- suspended: suspensionConfirmed,
2549
- suspendedReason: suspensionReason
2550
- };
2551
- }
2552
- }
2553
- try {
2554
- const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
2555
- const res = await fetch(`${promptowlUrl}/api/license/validate`, {
2556
- method: "POST",
2557
- headers: { "Content-Type": "application/json" },
2558
- body: JSON.stringify({ key })
2559
- });
2560
- if (!res.ok) {
2561
- if (cached) {
2562
- console.warn(
2563
- " PromptOwl unreachable, using cached license (grace period)"
2564
- );
2565
- return {
2566
- valid: true,
2567
- tier: cached.tier,
2568
- org: cached.org,
2569
- limits: cached.limits_json ? JSON.parse(cached.limits_json) : null,
2570
- suspended: suspensionConfirmed,
2571
- suspendedReason: suspensionReason
2572
- };
2573
- }
2574
- return {
2575
- valid: false,
2576
- tier: "none",
2577
- org: null,
2578
- limits: null,
2579
- suspended: false,
2580
- suspendedReason: null
2581
- };
2582
- }
2583
- const data = await res.json();
2584
- if (data.suspended === true) {
2585
- if (!suspensionFirstSeen) {
2586
- suspensionFirstSeen = Date.now();
2587
- suspensionReason = data.suspended_reason || "Suspended by PromptOwl";
2588
- console.warn(
2589
- `
2590
- WARNING: PromptOwl has flagged this server for suspension.`
2591
- );
2592
- console.warn(
2593
- ` Reason: ${suspensionReason}`
2594
- );
2595
- console.warn(
2596
- ` This will be confirmed in ~1 hour. If this is an error,`
2597
- );
2598
- console.warn(
2599
- ` contact support@promptowl.ai to reverse it.
2600
- `
2601
- );
2602
- } else if (Date.now() - suspensionFirstSeen >= SUSPENSION_CONFIRM_WINDOW_MS) {
2603
- suspensionConfirmed = true;
2604
- suspensionReason = data.suspended_reason || "Suspended by PromptOwl";
2605
- console.error(
2606
- `
2607
- SERVER SUSPENDED: ${suspensionReason}`
2608
- );
2609
- console.error(
2610
- ` Write operations are disabled. Reads still work.`
2611
- );
2612
- console.error(
2613
- ` Contact support@promptowl.ai to resolve.
2614
- `
2615
- );
2616
- }
2617
- } else {
2618
- if (suspensionFirstSeen) {
2619
- console.log(" Suspension flag cleared by PromptOwl.");
2620
- }
2621
- suspensionFirstSeen = null;
2622
- suspensionConfirmed = false;
2623
- suspensionReason = null;
2624
- }
2625
- if (!data.valid && !data.suspended) {
2626
- return {
2627
- valid: false,
2628
- tier: "none",
2629
- org: null,
2630
- limits: null,
2631
- suspended: false,
2632
- suspendedReason: null
2633
- };
2634
- }
2635
- if (data.valid) {
2636
- const limitsJson = data.limits ? JSON.stringify(data.limits) : null;
2637
- db.prepare(
2638
- `INSERT OR REPLACE INTO license_cache (key, tier, org, limits_json, validated_at)
2639
- VALUES (?, ?, ?, ?, datetime('now'))`
2640
- ).run(key, data.tier || "community", data.org || null, limitsJson);
2641
- }
2642
- return {
2643
- valid: data.valid !== false,
2644
- tier: data.tier || "community",
2645
- org: data.org || null,
2646
- limits: data.limits || null,
2647
- suspended: suspensionConfirmed,
2648
- suspendedReason: suspensionReason
2649
- };
2650
- } catch (err) {
2651
- if (cached) {
2652
- console.warn(
2653
- ` PromptOwl validation failed (${err.message}), using cached license`
2654
- );
2655
- return {
2656
- valid: true,
2657
- tier: cached.tier,
2658
- org: cached.org,
2659
- limits: cached.limits_json ? JSON.parse(cached.limits_json) : null,
2660
- suspended: suspensionConfirmed,
2661
- suspendedReason: suspensionReason
2662
- };
2663
- }
2664
- return {
2665
- valid: false,
2666
- tier: "none",
2667
- org: null,
2668
- limits: null,
2669
- suspended: false,
2670
- suspendedReason: null
2671
- };
2672
- }
2673
- }
2674
-
2675
3190
  // src/app.ts
2676
3191
  import { serveStatic } from "@hono/node-server/serve-static";
3192
+ import { fileURLToPath } from "url";
3193
+ import { dirname, join as join4, relative } from "path";
3194
+ import { existsSync as existsSync3 } from "fs";
3195
+ var HERE = dirname(fileURLToPath(import.meta.url));
3196
+ var UI_DIR_CANDIDATES = [
3197
+ join4(HERE, "web3"),
3198
+ join4(process.cwd(), "dist", "web3")
3199
+ ];
3200
+ var UI_DIR_ABS = UI_DIR_CANDIDATES.find((p) => existsSync3(p)) || UI_DIR_CANDIDATES[0];
3201
+ var UI_DIR_REL = relative(process.cwd(), UI_DIR_ABS) || ".";
2677
3202
  var openModeMiddleware = createMiddleware2(async (c, next) => {
2678
3203
  const anonId = ensureAnonymousUser();
2679
3204
  c.set("userId", anonId);
@@ -2681,8 +3206,9 @@ var openModeMiddleware = createMiddleware2(async (c, next) => {
2681
3206
  await next();
2682
3207
  });
2683
3208
  var flexAuthMiddleware = createMiddleware2(async (c, next) => {
2684
- const header = c.req.header("Authorization");
2685
- if (header?.startsWith("Bearer cnst_")) {
3209
+ const hasBearer = c.req.header("Authorization")?.startsWith("Bearer cnst_");
3210
+ const hasCookie = !!c.req.header("Cookie")?.includes("cnst_session=");
3211
+ if (hasBearer || hasCookie) {
2686
3212
  return authMiddleware(c, next);
2687
3213
  }
2688
3214
  if (config.AUTH_MODE === "open") {
@@ -2691,11 +3217,10 @@ var flexAuthMiddleware = createMiddleware2(async (c, next) => {
2691
3217
  c.set("nestScope", null);
2692
3218
  return next();
2693
3219
  }
2694
- return c.json({ error: "Missing or invalid API key" }, 401);
3220
+ return c.json({ error: "Missing or invalid credentials" }, 401);
2695
3221
  });
2696
3222
  function createApp() {
2697
3223
  const app = new Hono8();
2698
- app.use("*", logger());
2699
3224
  const corsOrigins = config.CORS_ORIGINS;
2700
3225
  app.use(
2701
3226
  "*",
@@ -2726,7 +3251,79 @@ function createApp() {
2726
3251
  ...isSuspended() && { suspended_reason: getSuspensionReason() }
2727
3252
  })
2728
3253
  );
3254
+ app.use("/auth/invite", async (c, next) => {
3255
+ if (!getCurrentLicense()?.valid) {
3256
+ return c.json(
3257
+ {
3258
+ error: "License required",
3259
+ reason: "Install a valid PromptOwl license key before inviting teammates."
3260
+ },
3261
+ 503
3262
+ );
3263
+ }
3264
+ return next();
3265
+ });
3266
+ app.use("/auth/teammates", async (c, next) => {
3267
+ if (!getCurrentLicense()?.valid) {
3268
+ return c.json(
3269
+ {
3270
+ error: "License required",
3271
+ reason: "Install a valid PromptOwl license key to view teammates."
3272
+ },
3273
+ 503
3274
+ );
3275
+ }
3276
+ return next();
3277
+ });
2729
3278
  app.route("/auth", authRoutes);
3279
+ app.get("/license/status", (c) => {
3280
+ const lic = getCurrentLicense();
3281
+ return c.json({
3282
+ valid: !!lic?.valid,
3283
+ tier: lic?.tier || "none",
3284
+ org: lic?.org || null,
3285
+ owner_email: lic?.ownerEmail || null,
3286
+ suspended: !!lic?.suspended,
3287
+ suspended_reason: lic?.suspendedReason || null,
3288
+ limits: lic?.limits || null
3289
+ });
3290
+ });
3291
+ app.post("/license/install", async (c) => {
3292
+ let body;
3293
+ try {
3294
+ body = await c.req.json();
3295
+ } catch {
3296
+ return c.json({ error: "Invalid JSON body" }, 400);
3297
+ }
3298
+ const key = body?.key?.trim();
3299
+ if (!key || !key.startsWith("pk_")) {
3300
+ return c.json(
3301
+ { error: "Invalid license key. Must start with pk_." },
3302
+ 400
3303
+ );
3304
+ }
3305
+ try {
3306
+ const info = await installLicenseKey(key);
3307
+ if (!info.valid) {
3308
+ return c.json(
3309
+ {
3310
+ valid: false,
3311
+ error: "License key was saved but PromptOwl rejected it. Verify the key is correct and active."
3312
+ },
3313
+ 400
3314
+ );
3315
+ }
3316
+ return c.json({
3317
+ valid: true,
3318
+ tier: info.tier,
3319
+ org: info.org,
3320
+ limits: info.limits
3321
+ });
3322
+ } catch (err) {
3323
+ const msg = err instanceof Error ? err.message : "Failed to install key";
3324
+ return c.json({ error: msg }, 500);
3325
+ }
3326
+ });
2730
3327
  const nestsApp = new Hono8();
2731
3328
  nestsApp.use("*", flexAuthMiddleware);
2732
3329
  nestsApp.use("*", async (c, next) => {
@@ -2749,6 +3346,24 @@ function createApp() {
2749
3346
  503
2750
3347
  );
2751
3348
  }
3349
+ {
3350
+ const path2 = c.req.path;
3351
+ 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");
3352
+ const needsLicense = c.req.method !== "GET" || isGovernance;
3353
+ if (needsLicense) {
3354
+ const lic = getCurrentLicense();
3355
+ if (!lic?.valid) {
3356
+ return c.json(
3357
+ {
3358
+ error: "License required",
3359
+ reason: "Install a PromptOwl license key via the setup screen or POST /license/install.",
3360
+ setup_url: "/setup"
3361
+ },
3362
+ 503
3363
+ );
3364
+ }
3365
+ }
3366
+ }
2752
3367
  if (config.AUTH_MODE === "open") {
2753
3368
  c.set("nestPermission", "owner");
2754
3369
  return next();
@@ -2759,13 +3374,21 @@ function createApp() {
2759
3374
  }
2760
3375
  let required = "read";
2761
3376
  const path = c.req.path;
3377
+ const isStewardActionPath = path.includes("/approve") || path.includes("/reject") || path.includes("/submit-review") || path.includes("/cancel-review");
2762
3378
  if (path.includes("/collaborators") || path.includes("/visibility")) {
2763
3379
  required = "admin";
2764
- } else if (c.req.method !== "GET") {
3380
+ } else if (c.req.method !== "GET" && !isStewardActionPath) {
2765
3381
  required = "write";
2766
3382
  }
2767
3383
  if (permissionLevel(permission) < permissionLevel(required)) {
2768
- return c.json({ error: "Insufficient permissions" }, 403);
3384
+ return c.json(
3385
+ {
3386
+ 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.`,
3387
+ required_permission: required,
3388
+ your_permission: permission
3389
+ },
3390
+ 403
3391
+ );
2769
3392
  }
2770
3393
  c.set("nestPermission", permission);
2771
3394
  return next();
@@ -2778,14 +3401,80 @@ function createApp() {
2778
3401
  nestsApp.route("/:nestId", sharingRoutes);
2779
3402
  nestsApp.route("/:nestId/mcp", mcpRoutes);
2780
3403
  app.route("/nests", nestsApp);
2781
- app.use("/assets/*", serveStatic({ root: "./dist/web3" }));
2782
- app.get("*", serveStatic({ root: "./dist/web3", path: "index.html" }));
3404
+ app.use("/assets/*", serveStatic({ root: UI_DIR_REL }));
3405
+ app.get("*", serveStatic({ root: UI_DIR_REL, path: "index.html" }));
2783
3406
  app.onError((err, c) => {
2784
3407
  if (err instanceof AppError) {
2785
3408
  return c.json({ error: err.message }, err.statusCode);
2786
3409
  }
3410
+ const e = err;
3411
+ const code = e.code;
3412
+ const msg = e.message || "";
3413
+ if (code === "SQLITE_CONSTRAINT_UNIQUE") {
3414
+ let friendly = "That conflicts with existing data. Please change a value and try again.";
3415
+ if (msg.includes("node_versions")) {
3416
+ 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.";
3417
+ } else if (msg.includes("review_requests")) {
3418
+ friendly = "A review request for this node already exists.";
3419
+ } else if (msg.includes("nests")) {
3420
+ friendly = "A nest with that name already exists.";
3421
+ } else if (msg.includes("users")) {
3422
+ friendly = "An account with that email already exists.";
3423
+ } else if (msg.includes("stewards")) {
3424
+ friendly = "That steward is already assigned with the same scope.";
3425
+ }
3426
+ return c.json({ error: friendly }, 409);
3427
+ }
3428
+ if (code === "SQLITE_CONSTRAINT_FOREIGNKEY") {
3429
+ return c.json(
3430
+ {
3431
+ error: "Required related record is missing. Ensure the parent resource still exists."
3432
+ },
3433
+ 409
3434
+ );
3435
+ }
3436
+ if (code === "SQLITE_CONSTRAINT_CHECK") {
3437
+ return c.json(
3438
+ {
3439
+ error: "Provided value isn't allowed for one of the fields. Double-check your input."
3440
+ },
3441
+ 400
3442
+ );
3443
+ }
3444
+ if (code === "SQLITE_CONSTRAINT_NOTNULL") {
3445
+ return c.json(
3446
+ { error: "A required field is missing. Please fill it in and retry." },
3447
+ 400
3448
+ );
3449
+ }
3450
+ if (code === "ENOENT") {
3451
+ return c.json(
3452
+ { error: "The requested file or folder doesn't exist." },
3453
+ 404
3454
+ );
3455
+ }
3456
+ if (code === "EACCES" || code === "EPERM") {
3457
+ return c.json(
3458
+ { error: "Server doesn't have permission to access that file." },
3459
+ 500
3460
+ );
3461
+ }
3462
+ if (code === "ENOSPC") {
3463
+ return c.json(
3464
+ { error: "Out of disk space on the server. Free up space and retry." },
3465
+ 507
3466
+ );
3467
+ }
3468
+ if (err instanceof SyntaxError && msg.toLowerCase().includes("json")) {
3469
+ return c.json({ error: "Request body isn't valid JSON." }, 400);
3470
+ }
2787
3471
  console.error("Unhandled error:", err);
2788
- return c.json({ error: "Internal server error" }, 500);
3472
+ return c.json(
3473
+ {
3474
+ error: "Something went wrong on the server. Try again \u2014 if it keeps failing, check the server log."
3475
+ },
3476
+ 500
3477
+ );
2789
3478
  });
2790
3479
  return app;
2791
3480
  }
@@ -2801,16 +3490,19 @@ async function main() {
2801
3490
  if (!license.valid) {
2802
3491
  console.warn(`
2803
3492
  WARNING: No valid PromptOwl license key found.
2804
- The server will run, but some features may be limited.
3493
+ Server boots in SETUP MODE \u2014 writes return 503 and admin features
3494
+ (stewards, teammates, governance) are locked until a key is installed.
2805
3495
 
2806
- To register:
3496
+ To activate:
2807
3497
  1. Sign up at https://app.promptowl.ai
2808
- 2. Go to Settings > License Keys
2809
- 3. Create a Community Server key
2810
- 4. Set PROMPTOWL_KEY=pk_... in your environment
3498
+ 2. Open Overview > Community License
3499
+ 3. Generate a key
3500
+ 4. Paste it into the setup screen (UI) or POST /license/install,
3501
+ or set PROMPTOWL_KEY=pk_... in your environment and restart.
2811
3502
  `);
2812
3503
  }
2813
3504
  const app = createApp();
3505
+ startLicenseWatcher();
2814
3506
  startTelemetryLoop();
2815
3507
  trackEvent("server.start", {
2816
3508
  tier: license.tier,