@promptowl/contextnest-community 1.10.0 → 1.12.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
@@ -28,7 +28,7 @@ import {
28
28
  seedDefaultEdgeTypes,
29
29
  submitForReview,
30
30
  verifySmtp
31
- } from "./chunk-ZM4F7MY7.js";
31
+ } from "./chunk-XB7T3THE.js";
32
32
  import {
33
33
  createGrant,
34
34
  deleteGrant,
@@ -37,7 +37,7 @@ import {
37
37
  listGrants,
38
38
  listUserGrants,
39
39
  resolveNodeGrant
40
- } from "./chunk-7PQREKSM.js";
40
+ } from "./chunk-ZMSU7BCC.js";
41
41
  import {
42
42
  generateApiKey,
43
43
  getKeyPrefix,
@@ -46,9 +46,6 @@ import {
46
46
  parseBearerToken,
47
47
  verifyPassword
48
48
  } from "./chunk-XRK6SQSC.js";
49
- import {
50
- normalizeEmail
51
- } from "./chunk-FRQJWGN3.js";
52
49
  import {
53
50
  checkConflict,
54
51
  createVersion,
@@ -57,8 +54,10 @@ import {
57
54
  getDisplayStatus,
58
55
  getVersions,
59
56
  setApprovedVersion
60
- } from "./chunk-UHUU3VAK.js";
57
+ } from "./chunk-JHIU6RZU.js";
61
58
  import {
59
+ addMember,
60
+ buildTitleMap,
62
61
  canCreateInNest,
63
62
  canManageStewards,
64
63
  canUserAccess,
@@ -67,10 +66,14 @@ import {
67
66
  collabPermToRole,
68
67
  createNest,
69
68
  createStewardRecord,
69
+ createTeam,
70
70
  deleteNest,
71
+ deleteTeam,
71
72
  disableStewardshipAndWipeGovernance,
72
73
  docLink,
73
74
  engineCache,
75
+ findTeamByExternalId,
76
+ getAccessConfig,
74
77
  getCollaboratorRole,
75
78
  getCurrentLicense,
76
79
  getNest,
@@ -78,50 +81,68 @@ import {
78
81
  getStewardsForNest,
79
82
  getStewardsForUser,
80
83
  getSuspensionReason,
84
+ getTeam,
81
85
  importNest,
82
86
  installLicenseKey,
87
+ isConfigSuperAdmin,
83
88
  isLicenseAdminEmail,
84
89
  isLicenseAdminUserId,
85
90
  isPublicReader,
91
+ isServerAdminUserId,
86
92
  isStewardshipEnabled,
87
93
  isSuperAdmin,
88
94
  isSuspended,
95
+ listNestTeams,
89
96
  listNests,
90
97
  listPublicNests,
91
98
  listSharedNests,
92
99
  listStewards,
100
+ listTeamsForUser,
93
101
  loadAccessConfig,
102
+ loadGrantedSuperAdmins,
94
103
  loadServerSettings,
95
104
  nestAllowsSelfApprove,
96
105
  nestName,
97
106
  nestStorageRoot,
98
107
  permissionLevel,
99
108
  persistSetting,
109
+ removeMember,
100
110
  removeSteward,
101
111
  renameNest,
112
+ renameTeam,
113
+ replaceTeamRoster,
114
+ resolveMemberIdentity,
102
115
  resolveNestPath,
103
116
  resolveNestPermission,
104
117
  resolveStewardsForNode,
105
118
  resolveStewardsWithFallback,
119
+ resolveTeamRolesForUser,
106
120
  resolveUserRoles,
107
121
  setAllowSelfApprove,
108
122
  setStewardshipEnabled,
123
+ shareTeamWithNest,
109
124
  startLicenseSafetyPoll,
110
125
  startTelemetryLoop,
111
126
  syncFromConfig,
112
127
  trackEvent,
113
128
  uniqueNestName,
129
+ unshareTeamFromNest,
130
+ updateMemberRole,
114
131
  updateSteward,
115
132
  validateLicense
116
- } from "./chunk-KFGZIECB.js";
133
+ } from "./chunk-F7EKYNXG.js";
117
134
  import {
118
135
  AppError,
119
136
  ConflictError,
120
137
  ForbiddenError,
121
138
  LockedError,
122
139
  NotFoundError,
140
+ UnauthorizedError,
123
141
  ValidationError
124
- } from "./chunk-3JTODC3Y.js";
142
+ } from "./chunk-GUNJTORH.js";
143
+ import {
144
+ normalizeEmail
145
+ } from "./chunk-FRQJWGN3.js";
125
146
  import {
126
147
  insertOrIgnore,
127
148
  nowExpr
@@ -132,7 +153,7 @@ import {
132
153
  initDb,
133
154
  isEmailListish,
134
155
  isEmailish
135
- } from "./chunk-BC6KFUZH.js";
156
+ } from "./chunk-3M7677XW.js";
136
157
  import {
137
158
  ANON_EMAIL,
138
159
  ANON_USER_ID
@@ -142,14 +163,14 @@ import {
142
163
  import { serve } from "@hono/node-server";
143
164
 
144
165
  // src/app.ts
145
- import { Hono as Hono18 } from "hono";
166
+ import { Hono as Hono21 } from "hono";
146
167
  import { LinearRouter } from "hono/router/linear-router";
147
168
  import { createMiddleware as createMiddleware2 } from "hono/factory";
148
169
  import { cors } from "hono/cors";
149
170
 
150
171
  // src/auth/routes.ts
151
- import { Hono } from "hono";
152
- import { v4 as uuid } from "uuid";
172
+ import { Hono as Hono2 } from "hono";
173
+ import { v4 as uuid2 } from "uuid";
153
174
 
154
175
  // src/auth/middleware.ts
155
176
  import { createMiddleware } from "hono/factory";
@@ -173,6 +194,12 @@ async function createSession(userId, userAgent) {
173
194
  VALUES (?, ?, ?, ?)`,
174
195
  [id, userId, expiryIso(), userAgent || null]
175
196
  );
197
+ try {
198
+ await db.run("UPDATE users SET is_invited = 0 WHERE id = ? AND is_invited = 1", [
199
+ userId
200
+ ]);
201
+ } catch {
202
+ }
176
203
  return id;
177
204
  }
178
205
  async function resolveSession(id) {
@@ -227,6 +254,53 @@ function buildSessionCookie(id, opts = {}) {
227
254
  function buildClearSessionCookie(opts = {}) {
228
255
  return buildSessionCookie("", { secure: opts.secure, maxAgeSeconds: 0 });
229
256
  }
257
+ var OIDC_SESSION_MARKER_COOKIE = "cnst_sso_session";
258
+ var PROMPTOWL_TOKEN_COOKIE = "cnst_po_token";
259
+ var PROMPTOWL_TOKEN_COOKIE_PATH = "/teams";
260
+ function buildPromptowlTokenCookie(token, opts = {}) {
261
+ const parts = [
262
+ `${PROMPTOWL_TOKEN_COOKIE}=${opts.clear ? "" : encodeURIComponent(token)}`,
263
+ "HttpOnly",
264
+ `Path=${PROMPTOWL_TOKEN_COOKIE_PATH}`,
265
+ "SameSite=Lax",
266
+ `Max-Age=${opts.clear ? 0 : SESSION_TTL_SECS}`
267
+ ];
268
+ if (opts.secure) parts.push("Secure");
269
+ return parts.join("; ");
270
+ }
271
+ var PROMPTOWL_MARKER_COOKIE = "cnst_po_session";
272
+ function buildPromptowlMarkerCookie(opts = {}) {
273
+ const parts = [
274
+ `${PROMPTOWL_MARKER_COOKIE}=${opts.clear ? "" : "1"}`,
275
+ "Path=/",
276
+ "SameSite=Lax",
277
+ `Max-Age=${opts.clear ? 0 : SESSION_TTL_SECS}`
278
+ ];
279
+ if (opts.secure) parts.push("Secure");
280
+ return parts.join("; ");
281
+ }
282
+ function getPromptowlTokenFromRequest(c) {
283
+ const header = c.req.header("Cookie");
284
+ if (!header) return null;
285
+ for (const part of header.split(";")) {
286
+ const [k, ...rest] = part.trim().split("=");
287
+ if (k === PROMPTOWL_TOKEN_COOKIE) {
288
+ const v = decodeURIComponent(rest.join("="));
289
+ return /^po_device_[A-Za-z0-9_-]+$/.test(v) ? v : null;
290
+ }
291
+ }
292
+ return null;
293
+ }
294
+ function buildOidcMarkerCookie(opts = {}) {
295
+ const parts = [
296
+ `${OIDC_SESSION_MARKER_COOKIE}=${opts.clear ? "" : "1"}`,
297
+ "Path=/",
298
+ "SameSite=Lax",
299
+ `Max-Age=${opts.clear ? 0 : SESSION_TTL_SECS}`
300
+ ];
301
+ if (opts.secure) parts.push("Secure");
302
+ return parts.join("; ");
303
+ }
230
304
  function isSecureRequest(c) {
231
305
  const proto = c.req.header("x-forwarded-proto");
232
306
  if (proto) return proto.split(",")[0].trim() === "https";
@@ -357,11 +431,349 @@ function verifySsoTicket(token, secret, nowSec = Math.floor(Date.now() / 1e3)) {
357
431
  return claims;
358
432
  }
359
433
 
360
- // src/auth/routes.ts
434
+ // src/auth/oidc.ts
435
+ import { Hono } from "hono";
436
+ import { createHash, randomBytes as randomBytes2 } from "crypto";
437
+ import { v4 as uuid } from "uuid";
438
+ import { createRemoteJWKSet, jwtVerify } from "jose";
439
+
440
+ // src/shared/base-url.ts
441
+ function requestBaseUrl(reqUrl) {
442
+ if (config.PUBLIC_BASE_URL) return config.PUBLIC_BASE_URL;
443
+ try {
444
+ return new URL(reqUrl).origin;
445
+ } catch {
446
+ return "";
447
+ }
448
+ }
449
+
450
+ // src/shared/request-ip.ts
361
451
  import { getConnInfo } from "@hono/node-server/conninfo";
452
+ function clientIp(c) {
453
+ const xff = c.req.header("x-forwarded-for");
454
+ if (xff) return xff.split(",")[0].trim();
455
+ const realIp = c.req.header("x-real-ip");
456
+ if (realIp) return realIp.trim();
457
+ try {
458
+ const addr = getConnInfo(c).remote.address;
459
+ if (addr) return addr;
460
+ } catch {
461
+ }
462
+ return "unknown";
463
+ }
464
+
465
+ // src/auth/oidc.ts
466
+ var OIDC_LIMIT = { max: 100, windowMs: 15 * 6e4 };
467
+ var IDP_FETCH_TIMEOUT_MS = 1e4;
468
+ var STATE_COOKIE = "cnst_oidc_state";
469
+ var NONCE_COOKIE = "cnst_oidc_nonce";
470
+ var VERIFIER_COOKIE = "cnst_oidc_verifier";
471
+ var FLOW_COOKIE_TTL_SECS = 10 * 60;
472
+ var DISCOVERY_TTL_MS = 5 * 6e4;
473
+ var discoveryCache = null;
474
+ function isOidcReady() {
475
+ return config.OIDC_ENABLED && !!config.OIDC_ISSUER && !!config.OIDC_CLIENT_ID && !!config.OIDC_CLIENT_SECRET;
476
+ }
477
+ async function discover(issuer) {
478
+ const now = Date.now();
479
+ if (discoveryCache && discoveryCache.issuer === issuer && now - discoveryCache.fetchedAt < DISCOVERY_TTL_MS) {
480
+ return { doc: discoveryCache.doc, jwks: discoveryCache.jwks };
481
+ }
482
+ const res = await fetch(`${issuer}/.well-known/openid-configuration`, {
483
+ headers: { Accept: "application/json" },
484
+ signal: AbortSignal.timeout(IDP_FETCH_TIMEOUT_MS)
485
+ });
486
+ if (!res.ok) {
487
+ throw new Error(`discovery fetch failed with status ${res.status}`);
488
+ }
489
+ const doc = await res.json();
490
+ if (!doc.issuer || !doc.authorization_endpoint || !doc.token_endpoint || !doc.jwks_uri) {
491
+ throw new Error("discovery document is missing required fields");
492
+ }
493
+ if (doc.issuer.replace(/\/$/, "") !== issuer.replace(/\/$/, "")) {
494
+ throw new Error(
495
+ `discovery issuer mismatch: expected ${issuer}, got ${doc.issuer}`
496
+ );
497
+ }
498
+ const jwks = createRemoteJWKSet(new URL(doc.jwks_uri));
499
+ discoveryCache = {
500
+ issuer,
501
+ doc,
502
+ jwks,
503
+ fetchedAt: now
504
+ };
505
+ return { doc, jwks };
506
+ }
507
+ function base64url(buf) {
508
+ return buf.toString("base64url");
509
+ }
510
+ function pkceChallenge(verifier) {
511
+ return createHash("sha256").update(verifier).digest("base64url");
512
+ }
513
+ var DEPARTMENT_MAX_LENGTH = 120;
514
+ function normalizeDepartment(claim) {
515
+ if (typeof claim !== "string") return null;
516
+ const collapsed = claim.replace(/\s+/g, " ").trim();
517
+ if (!collapsed) return null;
518
+ return collapsed.slice(0, DEPARTMENT_MAX_LENGTH).trim();
519
+ }
520
+ function buildFlowCookie(name, value, opts) {
521
+ const parts = [
522
+ `${name}=${value}`,
523
+ "HttpOnly",
524
+ "Path=/auth/oidc",
525
+ "SameSite=Lax",
526
+ `Max-Age=${opts.clear ? 0 : FLOW_COOKIE_TTL_SECS}`
527
+ ];
528
+ if (opts.secure) parts.push("Secure");
529
+ return parts.join("; ");
530
+ }
531
+ function readCookie(c, name) {
532
+ const header = c.req.header("Cookie");
533
+ if (!header) return null;
534
+ for (const part of header.split(";")) {
535
+ const [k, ...rest] = part.trim().split("=");
536
+ if (k === name) {
537
+ const v = rest.join("=").trim();
538
+ return /^[A-Za-z0-9_-]{16,256}$/.test(v) ? v : null;
539
+ }
540
+ }
541
+ return null;
542
+ }
543
+ function oidcRedirectUri(reqUrl) {
544
+ return `${requestBaseUrl(reqUrl)}/auth/oidc/callback`;
545
+ }
546
+ var oidcRoutes = new Hono();
547
+ oidcRoutes.get("/login", async (c) => {
548
+ const bounce = (code) => c.redirect(`/?sso_error=${code}`, 302);
549
+ if (!config.OIDC_ENABLED) return bounce("disabled");
550
+ if (!isOidcReady()) return bounce("not_configured");
551
+ if (!tryConsume(`oidc-login:ip:${clientIp(c)}`, OIDC_LIMIT)) {
552
+ return bounce("rate_limited");
553
+ }
554
+ let doc;
555
+ try {
556
+ ({ doc } = await discover(config.OIDC_ISSUER));
557
+ } catch (err) {
558
+ console.error("[oidc] discovery failed:", err);
559
+ return bounce("discovery_failed");
560
+ }
561
+ const state = base64url(randomBytes2(24));
562
+ const nonce = base64url(randomBytes2(24));
563
+ const verifier = base64url(randomBytes2(32));
564
+ const secure = isSecureRequest(c);
565
+ c.header("Set-Cookie", buildFlowCookie(STATE_COOKIE, state, { secure }), {
566
+ append: true
567
+ });
568
+ c.header("Set-Cookie", buildFlowCookie(NONCE_COOKIE, nonce, { secure }), {
569
+ append: true
570
+ });
571
+ c.header(
572
+ "Set-Cookie",
573
+ buildFlowCookie(VERIFIER_COOKIE, verifier, { secure }),
574
+ { append: true }
575
+ );
576
+ const authorizeUrl = new URL(doc.authorization_endpoint);
577
+ authorizeUrl.searchParams.set("client_id", config.OIDC_CLIENT_ID);
578
+ authorizeUrl.searchParams.set("redirect_uri", oidcRedirectUri(c.req.url));
579
+ authorizeUrl.searchParams.set("response_type", "code");
580
+ authorizeUrl.searchParams.set("scope", "openid email profile");
581
+ authorizeUrl.searchParams.set("state", state);
582
+ authorizeUrl.searchParams.set("nonce", nonce);
583
+ authorizeUrl.searchParams.set("code_challenge", pkceChallenge(verifier));
584
+ authorizeUrl.searchParams.set("code_challenge_method", "S256");
585
+ return c.redirect(authorizeUrl.toString(), 302);
586
+ });
587
+ oidcRoutes.get("/callback", async (c) => {
588
+ const secure = isSecureRequest(c);
589
+ const clearFlowCookies = () => {
590
+ for (const name2 of [STATE_COOKIE, NONCE_COOKIE, VERIFIER_COOKIE]) {
591
+ c.header("Set-Cookie", buildFlowCookie(name2, "", { secure, clear: true }), {
592
+ append: true
593
+ });
594
+ }
595
+ };
596
+ const bounce = (code2) => {
597
+ clearFlowCookies();
598
+ return c.redirect(`/?sso_error=${code2}`, 302);
599
+ };
600
+ if (!config.OIDC_ENABLED) return bounce("disabled");
601
+ if (!isOidcReady()) return bounce("not_configured");
602
+ if (!tryConsume(`oidc-cb:ip:${clientIp(c)}`, OIDC_LIMIT)) {
603
+ return bounce("rate_limited");
604
+ }
605
+ if (c.req.query("error")) return bounce("provider_error");
606
+ const stateParam = c.req.query("state") || "";
607
+ const stateCookie = readCookie(c, STATE_COOKIE);
608
+ if (!stateCookie || !stateParam || stateParam !== stateCookie) {
609
+ return bounce("state_mismatch");
610
+ }
611
+ const code = c.req.query("code");
612
+ if (!code) return bounce("exchange_failed");
613
+ const nonceCookie = readCookie(c, NONCE_COOKIE);
614
+ const verifierCookie = readCookie(c, VERIFIER_COOKIE);
615
+ if (!nonceCookie || !verifierCookie) return bounce("state_mismatch");
616
+ let doc;
617
+ let jwks;
618
+ try {
619
+ ({ doc, jwks } = await discover(config.OIDC_ISSUER));
620
+ } catch (err) {
621
+ console.error("[oidc] discovery failed:", err);
622
+ return bounce("discovery_failed");
623
+ }
624
+ let idToken;
625
+ try {
626
+ const tokenRes = await fetch(doc.token_endpoint, {
627
+ method: "POST",
628
+ headers: {
629
+ "Content-Type": "application/x-www-form-urlencoded",
630
+ Accept: "application/json"
631
+ },
632
+ body: new URLSearchParams({
633
+ grant_type: "authorization_code",
634
+ code,
635
+ redirect_uri: oidcRedirectUri(c.req.url),
636
+ client_id: config.OIDC_CLIENT_ID,
637
+ client_secret: config.OIDC_CLIENT_SECRET,
638
+ code_verifier: verifierCookie
639
+ }).toString(),
640
+ signal: AbortSignal.timeout(IDP_FETCH_TIMEOUT_MS)
641
+ });
642
+ if (!tokenRes.ok) {
643
+ console.error(
644
+ `[oidc] token exchange failed with status ${tokenRes.status}`
645
+ );
646
+ return bounce("exchange_failed");
647
+ }
648
+ const tokens = await tokenRes.json();
649
+ if (!tokens.id_token || typeof tokens.id_token !== "string") {
650
+ return bounce("exchange_failed");
651
+ }
652
+ idToken = tokens.id_token;
653
+ } catch (err) {
654
+ console.error("[oidc] token exchange failed:", err);
655
+ return bounce("exchange_failed");
656
+ }
657
+ let claims;
658
+ try {
659
+ const { payload } = await jwtVerify(idToken, jwks, {
660
+ issuer: doc.issuer,
661
+ audience: config.OIDC_CLIENT_ID,
662
+ // Explicit allowlist on top of the JWKS constraint. createRemoteJWKSet
663
+ // already refuses keys the issuer doesn't publish, so alg-confusion is
664
+ // blocked either way; this pins the header alg so a JWKS that ever
665
+ // starts publishing an unexpected key type can't shift us onto it.
666
+ // RS256 is what every supported preset emits (Entra v2, Google, Okta,
667
+ // Keycloak default) and is the OIDC-mandated baseline.
668
+ algorithms: ["RS256"]
669
+ });
670
+ claims = payload;
671
+ } catch (err) {
672
+ console.error("[oidc] ID token validation failed:", err);
673
+ return bounce("invalid_token");
674
+ }
675
+ if (claims.nonce !== nonceCookie) return bounce("invalid_token");
676
+ if (claims.email_verified === false || claims.email_verified === "false") {
677
+ return bounce("email_not_verified");
678
+ }
679
+ const rawEmail = typeof claims.email === "string" && claims.email || typeof claims.preferred_username === "string" && claims.preferred_username || "";
680
+ const email = normalizeEmail(rawEmail);
681
+ if (!email || !isEmailish(email)) return bounce("invalid_token");
682
+ const allowed = config.OIDC_ALLOWED_DOMAINS;
683
+ if (allowed.length > 0) {
684
+ const domain = email.slice(email.lastIndexOf("@") + 1);
685
+ if (!allowed.includes(domain)) return bounce("domain_not_allowed");
686
+ }
687
+ const name = typeof claims.name === "string" && claims.name.trim() ? claims.name.trim() : null;
688
+ const department = normalizeDepartment(claims.department);
689
+ try {
690
+ const db = getDb();
691
+ let user = await db.get(
692
+ "SELECT id, email, name FROM users WHERE LOWER(email) = ?",
693
+ [email]
694
+ );
695
+ if (!user) {
696
+ if (!config.OIDC_AUTO_PROVISION) return bounce("not_invited");
697
+ const userId = uuid();
698
+ const placeholderHash = await hashPassword(uuid());
699
+ try {
700
+ await db.run(
701
+ "INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)",
702
+ [userId, email, name, placeholderHash]
703
+ );
704
+ user = { id: userId, email, name };
705
+ trackEvent("user.register", { userId, email, method: "oidc" });
706
+ } catch (insErr) {
707
+ const raced = await db.get(
708
+ "SELECT id, email, name FROM users WHERE LOWER(email) = ?",
709
+ [email]
710
+ );
711
+ if (!raced) throw insErr;
712
+ user = raced;
713
+ trackEvent("user.login", { userId: user.id, method: "oidc" });
714
+ }
715
+ } else {
716
+ trackEvent("user.login", { userId: user.id, method: "oidc" });
717
+ }
718
+ try {
719
+ await db.run("UPDATE users SET department = ? WHERE id = ?", [
720
+ department,
721
+ user.id
722
+ ]);
723
+ } catch (err) {
724
+ console.error("[oidc] department upsert failed (login continues):", err);
725
+ }
726
+ const sessionId = await createSession(user.id, c.req.header("User-Agent"));
727
+ clearFlowCookies();
728
+ c.header("Set-Cookie", buildSessionCookie(sessionId, { secure }), {
729
+ append: true
730
+ });
731
+ c.header("Set-Cookie", buildOidcMarkerCookie({ secure }), { append: true });
732
+ return c.redirect("/", 302);
733
+ } catch (err) {
734
+ console.error("[oidc] provisioning/session failed:", err);
735
+ return bounce("service_error");
736
+ }
737
+ });
738
+ oidcRoutes.get("/logout", async (c) => {
739
+ const secure = isSecureRequest(c);
740
+ const sessionId = getSessionIdFromRequest(c);
741
+ if (sessionId) {
742
+ try {
743
+ await deleteSession(sessionId);
744
+ } catch (err) {
745
+ console.error("[oidc] logout: failed to delete session:", err);
746
+ }
747
+ }
748
+ c.header("Set-Cookie", buildClearSessionCookie({ secure }), { append: true });
749
+ c.header("Set-Cookie", buildOidcMarkerCookie({ secure, clear: true }), {
750
+ append: true
751
+ });
752
+ const home = `${requestBaseUrl(c.req.url)}/`;
753
+ if (isOidcReady()) {
754
+ try {
755
+ const { doc } = await discover(config.OIDC_ISSUER);
756
+ if (doc.end_session_endpoint) {
757
+ const url = new URL(doc.end_session_endpoint);
758
+ url.searchParams.set("post_logout_redirect_uri", home);
759
+ url.searchParams.set("client_id", config.OIDC_CLIENT_ID);
760
+ return c.redirect(url.toString(), 302);
761
+ }
762
+ } catch (err) {
763
+ console.error("[oidc] logout: discovery failed, local-only:", err);
764
+ }
765
+ }
766
+ return c.redirect(home, 302);
767
+ });
768
+
769
+ // src/auth/routes.ts
770
+ function isServerAdminEmail(email) {
771
+ return isLicenseAdminEmail(email) || isSuperAdmin(email);
772
+ }
362
773
  var LOGIN_LIMIT = { max: 5, windowMs: 15 * 6e4 };
363
774
  var REGISTER_LIMIT = { max: 3, windowMs: 60 * 6e4 };
364
775
  var DEVICE_LIMIT = { max: 10, windowMs: 15 * 6e4 };
776
+ var PROMPTOWL_SCOPES = ["user:read", "teams:read"];
365
777
  var warnedMissingSsoAudience = false;
366
778
  var MIN_PASSWORD_LENGTH = 8;
367
779
  function assertValidPassword(password, label = "password") {
@@ -374,18 +786,6 @@ function assertValidPassword(password, label = "password") {
374
786
  );
375
787
  }
376
788
  }
377
- function clientIp(c) {
378
- const xff = c.req.header("x-forwarded-for");
379
- if (xff) return xff.split(",")[0].trim();
380
- const realIp = c.req.header("x-real-ip");
381
- if (realIp) return realIp.trim();
382
- try {
383
- const addr = getConnInfo(c).remote.address;
384
- if (addr) return addr;
385
- } catch {
386
- }
387
- return "unknown";
388
- }
389
789
  async function resolveCallerUserId(c) {
390
790
  const sessionId = getSessionIdFromRequest(c);
391
791
  if (sessionId) {
@@ -423,6 +823,16 @@ function clearSessionCookie(c) {
423
823
  "Set-Cookie",
424
824
  buildClearSessionCookie({ secure: isSecureRequest(c) })
425
825
  );
826
+ c.header(
827
+ "Set-Cookie",
828
+ buildPromptowlTokenCookie("", { secure: isSecureRequest(c), clear: true }),
829
+ { append: true }
830
+ );
831
+ c.header(
832
+ "Set-Cookie",
833
+ buildPromptowlMarkerCookie({ secure: isSecureRequest(c), clear: true }),
834
+ { append: true }
835
+ );
426
836
  }
427
837
  async function provisionPromptowlUser(c, rawEmail, rawName) {
428
838
  const gate = config.PROMPTOWL_SIGN_IN_GATE;
@@ -443,8 +853,8 @@ async function provisionPromptowlUser(c, rawEmail, rawName) {
443
853
  [meEmail]
444
854
  );
445
855
  if (!user) {
446
- const userId = uuid();
447
- const placeholderHash = await hashPassword(uuid());
856
+ const userId = uuid2();
857
+ const placeholderHash = await hashPassword(uuid2());
448
858
  await db.run(
449
859
  "INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)",
450
860
  [userId, meEmail, rawName || null, placeholderHash]
@@ -476,7 +886,8 @@ async function provisionPromptowlUser(c, rawEmail, rawName) {
476
886
  setSessionCookie(c, sessionId);
477
887
  return { ok: true, user, isAdmin, claimBlocked };
478
888
  }
479
- var authRoutes = new Hono();
889
+ var authRoutes = new Hono2();
890
+ authRoutes.route("/oidc", oidcRoutes);
480
891
  authRoutes.post("/register", async (c) => {
481
892
  if (config.MANUAL_SIGN_IN !== "open") {
482
893
  return c.json(
@@ -512,7 +923,7 @@ authRoutes.post("/register", async (c) => {
512
923
  }
513
924
  throw new ValidationError("Email already registered");
514
925
  }
515
- const userId = uuid();
926
+ const userId = uuid2();
516
927
  const passwordHash = await hashPassword(body.password);
517
928
  await db.run(
518
929
  "INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)",
@@ -527,7 +938,7 @@ authRoutes.post("/register", async (c) => {
527
938
  id: userId,
528
939
  email,
529
940
  name: body.name || null,
530
- is_admin: isLicenseAdminEmail(email)
941
+ is_admin: isServerAdminEmail(email)
531
942
  }
532
943
  },
533
944
  201
@@ -569,12 +980,6 @@ authRoutes.post("/login", async (c) => {
569
980
  console.log(`[auth] login OK \u2014 counter reset ip=${ip} email=${emailLower}`);
570
981
  if (hasIp) clear(ipKey);
571
982
  clear(emailKey);
572
- if (user.is_invited === 1) {
573
- try {
574
- await db.run("UPDATE users SET is_invited = 0 WHERE id = ?", [user.id]);
575
- } catch {
576
- }
577
- }
578
983
  if (check.needsRehash) {
579
984
  try {
580
985
  const newHash = await hashPassword(body.password);
@@ -593,7 +998,7 @@ authRoutes.post("/login", async (c) => {
593
998
  id: user.id,
594
999
  email: user.email,
595
1000
  name: user.name,
596
- is_admin: isLicenseAdminEmail(user.email)
1001
+ is_admin: isServerAdminEmail(user.email)
597
1002
  }
598
1003
  });
599
1004
  });
@@ -601,6 +1006,11 @@ authRoutes.post("/logout", async (c) => {
601
1006
  const sessionId = getSessionIdFromRequest(c);
602
1007
  if (sessionId) await deleteSession(sessionId);
603
1008
  clearSessionCookie(c);
1009
+ c.header(
1010
+ "Set-Cookie",
1011
+ buildOidcMarkerCookie({ secure: isSecureRequest(c), clear: true }),
1012
+ { append: true }
1013
+ );
604
1014
  return c.json({ ok: true });
605
1015
  });
606
1016
  authRoutes.post("/keys", authMiddleware, async (c) => {
@@ -623,7 +1033,7 @@ authRoutes.post("/keys", authMiddleware, async (c) => {
623
1033
  );
624
1034
  }
625
1035
  const apiKey = generateApiKey();
626
- const keyId = uuid();
1036
+ const keyId = uuid2();
627
1037
  await db.run(
628
1038
  "INSERT INTO api_keys (id, user_id, key_hash, key_prefix, nest_id, label) VALUES (?, ?, ?, ?, ?, ?)",
629
1039
  [
@@ -641,17 +1051,16 @@ authRoutes.post("/keys", authMiddleware, async (c) => {
641
1051
  );
642
1052
  });
643
1053
  authRoutes.post("/keys/rotate", authMiddleware, async (c) => {
644
- const body = await c.req.json().catch(
645
- () => ({})
646
- );
1054
+ const body = await c.req.json().catch(() => ({}));
647
1055
  const db = getDb();
648
1056
  const userId = c.get("userId");
649
1057
  const apiKey = generateApiKey();
650
- const keyId = uuid();
1058
+ const keyId = uuid2();
651
1059
  const prior = await db.get(
652
1060
  "SELECT label, nest_id FROM api_keys WHERE user_id = ?",
653
1061
  [userId]
654
1062
  );
1063
+ const nestScope = "nest_id" in body ? body.nest_id || null : prior?.nest_id ?? null;
655
1064
  await db.transaction(async (tx) => {
656
1065
  await tx.run("DELETE FROM api_keys WHERE user_id = ?", [userId]);
657
1066
  await tx.run(
@@ -661,7 +1070,7 @@ authRoutes.post("/keys/rotate", authMiddleware, async (c) => {
661
1070
  userId,
662
1071
  hashApiKey(apiKey),
663
1072
  getKeyPrefix(apiKey),
664
- body.nest_id ?? prior?.nest_id ?? null,
1073
+ nestScope,
665
1074
  body.label ?? prior?.label ?? null
666
1075
  ]
667
1076
  );
@@ -693,14 +1102,21 @@ authRoutes.post("/device", async (c) => {
693
1102
  if (!tryConsume(`device:ip:${clientIp(c)}`, DEVICE_LIMIT)) {
694
1103
  return c.json({ error: "Too many device auth attempts, try again later" }, 429);
695
1104
  }
696
- const body = await c.req.json();
1105
+ const body = await c.req.json().catch(() => ({}));
697
1106
  const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
698
1107
  const res = await fetch(`${promptowlUrl}/api/auth/device`, {
699
1108
  method: "POST",
700
1109
  headers: config.PROMPTOWL_FETCH_HEADERS,
701
1110
  body: JSON.stringify({
702
1111
  deviceName: body.deviceName || "ContextNest Community",
703
- deviceType: body.deviceType || "webapp"
1112
+ deviceType: body.deviceType || "webapp",
1113
+ // Ask for the least this server can work with: read the profile (to
1114
+ // identify who signed in) and read teams (to import them). The token we
1115
+ // then hold — and park in the user's cookie — is refused by every other
1116
+ // PromptOwl endpoint, so it can't chat as the user or spend their
1117
+ // credits even if it leaks. An older PromptOwl ignores the field and
1118
+ // returns an unscoped token, which still works.
1119
+ scopes: PROMPTOWL_SCOPES
704
1120
  })
705
1121
  });
706
1122
  const data = await res.json();
@@ -753,6 +1169,16 @@ authRoutes.post("/promptowl", async (c) => {
753
1169
  if (!result.ok) {
754
1170
  return c.json({ error: result.error, gate: result.gate }, result.status);
755
1171
  }
1172
+ c.header(
1173
+ "Set-Cookie",
1174
+ buildPromptowlTokenCookie(body.token, { secure: isSecureRequest(c) }),
1175
+ { append: true }
1176
+ );
1177
+ c.header(
1178
+ "Set-Cookie",
1179
+ buildPromptowlMarkerCookie({ secure: isSecureRequest(c) }),
1180
+ { append: true }
1181
+ );
756
1182
  return c.json({
757
1183
  user: {
758
1184
  id: result.user.id,
@@ -843,7 +1269,7 @@ authRoutes.get("/admin-status", async (c) => {
843
1269
  me = {
844
1270
  email: row.email,
845
1271
  name: row.name,
846
- is_admin: isLicenseAdminEmail(row.email)
1272
+ is_admin: isServerAdminEmail(row.email)
847
1273
  };
848
1274
  }
849
1275
  }
@@ -890,9 +1316,9 @@ authRoutes.post("/admin/reset-password/:userId", async (c) => {
890
1316
  if (!callerId) {
891
1317
  return c.json({ error: "Authentication required." }, 401);
892
1318
  }
893
- if (!await isLicenseAdminUserId(callerId)) {
1319
+ if (!await isServerAdminUserId(callerId)) {
894
1320
  return c.json(
895
- { error: "Only the license-admin user can reset passwords." },
1321
+ { error: "Only a server admin can reset passwords." },
896
1322
  403
897
1323
  );
898
1324
  }
@@ -903,6 +1329,12 @@ authRoutes.post("/admin/reset-password/:userId", async (c) => {
903
1329
  [targetId]
904
1330
  );
905
1331
  if (!target) return c.json({ error: "User not found" }, 404);
1332
+ if (target.id !== callerId && isServerAdminEmail(target.email)) {
1333
+ return c.json(
1334
+ { error: "This user is a server admin \u2014 admins can't reset each other's passwords. They can change their own via their account." },
1335
+ 403
1336
+ );
1337
+ }
906
1338
  let supplied;
907
1339
  try {
908
1340
  supplied = (await c.req.json()).password;
@@ -912,7 +1344,7 @@ authRoutes.post("/admin/reset-password/:userId", async (c) => {
912
1344
  if (supplied !== void 0) {
913
1345
  assertValidPassword(supplied);
914
1346
  }
915
- const generated = supplied ? null : uuid().replace(/-/g, "").slice(0, 16);
1347
+ const generated = supplied ? null : uuid2().replace(/-/g, "").slice(0, 16);
916
1348
  const newPassword = supplied ?? generated;
917
1349
  const newHash = await hashPassword(newPassword);
918
1350
  await db.transaction(async (tx) => {
@@ -936,9 +1368,9 @@ authRoutes.delete("/users/:userId", async (c) => {
936
1368
  if (!callerId) {
937
1369
  return c.json({ error: "Authentication required." }, 401);
938
1370
  }
939
- if (!await isLicenseAdminUserId(callerId)) {
1371
+ if (!await isServerAdminUserId(callerId)) {
940
1372
  return c.json(
941
- { error: "Only the license-admin user can remove users." },
1373
+ { error: "Only a server admin can remove users." },
942
1374
  403
943
1375
  );
944
1376
  }
@@ -952,6 +1384,12 @@ authRoutes.delete("/users/:userId", async (c) => {
952
1384
  [targetId]
953
1385
  );
954
1386
  if (!target) return c.json({ error: "User not found" }, 404);
1387
+ if (isServerAdminEmail(target.email)) {
1388
+ return c.json(
1389
+ { error: "This user is a server admin \u2014 admins can't remove each other. Revoke their superadmin access first." },
1390
+ 403
1391
+ );
1392
+ }
955
1393
  const ownedNests = (await db.get(
956
1394
  "SELECT COUNT(*) AS c FROM nests WHERE user_id = ?",
957
1395
  [target.id]
@@ -994,10 +1432,10 @@ authRoutes.post("/invite", async (c) => {
994
1432
  );
995
1433
  }
996
1434
  const db = getDb();
997
- if (!await isLicenseAdminUserId(callerId)) {
1435
+ if (!await isServerAdminUserId(callerId)) {
998
1436
  return c.json(
999
1437
  {
1000
- error: "Only the license-admin user can invite teammates. Contact the admin who installed the PromptOwl license on this server to issue invitations."
1438
+ error: "Only a server admin can invite teammates. Contact the admin who installed the PromptOwl license on this server to issue invitations."
1001
1439
  },
1002
1440
  403
1003
1441
  );
@@ -1014,7 +1452,7 @@ authRoutes.post("/invite", async (c) => {
1014
1452
  409
1015
1453
  );
1016
1454
  }
1017
- const tempPassword = uuid().replace(/-/g, "").slice(0, 16);
1455
+ const tempPassword = uuid2().replace(/-/g, "").slice(0, 16);
1018
1456
  const passwordHash = await hashPassword(tempPassword);
1019
1457
  let userId;
1020
1458
  if (existing) {
@@ -1024,7 +1462,7 @@ authRoutes.post("/invite", async (c) => {
1024
1462
  userId
1025
1463
  ]);
1026
1464
  } else {
1027
- userId = uuid();
1465
+ userId = uuid2();
1028
1466
  await db.run(
1029
1467
  "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
1030
1468
  [userId, email, null, passwordHash]
@@ -1051,10 +1489,10 @@ authRoutes.get("/teammates", async (c) => {
1051
1489
  );
1052
1490
  }
1053
1491
  const db = getDb();
1054
- if (!await isLicenseAdminUserId(callerId)) {
1492
+ if (!await isServerAdminUserId(callerId)) {
1055
1493
  return c.json(
1056
1494
  {
1057
- error: "Only the license-admin user can view the teammates list. Contact the admin who installed the PromptOwl license on this server."
1495
+ error: "Only a server admin can view the teammates list. Contact the admin who installed the PromptOwl license on this server."
1058
1496
  },
1059
1497
  403
1060
1498
  );
@@ -1084,7 +1522,7 @@ authRoutes.get("/teammates", async (c) => {
1084
1522
  )
1085
1523
  ORDER BY s.user_email`
1086
1524
  );
1087
- const enriched = teammates.map((t) => ({ ...t, is_admin: isLicenseAdminEmail(t.email) })).sort((a, b) => Number(b.is_admin) - Number(a.is_admin));
1525
+ const enriched = teammates.map((t) => ({ ...t, is_admin: isServerAdminEmail(t.email) })).sort((a, b) => Number(b.is_admin) - Number(a.is_admin));
1088
1526
  return c.json({
1089
1527
  teammates: enriched,
1090
1528
  pending_stewards: pendingStewards.map((p) => p.email)
@@ -1092,15 +1530,17 @@ authRoutes.get("/teammates", async (c) => {
1092
1530
  });
1093
1531
 
1094
1532
  // src/nests/routes.ts
1095
- import { Hono as Hono2 } from "hono";
1533
+ import { Hono as Hono3 } from "hono";
1534
+ import { streamText } from "hono/streaming";
1096
1535
 
1097
1536
  // src/nodes/service.ts
1098
1537
  import {
1099
1538
  serializeDocument,
1100
- parseDocument as parseDocument2
1539
+ parseDocument as parseDocument2,
1540
+ normalizeStatus
1101
1541
  } from "@promptowl/contextnest-engine";
1102
- import { rename, mkdir } from "fs/promises";
1103
- import { dirname, join as join2 } from "path";
1542
+ import { rename, mkdir, rm } from "fs/promises";
1543
+ import { dirname, join as join2, basename } from "path";
1104
1544
 
1105
1545
  // src/governance/tag-index-service.ts
1106
1546
  function normalizeTag(raw) {
@@ -1318,7 +1758,7 @@ async function approveExternalEdit(input) {
1318
1758
  const node = await storage.readDocument(input.documentId);
1319
1759
  const versionNum = result.versionEntry.version;
1320
1760
  const tags = node.frontmatter.tags || [];
1321
- const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-A2YAKYQW.js");
1761
+ const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-YQIIQ7FN.js");
1322
1762
  await createVersion2({
1323
1763
  nestId: input.nestId,
1324
1764
  nodeId: input.documentId,
@@ -1413,6 +1853,20 @@ function folderToSegments(folder) {
1413
1853
  return segments;
1414
1854
  }
1415
1855
  var stripUndefined = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== void 0));
1856
+ async function departmentTagFor(userEmail) {
1857
+ if (!config.OIDC_DEPARTMENT_TAGGING) return null;
1858
+ try {
1859
+ const row = await getDb().get(
1860
+ "SELECT department FROM users WHERE LOWER(email) = LOWER(?)",
1861
+ [userEmail]
1862
+ );
1863
+ const slug = slugify(String(row?.department ?? ""));
1864
+ return slug ? normalizeTag2(`dept:${slug}`) : null;
1865
+ } catch (err) {
1866
+ console.error("[dept-tag] department lookup failed (tag skipped):", err);
1867
+ return null;
1868
+ }
1869
+ }
1416
1870
  var nodeWriteChains = /* @__PURE__ */ new Map();
1417
1871
  function withNodeWriteLock(key, fn) {
1418
1872
  const prev = (nodeWriteChains.get(key) ?? Promise.resolve()).catch(() => {
@@ -1463,6 +1917,59 @@ function toNodeResponse(node) {
1463
1917
  schedule: fm.metadata?.schedule != null ? String(fm.metadata.schedule) : void 0
1464
1918
  };
1465
1919
  }
1920
+ async function resolveAuthorship(nestId, nodes) {
1921
+ const result = /* @__PURE__ */ new Map();
1922
+ if (nodes.length === 0) return result;
1923
+ const db = getDb();
1924
+ const single = nodes.length === 1 ? nodes[0].id : null;
1925
+ const versionRows = await db.all(
1926
+ `SELECT node_id, author, MIN(version) AS first_version
1927
+ FROM node_versions
1928
+ WHERE nest_id = ?${single ? " AND node_id = ?" : ""}
1929
+ AND author IS NOT NULL AND author NOT LIKE 'system:%'
1930
+ GROUP BY node_id, author`,
1931
+ single ? [nestId, single] : [nestId]
1932
+ );
1933
+ const grantRows = await db.all(
1934
+ `SELECT g.target AS node_id, u.email AS email
1935
+ FROM grants g
1936
+ JOIN users u ON u.id = g.user_id
1937
+ WHERE g.nest_id = ? AND g.target_type = 'document'${single ? " AND g.target = ?" : ""}`,
1938
+ single ? [nestId, single] : [nestId]
1939
+ );
1940
+ const editorsByNode = /* @__PURE__ */ new Map();
1941
+ for (const r of versionRows) {
1942
+ let list = editorsByNode.get(r.node_id);
1943
+ if (!list) editorsByNode.set(r.node_id, list = []);
1944
+ list.push({ author: r.author, first: Number(r.first_version) });
1945
+ }
1946
+ const granteesByNode = /* @__PURE__ */ new Map();
1947
+ for (const g of grantRows) {
1948
+ let list = granteesByNode.get(g.node_id);
1949
+ if (!list) granteesByNode.set(g.node_id, list = []);
1950
+ list.push(g.email);
1951
+ }
1952
+ for (const n of nodes) {
1953
+ const editors = editorsByNode.get(n.id) ?? [];
1954
+ const firstAuthor = editors.length ? editors.reduce((a, b) => b.first < a.first ? b : a).author : null;
1955
+ const author = n.frontmatterAuthor || firstAuthor;
1956
+ const authorKey = author ? author.toLowerCase() : null;
1957
+ const seen = /* @__PURE__ */ new Set();
1958
+ const collaborators = [];
1959
+ for (const email of [
1960
+ ...editors.map((e) => e.author),
1961
+ ...granteesByNode.get(n.id) ?? []
1962
+ ]) {
1963
+ const key = email.toLowerCase();
1964
+ if (key === authorKey || seen.has(key)) continue;
1965
+ seen.add(key);
1966
+ collaborators.push(email);
1967
+ }
1968
+ collaborators.sort((a, b) => a.localeCompare(b));
1969
+ result.set(n.id, { author: author ?? null, collaborators });
1970
+ }
1971
+ return result;
1972
+ }
1466
1973
  async function listNodesForCaller(nestId, userId, filters = {}) {
1467
1974
  const { storage, versions: versionManager } = await engineCache.get(nestId);
1468
1975
  let documents = await storage.discoverDocuments();
@@ -1485,9 +1992,21 @@ async function listNodesForCaller(nestId, userId, filters = {}) {
1485
1992
  accessible = grants.length ? documents.filter((d) => grantCoversNode(grants, d.id)) : accessible;
1486
1993
  }
1487
1994
  const publicReader = await isPublicReader(nestId, userId);
1995
+ const authorship = publicReader ? null : await resolveAuthorship(
1996
+ nestId,
1997
+ accessible.map((d) => ({
1998
+ id: d.id,
1999
+ frontmatterAuthor: d.frontmatter.author ?? null
2000
+ }))
2001
+ );
1488
2002
  const enriched = await Promise.all(
1489
2003
  accessible.map(async (doc) => {
1490
2004
  const r = toNodeResponse(doc);
2005
+ const people = authorship?.get(doc.id);
2006
+ if (people) {
2007
+ r.author = people.author;
2008
+ r.collaborators = people.collaborators;
2009
+ }
1491
2010
  if (publicReader) {
1492
2011
  const approved = await getApprovedVersion(nestId, doc.id);
1493
2012
  if (approved != null) {
@@ -1546,6 +2065,13 @@ async function createNode(nestId, input, userEmail) {
1546
2065
  }
1547
2066
  const now = (/* @__PURE__ */ new Date()).toISOString();
1548
2067
  const tags = (input.tags || []).map(normalizeTag2);
2068
+ const requestedType = input.type || "document";
2069
+ if (requestedType === "artifact" && !config.TYPE_ARTIFACT_ENABLED) {
2070
+ throw new ValidationError("Artifact nodes are disabled on this server");
2071
+ }
2072
+ if (requestedType === "table" && !config.TYPE_TABLE_ENABLED) {
2073
+ throw new ValidationError("Table nodes are disabled on this server");
2074
+ }
1549
2075
  if (input.schedule !== void 0 && !isRunnableType(input.type)) {
1550
2076
  throw new ValidationError(
1551
2077
  `schedule is only valid for runnable node types (${RUNNABLE_NODE_TYPES.join(
@@ -1553,6 +2079,10 @@ async function createNode(nestId, input, userEmail) {
1553
2079
  )})`
1554
2080
  );
1555
2081
  }
2082
+ const deptTag = await departmentTagFor(userEmail);
2083
+ if (deptTag && !tags.some((t) => t.toLowerCase() === deptTag)) {
2084
+ tags.push(deptTag);
2085
+ }
1556
2086
  const hasStewards = await isStewardshipEnabled(nestId);
1557
2087
  const initialStatus = hasStewards ? "draft" : "published";
1558
2088
  const initialVersion = hasStewards ? 1 : 0;
@@ -1561,7 +2091,7 @@ async function createNode(nestId, input, userEmail) {
1561
2091
  filePath: "",
1562
2092
  frontmatter: {
1563
2093
  title: input.title,
1564
- type: input.type || "document",
2094
+ type: requestedType,
1565
2095
  tags,
1566
2096
  status: input.status || initialStatus,
1567
2097
  version: initialVersion,
@@ -1628,7 +2158,27 @@ async function createNode(nestId, input, userEmail) {
1628
2158
  await trackEvent("node.create", { nestId, nodeId: id });
1629
2159
  return { node, version: savedVersion };
1630
2160
  }
1631
- async function registerImportedDocuments(nestId, userEmail) {
2161
+ function explicitFrontmatterStatus(raw) {
2162
+ const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
2163
+ if (!fm) return null;
2164
+ const m = fm[1].match(/^status:[ \t]*["']?([A-Za-z_-]+)["']?[ \t]*$/m);
2165
+ return m ? normalizeStatus(m[1]) : null;
2166
+ }
2167
+ async function resetImportedDocForPublish(storage, nestId, nodeId) {
2168
+ const versionsDir = join2(
2169
+ resolveNestPath(nestId),
2170
+ dirname(nodeId),
2171
+ ".versions",
2172
+ basename(nodeId)
2173
+ );
2174
+ await rm(versionsDir, { recursive: true, force: true });
2175
+ const node = await storage.readDocument(nodeId);
2176
+ const fm = { ...node.frontmatter };
2177
+ delete fm.version;
2178
+ delete fm.checksum;
2179
+ await storage.writeDocument(nodeId, serializeDocument({ ...node, frontmatter: fm }));
2180
+ }
2181
+ async function registerImportedDocuments(nestId, userEmail, onProgress) {
1632
2182
  const { storage } = await engineCache.get(nestId);
1633
2183
  let docs;
1634
2184
  try {
@@ -1637,24 +2187,52 @@ async function registerImportedDocuments(nestId, userEmail) {
1637
2187
  console.error("registerImportedDocuments: discovery failed", nestId, err);
1638
2188
  return 0;
1639
2189
  }
2190
+ const startedAt = Date.now();
2191
+ console.log(`[import] register: discovered=${docs.length} nest=${nestId}`);
2192
+ await onProgress?.(0, docs.length);
1640
2193
  let registered = 0;
2194
+ let alreadyTracked = 0;
2195
+ let publishFailed = 0;
2196
+ let publishRecovered = 0;
2197
+ let keptUnpublished = 0;
2198
+ let processed = 0;
1641
2199
  for (let doc of docs) {
1642
2200
  const nodeId = doc.id;
1643
- if (await getCurrentVersion(nestId, nodeId) > 0) continue;
1644
- if (!doc.frontmatter?.title) {
1645
- const title = nodeId.split("/").pop() || nodeId;
1646
- doc = { ...doc, frontmatter: { ...doc.frontmatter, title } };
1647
- try {
1648
- await storage.writeDocument(nodeId, serializeDocument(doc));
1649
- } catch (err) {
1650
- console.error("import: failed to persist filename title", nodeId, err);
1651
- }
2201
+ await onProgress?.(++processed, docs.length);
2202
+ if (await getCurrentVersion(nestId, nodeId) > 0) {
2203
+ alreadyTracked++;
2204
+ continue;
2205
+ }
2206
+ const title = doc.frontmatter?.title ?? (nodeId.split("/").pop() || nodeId);
2207
+ doc = { ...doc, frontmatter: { ...doc.frontmatter, title, author: userEmail } };
2208
+ try {
2209
+ await storage.writeDocument(nodeId, serializeDocument(doc));
2210
+ } catch (err) {
2211
+ console.error("import: failed to persist import metadata", nodeId, err);
1652
2212
  }
1653
2213
  const rawTags = Array.isArray(doc.frontmatter?.tags) ? doc.frontmatter.tags : [];
1654
2214
  const tags = rawTags.map((t) => normalizeTag2(String(t)));
1655
2215
  const fmVersion = Number(doc.frontmatter?.version);
1656
- let version = Number.isFinite(fmVersion) && fmVersion > 0 ? fmVersion : 1;
2216
+ let version = Number.isInteger(fmVersion) && fmVersion > 0 ? fmVersion : 1;
1657
2217
  let content = doc.body || "";
2218
+ const status = explicitFrontmatterStatus(doc.rawContent);
2219
+ if (status && status !== "published" && status !== "approved") {
2220
+ await createVersion({
2221
+ nestId,
2222
+ nodeId,
2223
+ version,
2224
+ content,
2225
+ author: userEmail,
2226
+ status: "draft",
2227
+ changeNote: "Imported from existing folder (kept unpublished)",
2228
+ tags
2229
+ });
2230
+ await syncNodeTags(nestId, nodeId, tags);
2231
+ keptUnpublished++;
2232
+ registered++;
2233
+ continue;
2234
+ }
2235
+ let published = false;
1658
2236
  try {
1659
2237
  const result = await safePublishDocument(storage, nodeId, {
1660
2238
  editedBy: userEmail,
@@ -1662,12 +2240,29 @@ async function registerImportedDocuments(nestId, userEmail) {
1662
2240
  });
1663
2241
  version = result.node.frontmatter.version || version;
1664
2242
  content = result.node.body || content;
2243
+ published = true;
1665
2244
  } catch (err) {
1666
- console.error(
1667
- "safePublishDocument failed (import register)",
1668
- nodeId,
1669
- err
2245
+ console.warn(
2246
+ `[import] publish retry needed for ${nodeId}: ${err?.message ?? err}`
1670
2247
  );
2248
+ try {
2249
+ await resetImportedDocForPublish(storage, nestId, nodeId);
2250
+ const result = await safePublishDocument(storage, nodeId, {
2251
+ editedBy: userEmail,
2252
+ note: "Imported from existing folder (reset chain)"
2253
+ });
2254
+ version = result.node.frontmatter.version || 1;
2255
+ content = result.node.body || content;
2256
+ published = true;
2257
+ publishRecovered++;
2258
+ } catch (err2) {
2259
+ publishFailed++;
2260
+ console.error(
2261
+ "reset+republish failed (import register)",
2262
+ nodeId,
2263
+ err2
2264
+ );
2265
+ }
1671
2266
  }
1672
2267
  await createVersion({
1673
2268
  nestId,
@@ -1675,14 +2270,19 @@ async function registerImportedDocuments(nestId, userEmail) {
1675
2270
  version,
1676
2271
  content,
1677
2272
  author: userEmail,
1678
- status: "published",
2273
+ status: published ? "published" : "draft",
1679
2274
  changeNote: "Imported from existing folder",
1680
2275
  tags
1681
2276
  });
1682
- await setApprovedVersion(nestId, nodeId, version, userEmail);
2277
+ if (published) {
2278
+ await setApprovedVersion(nestId, nodeId, version, userEmail);
2279
+ }
1683
2280
  await syncNodeTags(nestId, nodeId, tags);
1684
2281
  registered++;
1685
2282
  }
2283
+ console.log(
2284
+ `[import] register done: registered=${registered} alreadyTracked=${alreadyTracked} keptUnpublished=${keptUnpublished} publishRecovered=${publishRecovered} publishFailed=${publishFailed} tookMs=${Date.now() - startedAt}`
2285
+ );
1686
2286
  await trackEvent("nest.import.documents", { nestId, registered });
1687
2287
  return registered;
1688
2288
  }
@@ -2147,7 +2747,7 @@ async function effectivePermission(nestId, userId) {
2147
2747
  }
2148
2748
  return resolveNestPermission(nestId, userId);
2149
2749
  }
2150
- var nestRoutes = new Hono2();
2750
+ var nestRoutes = new Hono3();
2151
2751
  nestRoutes.get("/", async (c) => {
2152
2752
  const userId = c.get("userId");
2153
2753
  const owned = await listNests(userId);
@@ -2168,8 +2768,11 @@ nestRoutes.get("/", async (c) => {
2168
2768
  await getCollaboratorRole(n.id, callerEmail)
2169
2769
  );
2170
2770
  const stewardRoles = await getStewardRolesForUser(n.id, callerEmail);
2771
+ const teamRoles = await resolveTeamRolesForUser(n.id, userId);
2171
2772
  roles = [
2172
- ...new Set([grantRole, ...stewardRoles].filter(Boolean))
2773
+ ...new Set(
2774
+ [grantRole, ...stewardRoles, ...teamRoles].filter(Boolean)
2775
+ )
2173
2776
  ];
2174
2777
  }
2175
2778
  if (!is_owner && n.user_id !== ANON_USER_ID) {
@@ -2252,6 +2855,31 @@ nestRoutes.post("/:nestId", async (c) => {
2252
2855
  }
2253
2856
  const files = Array.isArray(body.files) ? body.files : [];
2254
2857
  const userId = c.get("userId");
2858
+ console.log(
2859
+ `[import] request: name=${JSON.stringify(body.name)} files=${files.length}`
2860
+ );
2861
+ if (c.req.query("stream") === "1") {
2862
+ const name = body.name;
2863
+ return streamText(c, async (stream) => {
2864
+ try {
2865
+ await stream.writeln(JSON.stringify({ phase: "write" }));
2866
+ const nest2 = await importNest(userId, name, files);
2867
+ const email = await resolveCallerEmail(userId);
2868
+ const documents2 = await registerImportedDocuments(
2869
+ nest2.id,
2870
+ email,
2871
+ async (done, total) => {
2872
+ await stream.writeln(JSON.stringify({ phase: "register", done, total }));
2873
+ }
2874
+ );
2875
+ await stream.writeln(JSON.stringify({ complete: true, nest: nest2, documents: documents2 }));
2876
+ } catch (err) {
2877
+ await stream.writeln(
2878
+ JSON.stringify({ error: err?.message || "Import failed" })
2879
+ );
2880
+ }
2881
+ });
2882
+ }
2255
2883
  const nest = await importNest(userId, body.name, files);
2256
2884
  const documents = await registerImportedDocuments(
2257
2885
  nest.id,
@@ -2261,7 +2889,7 @@ nestRoutes.post("/:nestId", async (c) => {
2261
2889
  });
2262
2890
  async function handleUnsyncedList(c) {
2263
2891
  const userId = c.get("userId");
2264
- if (config.AUTH_MODE !== "open" && !await isLicenseAdminUserId(userId)) {
2892
+ if (config.AUTH_MODE !== "open" && !await isServerAdminUserId(userId)) {
2265
2893
  throw new ForbiddenError("Only the server admin can list unsynced folders");
2266
2894
  }
2267
2895
  return c.json({ folders: listUnsyncedFolders() });
@@ -2271,7 +2899,7 @@ nestRoutes.post("/:nestId/sync", async (c) => {
2271
2899
  throw new NotFoundError("Not found");
2272
2900
  }
2273
2901
  const userId = c.get("userId");
2274
- if (config.AUTH_MODE !== "open" && !await isLicenseAdminUserId(userId)) {
2902
+ if (config.AUTH_MODE !== "open" && !await isServerAdminUserId(userId)) {
2275
2903
  throw new ForbiddenError("Only the server admin can sync folders");
2276
2904
  }
2277
2905
  const body = await c.req.json();
@@ -2391,10 +3019,10 @@ nestRoutes.patch("/:nestId/settings", async (c) => {
2391
3019
  });
2392
3020
 
2393
3021
  // src/nests/sharing-routes.ts
2394
- import { Hono as Hono3 } from "hono";
3022
+ import { Hono as Hono4 } from "hono";
2395
3023
 
2396
3024
  // src/nests/sharing-service.ts
2397
- import { v4 as uuid2 } from "uuid";
3025
+ import { v4 as uuid3 } from "uuid";
2398
3026
  var VALID_PERMISSIONS = ["read", "write", "admin"];
2399
3027
  async function addCollaborator(params) {
2400
3028
  const { nestId } = params;
@@ -2418,10 +3046,10 @@ async function addCollaborator(params) {
2418
3046
  userId = existing.id;
2419
3047
  } else {
2420
3048
  const { hashPassword: hashPassword2 } = await import("./keys-73STFJJB.js");
2421
- userId = uuid2();
3049
+ userId = uuid3();
2422
3050
  await db.run(
2423
3051
  "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
2424
- [userId, email, null, await hashPassword2(uuid2())]
3052
+ [userId, email, null, await hashPassword2(uuid3())]
2425
3053
  );
2426
3054
  }
2427
3055
  }
@@ -2459,7 +3087,7 @@ async function addCollaborator(params) {
2459
3087
  [params.grantedByEmail]
2460
3088
  ))?.id : void 0;
2461
3089
  const granterId = params.grantedByUserId || granterByEmail || ownerRow.user_id;
2462
- const collabId = uuid2();
3090
+ const collabId = uuid3();
2463
3091
  await db.run(
2464
3092
  "INSERT INTO nest_collaborators (id, nest_id, user_id, permission, granted_by) VALUES (?, ?, ?, ?, ?)",
2465
3093
  [collabId, nestId, userId, params.permission, granterId]
@@ -2484,18 +3112,8 @@ async function addCollaborator(params) {
2484
3112
  );
2485
3113
  }
2486
3114
 
2487
- // src/shared/base-url.ts
2488
- function requestBaseUrl(reqUrl) {
2489
- if (config.PUBLIC_BASE_URL) return config.PUBLIC_BASE_URL;
2490
- try {
2491
- return new URL(reqUrl).origin;
2492
- } catch {
2493
- return "";
2494
- }
2495
- }
2496
-
2497
3115
  // src/nests/sharing-routes.ts
2498
- var sharingRoutes = new Hono3();
3116
+ var sharingRoutes = new Hono4();
2499
3117
  sharingRoutes.get("/collaborators", async (c) => {
2500
3118
  const db = getDb();
2501
3119
  const nestId = c.req.param("nestId");
@@ -2582,9 +3200,9 @@ sharingRoutes.patch("/visibility", async (c) => {
2582
3200
  });
2583
3201
 
2584
3202
  // src/nests/grant-routes.ts
2585
- import { Hono as Hono4 } from "hono";
2586
- import { v4 as uuid3 } from "uuid";
2587
- var grantRoutes = new Hono4();
3203
+ import { Hono as Hono5 } from "hono";
3204
+ import { v4 as uuid4 } from "uuid";
3205
+ var grantRoutes = new Hono5();
2588
3206
  async function requireManage(c, nestId) {
2589
3207
  if (!await canManageStewards(nestId, c.get("userId"))) {
2590
3208
  throw new ForbiddenError(
@@ -2598,10 +3216,10 @@ async function resolveGrantee(email) {
2598
3216
  const existing = await db.get("SELECT id FROM users WHERE LOWER(email) = ?", [e]);
2599
3217
  if (existing) return existing.id;
2600
3218
  const { hashPassword: hashPassword2 } = await import("./keys-73STFJJB.js");
2601
- const id = uuid3();
3219
+ const id = uuid4();
2602
3220
  await db.run(
2603
3221
  "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
2604
- [id, e, null, await hashPassword2(uuid3())]
3222
+ [id, e, null, await hashPassword2(uuid4())]
2605
3223
  );
2606
3224
  return id;
2607
3225
  }
@@ -2652,10 +3270,347 @@ grantRoutes.delete("/:id", async (c) => {
2652
3270
  return c.json({ deleted: true });
2653
3271
  });
2654
3272
 
3273
+ // src/nests/nest-team-routes.ts
3274
+ import { Hono as Hono6 } from "hono";
3275
+ var nestTeamRoutes = new Hono6();
3276
+ nestTeamRoutes.get("/", async (c) => {
3277
+ const nestId = c.req.param("nestId");
3278
+ const refs = await listNestTeams(nestId);
3279
+ const teams = [];
3280
+ for (const ref of refs) {
3281
+ const team = await getTeam(ref.teamId).catch(() => null);
3282
+ teams.push({
3283
+ teamId: ref.teamId,
3284
+ teamName: team?.name ?? ref.teamName,
3285
+ members: team?.members ?? []
3286
+ });
3287
+ }
3288
+ return c.json({ teams });
3289
+ });
3290
+ nestTeamRoutes.post("/", async (c) => {
3291
+ const body = await c.req.json();
3292
+ if (!body.team_id) {
3293
+ throw new ValidationError("team_id is required");
3294
+ }
3295
+ const userId = c.get("userId");
3296
+ const teams = await shareTeamWithNest({
3297
+ nestId: c.req.param("nestId"),
3298
+ teamId: body.team_id,
3299
+ callerUserId: userId,
3300
+ allowAdmin: await isLicenseAdminUserId(userId)
3301
+ });
3302
+ return c.json({ teams }, 201);
3303
+ });
3304
+ nestTeamRoutes.delete("/:teamId", async (c) => {
3305
+ const teams = await unshareTeamFromNest({
3306
+ nestId: c.req.param("nestId"),
3307
+ teamId: c.req.param("teamId")
3308
+ });
3309
+ return c.json({ teams });
3310
+ });
3311
+
3312
+ // src/governance/team-routes.ts
3313
+ import { Hono as Hono7 } from "hono";
3314
+
3315
+ // src/governance/promptowl-teams.ts
3316
+ var PROMPTOWL_SOURCE = "promptowl";
3317
+ var IMPORTABLE_PROMPTOWL_ROLES = ["owner", "editor"];
3318
+ function canImportPromptowlTeam(team) {
3319
+ return IMPORTABLE_PROMPTOWL_ROLES.includes(
3320
+ (team.your_role || "").toLowerCase()
3321
+ );
3322
+ }
3323
+ function mapPromptowlRole(role) {
3324
+ switch ((role || "").toLowerCase()) {
3325
+ case "owner":
3326
+ return "admin";
3327
+ case "editor":
3328
+ return "editor";
3329
+ default:
3330
+ return "viewer";
3331
+ }
3332
+ }
3333
+ async function fetchOwnedPromptowlTeams(token) {
3334
+ if (!token?.trim()) throw new ValidationError("token is required");
3335
+ const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
3336
+ let res;
3337
+ try {
3338
+ res = await fetch(`${promptowlUrl}/api/user/teams`, {
3339
+ headers: {
3340
+ ...config.PROMPTOWL_FETCH_HEADERS,
3341
+ Authorization: `Bearer ${token}`
3342
+ }
3343
+ });
3344
+ } catch {
3345
+ throw new ValidationError("Could not reach PromptOwl. Check this server's network access.");
3346
+ }
3347
+ if (res.status === 401) {
3348
+ throw new UnauthorizedError(
3349
+ "PromptOwl rejected this approval. Approve again and retry."
3350
+ );
3351
+ }
3352
+ if (!res.ok) {
3353
+ throw new ValidationError(
3354
+ `PromptOwl could not list your teams (HTTP ${res.status}).`
3355
+ );
3356
+ }
3357
+ const data = await res.json();
3358
+ return Array.isArray(data.teams) ? data.teams : [];
3359
+ }
3360
+ async function syncPromptowlTeams(params) {
3361
+ if (!params.teamIds?.length) {
3362
+ throw new ValidationError("team_ids is required");
3363
+ }
3364
+ const owned = await fetchOwnedPromptowlTeams(params.token);
3365
+ const byId = new Map(owned.map((t) => [t.id, t]));
3366
+ const selected = [];
3367
+ for (const id of params.teamIds) {
3368
+ const team = byId.get(id);
3369
+ if (!team) {
3370
+ throw new ValidationError(
3371
+ "One of the selected teams isn't available to import. Refresh the list and try again."
3372
+ );
3373
+ }
3374
+ if (!canImportPromptowlTeam(team)) {
3375
+ throw new ForbiddenError(
3376
+ `You're view-only on "${team.name}" in PromptOwl, so you can't import it. Ask an owner or editor of that team to import it.`
3377
+ );
3378
+ }
3379
+ selected.push(team);
3380
+ }
3381
+ const db = getDb();
3382
+ const caller = await db.get("SELECT email FROM users WHERE id = ?", [
3383
+ params.callerUserId
3384
+ ]);
3385
+ if (!caller?.email) {
3386
+ throw new ValidationError("Could not resolve your account.");
3387
+ }
3388
+ const results = [];
3389
+ for (const poTeam of selected) {
3390
+ results.push(await syncOne(poTeam, params.callerUserId, caller.email));
3391
+ }
3392
+ return results;
3393
+ }
3394
+ async function syncOne(poTeam, callerUserId, callerEmail) {
3395
+ const existing = await findTeamByExternalId({
3396
+ ownerId: callerUserId,
3397
+ source: PROMPTOWL_SOURCE,
3398
+ externalId: poTeam.id
3399
+ });
3400
+ const team = existing ?? await createTeam({
3401
+ name: poTeam.name,
3402
+ ownerId: callerUserId,
3403
+ source: PROMPTOWL_SOURCE,
3404
+ externalId: poTeam.id
3405
+ });
3406
+ if (existing && existing.name !== poTeam.name) {
3407
+ await renameTeam({
3408
+ teamId: team.id,
3409
+ name: poTeam.name,
3410
+ callerUserId,
3411
+ allowAdmin: false
3412
+ });
3413
+ }
3414
+ const desired = await desiredRoster(poTeam, callerEmail);
3415
+ const before = new Map(team.members.map((m) => [m.email, m.role]));
3416
+ const after = new Map(desired.map((m) => [m.email, m.role]));
3417
+ const added = [...after.keys()].filter((e) => !before.has(e));
3418
+ const removed = [...before.keys()].filter((e) => !after.has(e));
3419
+ const updated = [...after.keys()].filter(
3420
+ (e) => before.has(e) && before.get(e) !== after.get(e)
3421
+ );
3422
+ if (added.length || removed.length || updated.length) {
3423
+ await replaceTeamRoster({
3424
+ teamId: team.id,
3425
+ members: desired,
3426
+ callerUserId,
3427
+ allowAdmin: false
3428
+ });
3429
+ }
3430
+ return {
3431
+ team_id: team.id,
3432
+ external_id: poTeam.id,
3433
+ name: poTeam.name,
3434
+ created: !existing,
3435
+ added,
3436
+ removed,
3437
+ updated
3438
+ };
3439
+ }
3440
+ async function desiredRoster(poTeam, callerEmail) {
3441
+ const caller = normalizeEmail(callerEmail);
3442
+ const roster = [];
3443
+ const seen = /* @__PURE__ */ new Set();
3444
+ for (const poMember of poTeam.members || []) {
3445
+ if (!poMember?.email) continue;
3446
+ const email = normalizeEmail(poMember.email);
3447
+ if (email === caller || seen.has(email)) continue;
3448
+ seen.add(email);
3449
+ const identity = await resolveMemberIdentity({ email });
3450
+ roster.push({
3451
+ userId: identity.userId,
3452
+ email: identity.email,
3453
+ role: mapPromptowlRole(poMember.role)
3454
+ });
3455
+ }
3456
+ return roster;
3457
+ }
3458
+
3459
+ // src/governance/team-routes.ts
3460
+ var teamRoutes = new Hono7();
3461
+ var PROMPTOWL_LIMIT = { max: 10, windowMs: 15 * 6e4 };
3462
+ var CONNECT_REQUIRED = "promptowl_connect_required";
3463
+ var CONNECT_REQUIRED_STATUS = 428;
3464
+ async function withPromptowlToken(c, run) {
3465
+ if (!config.PROMPTOWL_TEAMS_ENABLED) {
3466
+ return c.json({ error: "Not found" }, 404);
3467
+ }
3468
+ if (!tryConsume(`po-teams:ip:${clientIp(c)}`, PROMPTOWL_LIMIT)) {
3469
+ return c.json({ error: "Too many PromptOwl requests, try again later" }, 429);
3470
+ }
3471
+ const token = getPromptowlTokenFromRequest(c);
3472
+ if (!token) {
3473
+ return c.json(
3474
+ {
3475
+ error: "Sign in with PromptOwl to import teams.",
3476
+ code: CONNECT_REQUIRED
3477
+ },
3478
+ CONNECT_REQUIRED_STATUS
3479
+ );
3480
+ }
3481
+ try {
3482
+ return c.json(await run(token));
3483
+ } catch (err) {
3484
+ if (err instanceof UnauthorizedError) {
3485
+ c.header(
3486
+ "Set-Cookie",
3487
+ buildPromptowlTokenCookie("", { secure: isSecureRequest(c), clear: true }),
3488
+ { append: true }
3489
+ );
3490
+ return c.json(
3491
+ { error: err.message, code: CONNECT_REQUIRED },
3492
+ CONNECT_REQUIRED_STATUS
3493
+ );
3494
+ }
3495
+ throw err;
3496
+ }
3497
+ }
3498
+ teamRoutes.get(
3499
+ "/promptowl",
3500
+ async (c) => withPromptowlToken(c, async (token) => ({
3501
+ teams: await fetchOwnedPromptowlTeams(token)
3502
+ }))
3503
+ );
3504
+ teamRoutes.post("/promptowl/sync", async (c) => {
3505
+ const body = await c.req.json().catch(() => ({}));
3506
+ return withPromptowlToken(c, async (token) => ({
3507
+ results: await syncPromptowlTeams({
3508
+ callerUserId: c.get("userId"),
3509
+ token,
3510
+ teamIds: body.team_ids ?? []
3511
+ })
3512
+ }));
3513
+ });
3514
+ teamRoutes.post("/", async (c) => {
3515
+ const body = await c.req.json();
3516
+ const team = await createTeam({
3517
+ name: body.name ?? "",
3518
+ ownerId: c.get("userId")
3519
+ });
3520
+ return c.json({ team }, 201);
3521
+ });
3522
+ teamRoutes.get("/", async (c) => {
3523
+ const teams = await listTeamsForUser(c.get("userId"));
3524
+ return c.json({ teams });
3525
+ });
3526
+ teamRoutes.get("/:teamId", async (c) => {
3527
+ const userId = c.get("userId");
3528
+ const team = await getTeam(c.req.param("teamId"));
3529
+ const isMember = team.members.some((m) => m.userId === userId);
3530
+ if (team.owner_id !== userId && !isMember && !await isLicenseAdminUserId(userId)) {
3531
+ throw new ForbiddenError("You don't have access to this team.");
3532
+ }
3533
+ return c.json({ team });
3534
+ });
3535
+ teamRoutes.patch("/:teamId", async (c) => {
3536
+ const body = await c.req.json();
3537
+ const userId = c.get("userId");
3538
+ const team = await renameTeam({
3539
+ teamId: c.req.param("teamId"),
3540
+ name: body.name ?? "",
3541
+ callerUserId: userId,
3542
+ allowAdmin: await isLicenseAdminUserId(userId)
3543
+ });
3544
+ return c.json({ team });
3545
+ });
3546
+ teamRoutes.delete("/:teamId", async (c) => {
3547
+ const userId = c.get("userId");
3548
+ await deleteTeam({
3549
+ teamId: c.req.param("teamId"),
3550
+ callerUserId: userId,
3551
+ allowAdmin: await isLicenseAdminUserId(userId)
3552
+ });
3553
+ return c.json({ removed: true });
3554
+ });
3555
+ teamRoutes.post("/:teamId/members", async (c) => {
3556
+ const body = await c.req.json();
3557
+ const userId = c.get("userId");
3558
+ const team = await addMember({
3559
+ teamId: c.req.param("teamId"),
3560
+ email: body.email,
3561
+ userId: body.user_id,
3562
+ role: body.role ?? "",
3563
+ callerUserId: userId,
3564
+ allowAdmin: await isLicenseAdminUserId(userId)
3565
+ });
3566
+ return c.json({ team }, 201);
3567
+ });
3568
+ teamRoutes.patch("/:teamId/members/:userId", async (c) => {
3569
+ const body = await c.req.json();
3570
+ const userId = c.get("userId");
3571
+ const team = await updateMemberRole({
3572
+ teamId: c.req.param("teamId"),
3573
+ userId: c.req.param("userId"),
3574
+ role: body.role ?? "",
3575
+ callerUserId: userId,
3576
+ allowAdmin: await isLicenseAdminUserId(userId)
3577
+ });
3578
+ return c.json({ team });
3579
+ });
3580
+ teamRoutes.delete("/:teamId/members/:userId", async (c) => {
3581
+ const userId = c.get("userId");
3582
+ const team = await removeMember({
3583
+ teamId: c.req.param("teamId"),
3584
+ userId: c.req.param("userId"),
3585
+ callerUserId: userId,
3586
+ allowAdmin: await isLicenseAdminUserId(userId)
3587
+ });
3588
+ return c.json({ team });
3589
+ });
3590
+
2655
3591
  // src/nodes/routes.ts
2656
- import { Hono as Hono5 } from "hono";
3592
+ import { Hono as Hono8 } from "hono";
3593
+
3594
+ // src/shared/artifact-sandbox.ts
3595
+ var ARTIFACT_SANDBOX_CSP = [
3596
+ "sandbox allow-scripts allow-popups allow-modals",
3597
+ "default-src 'none'",
3598
+ "script-src 'unsafe-inline'",
3599
+ "style-src 'unsafe-inline'",
3600
+ "img-src data: blob:",
3601
+ "font-src data:",
3602
+ "connect-src 'none'",
3603
+ "base-uri 'none'",
3604
+ "form-action 'none'"
3605
+ ].join("; ");
3606
+ function applyArtifactSandboxHeaders(c) {
3607
+ c.header("Content-Security-Policy", ARTIFACT_SANDBOX_CSP);
3608
+ c.header("X-Content-Type-Options", "nosniff");
3609
+ c.header("Referrer-Policy", "no-referrer");
3610
+ }
2657
3611
 
2658
3612
  // src/nodes/markdown-export.ts
3613
+ import { parseDocument as parseDocument3 } from "@promptowl/contextnest-engine";
2659
3614
  function nodeToMarkdown(node) {
2660
3615
  const tags = node.tags ?? [];
2661
3616
  return [
@@ -2674,9 +3629,37 @@ function nodesToMarkdown(nodes) {
2674
3629
  function isMarkdownFormat(c) {
2675
3630
  return c.req.query("format") === "markdown";
2676
3631
  }
3632
+ function isRawFormat(c) {
3633
+ const format = c.req.query("format");
3634
+ if (format !== void 0) return format === "raw";
3635
+ const accept = c.req.header("Accept") ?? "";
3636
+ return accept.split(",").some((part) => part.trim().split(";")[0].trim().toLowerCase() === "text/markdown");
3637
+ }
3638
+ var RAW_CONTENT_TYPES = {
3639
+ artifact: "text/html; charset=utf-8",
3640
+ tool: "text/plain; charset=utf-8",
3641
+ table: "text/plain; charset=utf-8"
3642
+ };
3643
+ function rawContentType(nodeType) {
3644
+ return RAW_CONTENT_TYPES[nodeType || "document"] ?? "text/markdown; charset=utf-8";
3645
+ }
3646
+ function frontmatterBlockOf(raw) {
3647
+ if (!raw || !raw.startsWith("---")) return "";
3648
+ try {
3649
+ const body = parseDocument3("frontmatter-split.md", raw, "frontmatter-split").body ?? "";
3650
+ if (raw.endsWith(body)) return raw.slice(0, raw.length - body.length);
3651
+ } catch {
3652
+ }
3653
+ const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
3654
+ return m ? `---
3655
+ ${m[1]}
3656
+ ---
3657
+
3658
+ ` : "";
3659
+ }
2677
3660
 
2678
3661
  // src/annotations/service.ts
2679
- import { v4 as uuid4 } from "uuid";
3662
+ import { v4 as uuid5 } from "uuid";
2680
3663
 
2681
3664
  // src/annotations/projection.ts
2682
3665
  var MAX_CONTEXT_CHARS = 250;
@@ -2826,7 +3809,7 @@ async function createThread(nestId, nodeId, input, authorEmail) {
2826
3809
  throw new Error("comment body is required");
2827
3810
  }
2828
3811
  const db = getDb();
2829
- const id = uuid4();
3812
+ const id = uuid5();
2830
3813
  const anchor = clampAnchor(input.anchor);
2831
3814
  const snapshot = input.snapshotVersion ?? await getApprovedVersion(nestId, nodeId) ?? null;
2832
3815
  await db.transaction(async (tx) => {
@@ -2845,7 +3828,7 @@ async function createThread(nestId, nodeId, input, authorEmail) {
2845
3828
  );
2846
3829
  await tx.run(
2847
3830
  "INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
2848
- [uuid4(), id, authorEmail, body]
3831
+ [uuid5(), id, authorEmail, body]
2849
3832
  );
2850
3833
  });
2851
3834
  return rowToThread(await getThreadRow(id));
@@ -2865,7 +3848,7 @@ async function addComment(nestId, nodeId, threadId, authorEmail, body) {
2865
3848
  await getScopedThreadRow(threadId, nestId, nodeId);
2866
3849
  await getDb().run(
2867
3850
  "INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
2868
- [uuid4(), threadId, authorEmail, trimmed]
3851
+ [uuid5(), threadId, authorEmail, trimmed]
2869
3852
  );
2870
3853
  return rowToThread(await getThreadRow(threadId));
2871
3854
  }
@@ -2964,7 +3947,7 @@ async function syncAnnotationsNode(nestId, nodeId, userEmail) {
2964
3947
  }
2965
3948
 
2966
3949
  // src/nodes/routes.ts
2967
- var nodeRoutes = new Hono5();
3950
+ var nodeRoutes = new Hono8();
2968
3951
  function nodeAsMarkdown(response, nodeId) {
2969
3952
  return nodeToMarkdown({
2970
3953
  id: nodeId,
@@ -2974,6 +3957,17 @@ function nodeAsMarkdown(response, nodeId) {
2974
3957
  body: response.content
2975
3958
  });
2976
3959
  }
3960
+ function rawNodeBody(c, response, storedRaw) {
3961
+ const body = response.content ?? "";
3962
+ const payload = c.req.query("frontmatter") === "1" ? frontmatterBlockOf(storedRaw) + body : body;
3963
+ c.header("X-Content-Type-Options", "nosniff");
3964
+ if ((response.type || "document") === "artifact") {
3965
+ applyArtifactSandboxHeaders(c);
3966
+ }
3967
+ return c.body(payload, 200, {
3968
+ "Content-Type": rawContentType(response.type)
3969
+ });
3970
+ }
2977
3971
  nodeRoutes.get("/", async (c) => {
2978
3972
  const nestId = c.req.param("nestId");
2979
3973
  const userId = c.get("userId");
@@ -3017,7 +4011,7 @@ nodeRoutes.post("/", async (c) => {
3017
4011
  nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
3018
4012
  const nestId = c.req.param("nestId");
3019
4013
  const nodeId = c.req.param("nodeId");
3020
- const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-D5PDWXBR.js");
4014
+ const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-7BHI2YXB.js");
3021
4015
  const { stewards, fallbackToOwner, ownerEmail } = await resolveStewardsWithFallback2(
3022
4016
  nestId,
3023
4017
  nodeId
@@ -3038,7 +4032,7 @@ nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
3038
4032
  nodeRoutes.get("/:nodeId{.+}/versions", async (c) => {
3039
4033
  const nestId = c.req.param("nestId");
3040
4034
  const nodeId = c.req.param("nodeId");
3041
- const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-A2YAKYQW.js");
4035
+ const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-YQIIQ7FN.js");
3042
4036
  const allVersions = await getVersions2(nestId, nodeId);
3043
4037
  const approved = await getApprovedVersion2(nestId, nodeId);
3044
4038
  const db = getDb();
@@ -3070,7 +4064,7 @@ nodeRoutes.get("/:nodeId{.+}/versions", async (c) => {
3070
4064
  nodeRoutes.get("/:nodeId{.+}/reviews", async (c) => {
3071
4065
  const nestId = c.req.param("nestId");
3072
4066
  const nodeId = c.req.param("nodeId");
3073
- const { getReviewHistory: getReviewHistory2 } = await import("./review-service-VLSUT6KK.js");
4067
+ const { getReviewHistory: getReviewHistory2 } = await import("./review-service-DPN26C47.js");
3074
4068
  const history = await getReviewHistory2(nestId, nodeId);
3075
4069
  return c.json({ reviews: history });
3076
4070
  });
@@ -3189,15 +4183,26 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
3189
4183
  }
3190
4184
  }
3191
4185
  const response = toNodeResponse(node);
3192
- if (await isPublicReader(nestId, userId)) {
4186
+ const publicReader = await isPublicReader(nestId, userId);
4187
+ if (!publicReader) {
4188
+ const people = (await resolveAuthorship(nestId, [
4189
+ { id: nodeId, frontmatterAuthor: node.frontmatter?.author ?? null }
4190
+ ])).get(nodeId);
4191
+ if (people) {
4192
+ response.author = people.author;
4193
+ response.collaborators = people.collaborators;
4194
+ }
4195
+ }
4196
+ if (publicReader) {
3193
4197
  const approved = await getApprovedVersion(nestId, nodeId);
3194
4198
  if (approved != null) {
4199
+ let approvedRaw = "";
3195
4200
  try {
3196
- const raw = await versionManager.reconstructVersion(
4201
+ approvedRaw = await versionManager.reconstructVersion(
3197
4202
  nodeId,
3198
4203
  approved
3199
4204
  );
3200
- response.content = bodyOnly(nodeId, raw);
4205
+ response.content = bodyOnly(nodeId, approvedRaw);
3201
4206
  } catch (err) {
3202
4207
  console.error(
3203
4208
  "reconstructVersion failed (public single)",
@@ -3209,6 +4214,9 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
3209
4214
  }
3210
4215
  response.version = approved;
3211
4216
  response.status = "published";
4217
+ if (isRawFormat(c)) {
4218
+ return rawNodeBody(c, response, approvedRaw);
4219
+ }
3212
4220
  if (isMarkdownFormat(c)) {
3213
4221
  return c.body(nodeAsMarkdown(response, nodeId), 200, {
3214
4222
  "Content-Type": "text/markdown; charset=utf-8"
@@ -3222,6 +4230,9 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
3222
4230
  const pending = await getPendingReview(nestId, nodeId);
3223
4231
  response.pendingReviewBy = pending?.requestedBy ?? null;
3224
4232
  }
4233
+ if (isRawFormat(c)) {
4234
+ return rawNodeBody(c, response, node.rawContent);
4235
+ }
3225
4236
  if (isMarkdownFormat(c)) {
3226
4237
  return c.body(nodeAsMarkdown(response, nodeId), 200, {
3227
4238
  "Content-Type": "text/markdown; charset=utf-8"
@@ -3347,13 +4358,13 @@ async function getUserEmail(c) {
3347
4358
  }
3348
4359
 
3349
4360
  // src/annotations/routes.ts
3350
- import { Hono as Hono6 } from "hono";
4361
+ import { Hono as Hono9 } from "hono";
3351
4362
 
3352
4363
  // src/annotations/types.ts
3353
4364
  var ARTIFACT_NODE_TYPE = "artifact";
3354
4365
 
3355
4366
  // src/annotations/routes.ts
3356
- var annotationRoutes = new Hono6();
4367
+ var annotationRoutes = new Hono9();
3357
4368
  function getNodeId(c) {
3358
4369
  const raw = c.req.param("nodeId");
3359
4370
  try {
@@ -3461,28 +4472,13 @@ annotationRoutes.get("/:nodeId{.+}/hosted", async (c) => {
3461
4472
  if (type !== null && type !== ARTIFACT_NODE_TYPE) {
3462
4473
  return c.json({ error: "Not a hosted artifact" }, 404);
3463
4474
  }
3464
- c.header(
3465
- "Content-Security-Policy",
3466
- [
3467
- "sandbox allow-scripts allow-popups allow-modals",
3468
- "default-src 'none'",
3469
- "script-src 'unsafe-inline'",
3470
- "style-src 'unsafe-inline'",
3471
- "img-src data: blob:",
3472
- "font-src data:",
3473
- "connect-src 'none'",
3474
- "base-uri 'none'",
3475
- "form-action 'none'"
3476
- ].join("; ")
3477
- );
3478
- c.header("X-Content-Type-Options", "nosniff");
3479
- c.header("Referrer-Policy", "no-referrer");
4475
+ applyArtifactSandboxHeaders(c);
3480
4476
  if (served != null) c.header("X-Artifact-Snapshot", String(served));
3481
4477
  return c.html(html);
3482
4478
  });
3483
4479
 
3484
4480
  // src/nodes/query-routes.ts
3485
- import { Hono as Hono7 } from "hono";
4481
+ import { Hono as Hono10 } from "hono";
3486
4482
  import { serializeDocument as serializeDocument2 } from "@promptowl/contextnest-engine";
3487
4483
 
3488
4484
  // src/tables/service.ts
@@ -4053,7 +5049,7 @@ async function buildNestGraph(nestId, userId, userEmail, canSeeIdentities) {
4053
5049
  }
4054
5050
 
4055
5051
  // src/nodes/query-routes.ts
4056
- var queryRoutes = new Hono7();
5052
+ var queryRoutes = new Hono10();
4057
5053
  queryRoutes.get("/changes", async (c) => {
4058
5054
  const nestId = c.req.param("nestId");
4059
5055
  const userId = c.get("userId");
@@ -4517,6 +5513,15 @@ queryRoutes.post("/publish", async (c) => {
4517
5513
  if (!body.documents?.length && !body.context_md) {
4518
5514
  throw new ValidationError("documents array or context_md is required");
4519
5515
  }
5516
+ for (const doc of body.documents || []) {
5517
+ const t = doc.type || "document";
5518
+ if (t === "artifact" && !config.TYPE_ARTIFACT_ENABLED) {
5519
+ throw new ValidationError("Artifact nodes are disabled on this server");
5520
+ }
5521
+ if (t === "table" && !config.TYPE_TABLE_ENABLED) {
5522
+ throw new ValidationError("Table nodes are disabled on this server");
5523
+ }
5524
+ }
4520
5525
  const nestId = c.req.param("nestId");
4521
5526
  const { storage } = await engineCache.get(nestId);
4522
5527
  const created = [];
@@ -4565,7 +5570,7 @@ queryRoutes.post("/publish", async (c) => {
4565
5570
  });
4566
5571
 
4567
5572
  // src/mcp/routes.ts
4568
- import { Hono as Hono9 } from "hono";
5573
+ import { Hono as Hono12 } from "hono";
4569
5574
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4570
5575
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
4571
5576
 
@@ -4645,7 +5650,7 @@ async function listTraceEvents(filters = {}) {
4645
5650
  }
4646
5651
 
4647
5652
  // src/mcp/workflow-tools.ts
4648
- import { v4 as uuid7 } from "uuid";
5653
+ import { v4 as uuid8 } from "uuid";
4649
5654
 
4650
5655
  // src/shared/node-id.ts
4651
5656
  function assertSafeNodeId(rawId) {
@@ -4672,7 +5677,7 @@ function assertSafeNodeId(rawId) {
4672
5677
  }
4673
5678
 
4674
5679
  // src/workflow/run-service.ts
4675
- import { v4 as uuid5 } from "uuid";
5680
+ import { v4 as uuid6 } from "uuid";
4676
5681
  var RUNNABLE_TYPES = ["agent", "skill"];
4677
5682
  async function flowSubgraph(nestId, agentNode) {
4678
5683
  const db = getDb();
@@ -4802,7 +5807,7 @@ async function triggerRun(opts) {
4802
5807
  }
4803
5808
  const edges = await flowSubgraph(nestId, agentNode);
4804
5809
  const definitions = await referencedDefinitions(nestId, edges);
4805
- const id = `run_${uuid5()}`;
5810
+ const id = `run_${uuid6()}`;
4806
5811
  const db = getDb();
4807
5812
  await db.transaction(async (tx) => {
4808
5813
  if (parentRunId) {
@@ -4963,8 +5968,8 @@ async function listRuns(nestId, opts) {
4963
5968
  }
4964
5969
 
4965
5970
  // src/workflow/edge-routes.ts
4966
- import { Hono as Hono8 } from "hono";
4967
- import { v4 as uuid6 } from "uuid";
5971
+ import { Hono as Hono11 } from "hono";
5972
+ import { v4 as uuid7 } from "uuid";
4968
5973
  function toResponse(e, t) {
4969
5974
  return {
4970
5975
  id: e.id,
@@ -5066,7 +6071,7 @@ async function assertNoFlowCycle(db, nestId, fromNode, toNode) {
5066
6071
  }
5067
6072
  }
5068
6073
  }
5069
- var edgeRoutes = new Hono8();
6074
+ var edgeRoutes = new Hono11();
5070
6075
  edgeRoutes.get("/", requireWorkflowPlane, async (c) => {
5071
6076
  const nestId = c.req.param("nestId");
5072
6077
  const db = getDb();
@@ -5134,7 +6139,7 @@ edgeRoutes.post("/", requireWorkflowPlane, async (c) => {
5134
6139
  );
5135
6140
  const metadata = body.metadata === void 0 || body.metadata === null ? null : JSON.stringify(body.metadata);
5136
6141
  const now = (/* @__PURE__ */ new Date()).toISOString();
5137
- const id = uuid6();
6142
+ const id = uuid7();
5138
6143
  await db.transaction(async (tx) => {
5139
6144
  if (type.is_flow) {
5140
6145
  await assertNoFlowCycle(tx, nestId, fromNode, toNode);
@@ -5526,7 +6531,7 @@ async function createEdge(ctx, args) {
5526
6531
  );
5527
6532
  const metadata = args.metadata === void 0 || args.metadata === null ? null : JSON.stringify(args.metadata);
5528
6533
  const now = (/* @__PURE__ */ new Date()).toISOString();
5529
- const id = uuid7();
6534
+ const id = uuid8();
5530
6535
  await db.transaction(async (tx) => {
5531
6536
  if (type.is_flow) {
5532
6537
  await assertNoFlowCycle(tx, ctx.nestId, fromNode, toNode);
@@ -5589,7 +6594,7 @@ async function upsertEdgeType(ctx, args) {
5589
6594
  `INSERT INTO edge_types
5590
6595
  (id, nest_id, name, description, direction, is_flow, condition_schema, color, created_by, created_at, updated_at)
5591
6596
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
5592
- [uuid7(), ctx.nestId, name, args.description.trim(), direction, isFlow, conditionSchema, color, definedBy, now, now]
6597
+ [uuid8(), ctx.nestId, name, args.description.trim(), direction, isFlow, conditionSchema, color, definedBy, now, now]
5593
6598
  );
5594
6599
  }
5595
6600
  });
@@ -5765,7 +6770,7 @@ var TOOL_DEFINITIONS = [
5765
6770
  },
5766
6771
  type: {
5767
6772
  type: "string",
5768
- description: "Node type (default: document)"
6773
+ description: "Node type (default: document). Note: artifact and table can be disabled server-wide by an admin \u2014 creating a disabled type is rejected; existing nodes of that type stay readable."
5769
6774
  },
5770
6775
  tags: {
5771
6776
  type: "array",
@@ -6466,19 +7471,19 @@ ${list}`;
6466
7471
  if (!target) return "target is required (a node id or folder prefix).";
6467
7472
  if (!["read", "write"].includes(role)) return "role must be read or write.";
6468
7473
  try {
6469
- const { createGrant: createGrant2 } = await import("./grants-service-6H4WUA2F.js");
6470
- const db = (await import("./client-VZLX4THW.js")).getDb();
7474
+ const { createGrant: createGrant2 } = await import("./grants-service-PS5RMMEA.js");
7475
+ const db = (await import("./client-YUOEVUFU.js")).getDb();
6471
7476
  const { normalizeEmail: normalizeEmail2 } = await import("./email-R7DFS6E5.js");
6472
7477
  const e = normalizeEmail2(String(args.email || ""));
6473
7478
  if (!e) return "email is required.";
6474
7479
  let row = await db.get("SELECT id FROM users WHERE LOWER(email) = ?", [e]);
6475
7480
  if (!row) {
6476
7481
  const { hashPassword: hashPassword2 } = await import("./keys-73STFJJB.js");
6477
- const { v4: uuid15 } = await import("uuid");
6478
- const id = uuid15();
7482
+ const { v4: uuid16 } = await import("uuid");
7483
+ const id = uuid16();
6479
7484
  await db.run(
6480
7485
  "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
6481
- [id, e, null, await hashPassword2(uuid15())]
7486
+ [id, e, null, await hashPassword2(uuid16())]
6482
7487
  );
6483
7488
  row = { id };
6484
7489
  }
@@ -6496,7 +7501,7 @@ ${list}`;
6496
7501
  }
6497
7502
  }
6498
7503
  case "context_unsynced_list": {
6499
- if (config.AUTH_MODE !== "open" && !await isLicenseAdminUserId(userId)) {
7504
+ if (config.AUTH_MODE !== "open" && !await isServerAdminUserId(userId)) {
6500
7505
  return "You don't have permission to list unsynced folders. Server admin only.";
6501
7506
  }
6502
7507
  const folders = listUnsyncedFolders();
@@ -6509,7 +7514,7 @@ ${list}`;
6509
7514
  ${list}`;
6510
7515
  }
6511
7516
  case "context_sync_folder": {
6512
- if (config.AUTH_MODE !== "open" && !await isLicenseAdminUserId(userId)) {
7517
+ if (config.AUTH_MODE !== "open" && !await isServerAdminUserId(userId)) {
6513
7518
  return "You don't have permission to sync folders. Server admin only.";
6514
7519
  }
6515
7520
  try {
@@ -6533,7 +7538,7 @@ ${list}`;
6533
7538
 
6534
7539
  // src/mcp/routes.ts
6535
7540
  import { z } from "zod";
6536
- var mcpRoutes = new Hono9();
7541
+ var mcpRoutes = new Hono12();
6537
7542
  async function getUserEmail2(userId) {
6538
7543
  const db = getDb();
6539
7544
  const user = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
@@ -6599,8 +7604,8 @@ mcpRoutes.all("/", async (c) => {
6599
7604
  });
6600
7605
 
6601
7606
  // src/workflow/run-routes.ts
6602
- import { Hono as Hono10 } from "hono";
6603
- import { v4 as uuid8 } from "uuid";
7607
+ import { Hono as Hono13 } from "hono";
7608
+ import { v4 as uuid9 } from "uuid";
6604
7609
  var RUNNABLE_TYPES2 = ["agent", "skill"];
6605
7610
  var safeJson2 = (s) => {
6606
7611
  if (!s) return null;
@@ -6718,7 +7723,7 @@ async function buildRunBundle(nestId, agentNode) {
6718
7723
  definitions
6719
7724
  };
6720
7725
  }
6721
- var runTriggerRoutes = new Hono10();
7726
+ var runTriggerRoutes = new Hono13();
6722
7727
  async function resolveParentRun2(nestId, parentRunId, childAgent) {
6723
7728
  if (!config.FEATURE_SUBAGENT_RUNS) {
6724
7729
  throw new ValidationError(
@@ -6797,7 +7802,7 @@ runTriggerRoutes.post("/:agentNode{.+}", requireWorkflowPlane, async (c) => {
6797
7802
  }
6798
7803
  const bundle = await buildRunBundle(nestId, agentNode);
6799
7804
  const triggeredBy = await resolveCallerEmail(c.get("userId"));
6800
- const id = `run_${uuid8()}`;
7805
+ const id = `run_${uuid9()}`;
6801
7806
  await getDb().run(
6802
7807
  `INSERT INTO runs (id, nest_id, agent_node, triggered_by, status, inputs, started_at, parent_run_id, depth)
6803
7808
  VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?)`,
@@ -6826,7 +7831,7 @@ runTriggerRoutes.post("/:agentNode{.+}", requireWorkflowPlane, async (c) => {
6826
7831
  201
6827
7832
  );
6828
7833
  });
6829
- var runListRoutes = new Hono10();
7834
+ var runListRoutes = new Hono13();
6830
7835
  runListRoutes.get("/", requireWorkflowPlane, async (c) => {
6831
7836
  const nestId = c.req.param("nestId");
6832
7837
  const db = getDb();
@@ -6858,7 +7863,7 @@ runListRoutes.get("/", requireWorkflowPlane, async (c) => {
6858
7863
  }))
6859
7864
  });
6860
7865
  });
6861
- var runDetailRoutes = new Hono10();
7866
+ var runDetailRoutes = new Hono13();
6862
7867
  async function loadRunWithPermission(c, runId, required) {
6863
7868
  const run = await getDb().get("SELECT * FROM runs WHERE id = ?", [
6864
7869
  runId
@@ -7001,8 +8006,8 @@ runDetailRoutes.get("/:id", requireWorkflowPlane, async (c) => {
7001
8006
  });
7002
8007
 
7003
8008
  // src/workflow/schedule-routes.ts
7004
- import { Hono as Hono11 } from "hono";
7005
- import { v4 as uuid9 } from "uuid";
8009
+ import { Hono as Hono14 } from "hono";
8010
+ import { v4 as uuid10 } from "uuid";
7006
8011
  var RUNNABLE_TYPES3 = ["agent", "skill"];
7007
8012
  var MIN_EVERY = 5;
7008
8013
  var MAX_EVERY = 7 * 24 * 60;
@@ -7039,7 +8044,7 @@ function parseEvery(v) {
7039
8044
  }
7040
8045
  return n;
7041
8046
  }
7042
- var scheduleRoutes = new Hono11();
8047
+ var scheduleRoutes = new Hono14();
7043
8048
  scheduleRoutes.get("/", requireWorkflowPlane, async (c) => {
7044
8049
  const nestId = c.req.param("nestId");
7045
8050
  const rows = await getDb().all(
@@ -7080,7 +8085,7 @@ scheduleRoutes.post("/", requireWorkflowPlane, async (c) => {
7080
8085
  );
7081
8086
  }
7082
8087
  const now = (/* @__PURE__ */ new Date()).toISOString();
7083
- const id = `sch_${uuid9()}`;
8088
+ const id = `sch_${uuid10()}`;
7084
8089
  await db.run(
7085
8090
  `INSERT INTO schedules (id, nest_id, agent_node, every_minutes, enabled, last_run_at, max_runs, runs_created, created_by, created_at, updated_at)
7086
8091
  VALUES (?, ?, ?, ?, ?, NULL, ?, 0, ?, ?, ?)`,
@@ -7135,8 +8140,8 @@ scheduleRoutes.delete("/:id", requireWorkflowPlane, async (c) => {
7135
8140
  });
7136
8141
 
7137
8142
  // src/notify/connector-routes.ts
7138
- import { Hono as Hono12 } from "hono";
7139
- import { v4 as uuid10 } from "uuid";
8143
+ import { Hono as Hono15 } from "hono";
8144
+ import { v4 as uuid11 } from "uuid";
7140
8145
  var KNOWN_EVENTS = [
7141
8146
  "review_requested",
7142
8147
  "review_approved",
@@ -7182,7 +8187,7 @@ function parseUrl(v) {
7182
8187
  'url must be a public https:// URL (loopback/private/metadata hosts are refused) or an env reference like "env:SLACK_WEBHOOK"'
7183
8188
  );
7184
8189
  }
7185
- var connectorRoutes = new Hono12();
8190
+ var connectorRoutes = new Hono15();
7186
8191
  connectorRoutes.get("/", async (c) => {
7187
8192
  const nestId = c.req.param("nestId");
7188
8193
  const rows = await getDb().all(
@@ -7201,7 +8206,7 @@ connectorRoutes.post("/", async (c) => {
7201
8206
  const url = parseUrl(body.url);
7202
8207
  const events = parseEvents(body.events);
7203
8208
  const now = (/* @__PURE__ */ new Date()).toISOString();
7204
- const id = `con_${uuid10()}`;
8209
+ const id = `con_${uuid11()}`;
7205
8210
  await getDb().run(
7206
8211
  `INSERT INTO connectors (id, nest_id, channel, url, events, enabled, created_by, created_at, updated_at)
7207
8212
  VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)`,
@@ -7244,7 +8249,7 @@ connectorRoutes.delete("/:id", async (c) => {
7244
8249
  });
7245
8250
 
7246
8251
  // src/workflow/hook-routes.ts
7247
- import { Hono as Hono13 } from "hono";
8252
+ import { Hono as Hono16 } from "hono";
7248
8253
  import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2, randomUUID } from "crypto";
7249
8254
  var PRESETS = ["slack", "teams", "webhook"];
7250
8255
  var RUNNABLE_TYPES4 = ["agent", "skill"];
@@ -7267,7 +8272,7 @@ function toResponse4(c, r, opts = {}) {
7267
8272
  created_at: r.created_at
7268
8273
  };
7269
8274
  }
7270
- var hookNestRoutes = new Hono13();
8275
+ var hookNestRoutes = new Hono16();
7271
8276
  hookNestRoutes.get("/", requireWorkflowPlane, async (c) => {
7272
8277
  const nestId = c.req.param("nestId");
7273
8278
  const agent = c.req.query("agent");
@@ -7341,7 +8346,7 @@ function teamsSignatureValid(secret, authHeader, rawBody) {
7341
8346
  function stripMentions(text) {
7342
8347
  return text.replace(/<@[^>]+>/g, "").replace(/<at>[^<]*<\/at>/gi, "").trim();
7343
8348
  }
7344
- var hookFireRoutes = new Hono13();
8349
+ var hookFireRoutes = new Hono16();
7345
8350
  hookFireRoutes.post("/:token", async (c) => {
7346
8351
  const token = c.req.param("token");
7347
8352
  if (!tryConsume("hook:*", { max: 120, windowMs: 6e4 })) {
@@ -7441,9 +8446,9 @@ hookFireRoutes.post("/:token", async (c) => {
7441
8446
  });
7442
8447
 
7443
8448
  // src/definitions/routes.ts
7444
- import { Hono as Hono14 } from "hono";
7445
- import { v4 as uuid11 } from "uuid";
7446
- var definitionRoutes = new Hono14();
8449
+ import { Hono as Hono17 } from "hono";
8450
+ import { v4 as uuid12 } from "uuid";
8451
+ var definitionRoutes = new Hono17();
7447
8452
  definitionRoutes.get("/", async (c) => {
7448
8453
  const nestId = c.req.param("nestId");
7449
8454
  const db = getDb();
@@ -7522,7 +8527,7 @@ definitionRoutes.post("/", async (c) => {
7522
8527
  await applyUpdate(tx, existing.id);
7523
8528
  targetId = existing.id;
7524
8529
  } else {
7525
- targetId = uuid11();
8530
+ targetId = uuid12();
7526
8531
  wasCreated = true;
7527
8532
  await tx.run(
7528
8533
  `INSERT INTO definitions (id, nest_id, term, definition, linked_tag, defined_by, created_at, updated_at)
@@ -7551,41 +8556,69 @@ definitionRoutes.delete("/:id", async (c) => {
7551
8556
  });
7552
8557
 
7553
8558
  // src/nests/asset-routes.ts
7554
- import { Hono as Hono15 } from "hono";
7555
- import { v4 as uuid12 } from "uuid";
8559
+ import { Hono as Hono18 } from "hono";
8560
+ import { v4 as uuid13 } from "uuid";
7556
8561
  import { mkdir as mkdir2, readFile as readFile2, writeFile } from "fs/promises";
7557
8562
  import { join as join4 } from "path";
7558
- var assetRoutes = new Hono15();
8563
+ var assetRoutes = new Hono18();
7559
8564
  var CONTENT_TYPES = {
7560
8565
  png: "image/png",
7561
8566
  jpg: "image/jpeg",
7562
8567
  jpeg: "image/jpeg",
7563
8568
  gif: "image/gif",
7564
- webp: "image/webp"
8569
+ webp: "image/webp",
8570
+ mp4: "video/mp4",
8571
+ webm: "video/webm"
7565
8572
  };
7566
- var SAFE_NAME = /^[0-9a-f-]{36}\.(png|jpe?g|gif|webp)$/;
8573
+ var VIDEO_EXTS = /* @__PURE__ */ new Set(["mp4", "webm"]);
8574
+ var IMAGE_MAX_BYTES = 10 * 1024 * 1024;
8575
+ var VIDEO_MAX_BYTES = 100 * 1024 * 1024;
8576
+ var ASSET_MAX_REQUEST_BYTES = VIDEO_MAX_BYTES + 1024 * 1024;
8577
+ var SAFE_NAME = /^[0-9a-f-]{36}\.(png|jpe?g|gif|webp|mp4|webm)$/;
7567
8578
  assetRoutes.post("/", async (c) => {
7568
8579
  const nestId = c.req.param("nestId");
8580
+ const lenStr = c.req.header("Content-Length");
8581
+ if (lenStr) {
8582
+ const len = parseInt(lenStr, 10);
8583
+ if (Number.isFinite(len) && len > ASSET_MAX_REQUEST_BYTES) {
8584
+ throw new ValidationError(
8585
+ `upload too large (${(len / (1024 * 1024)).toFixed(1)} MB, max ${VIDEO_MAX_BYTES / (1024 * 1024)} MB for video, ${IMAGE_MAX_BYTES / (1024 * 1024)} MB for images)`
8586
+ );
8587
+ }
8588
+ }
7569
8589
  const body = await c.req.parseBody();
7570
8590
  const file = body["file"];
7571
8591
  if (!(file instanceof File)) {
7572
- throw new ValidationError('multipart field "file" (an image) is required');
8592
+ throw new ValidationError(
8593
+ 'multipart field "file" (an image or video) is required'
8594
+ );
7573
8595
  }
7574
8596
  const ext = (file.name.split(".").pop() || "").toLowerCase();
7575
8597
  if (!CONTENT_TYPES[ext]) {
7576
8598
  throw new ValidationError(
7577
- `unsupported image type ".${ext}" \u2014 allowed: ${Object.keys(CONTENT_TYPES).join(", ")}`
8599
+ `unsupported asset type ".${ext}" \u2014 allowed: ${Object.keys(CONTENT_TYPES).join(", ")}`
8600
+ );
8601
+ }
8602
+ const isVideo = VIDEO_EXTS.has(ext);
8603
+ const maxBytes = isVideo ? VIDEO_MAX_BYTES : IMAGE_MAX_BYTES;
8604
+ if (file.size > maxBytes) {
8605
+ throw new ValidationError(
8606
+ `${isVideo ? "video" : "image"} too large (${(file.size / (1024 * 1024)).toFixed(1)} MB, max ${maxBytes / (1024 * 1024)} MB for ${isVideo ? "video" : "images"})`
7578
8607
  );
7579
8608
  }
7580
- const name = `${uuid12()}.${ext === "jpeg" ? "jpg" : ext}`;
8609
+ const name = `${uuid13()}.${ext === "jpeg" ? "jpg" : ext}`;
7581
8610
  const dir = join4(resolveNestPath(nestId), "assets");
7582
8611
  await mkdir2(dir, { recursive: true });
7583
8612
  await writeFile(join4(dir, name), Buffer.from(await file.arrayBuffer()));
8613
+ const url = `/nests/${nestId}/assets/${name}`;
7584
8614
  return c.json(
7585
8615
  {
7586
8616
  file: name,
7587
- url: `/nests/${nestId}/assets/${name}`,
7588
- markdown: `![${file.name.replace(/\.[^.]+$/, "")}](/nests/${nestId}/assets/${name})`
8617
+ url,
8618
+ // Ready-to-paste reference: image syntax for images; videos are
8619
+ // referenced by bare URL on its own line (the read view upgrades
8620
+ // same-origin asset video URLs to a <video> player).
8621
+ markdown: isVideo ? url : `![${file.name.replace(/\.[^.]+$/, "")}](${url})`
7589
8622
  },
7590
8623
  201
7591
8624
  );
@@ -7606,12 +8639,15 @@ assetRoutes.get("/:file", async (c) => {
7606
8639
  return c.body(new Uint8Array(bytes), 200, {
7607
8640
  "Content-Type": CONTENT_TYPES[ext],
7608
8641
  // uuid names never change content — cache hard.
7609
- "Cache-Control": "public, max-age=31536000, immutable"
8642
+ "Cache-Control": "public, max-age=31536000, immutable",
8643
+ // Never let the browser second-guess the declared type on direct
8644
+ // navigation (same posture as annotation attachments).
8645
+ "X-Content-Type-Options": "nosniff"
7610
8646
  });
7611
8647
  });
7612
8648
 
7613
8649
  // src/mcp/server-routes.ts
7614
- import { Hono as Hono16 } from "hono";
8650
+ import { Hono as Hono19 } from "hono";
7615
8651
  import { McpServer as McpServer2 } from "@modelcontextprotocol/sdk/server/mcp.js";
7616
8652
  import { WebStandardStreamableHTTPServerTransport as WebStandardStreamableHTTPServerTransport2 } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
7617
8653
  import { z as z2 } from "zod";
@@ -7852,7 +8888,7 @@ function createServerMcp(userId, userEmail, nestScope, baseUrl) {
7852
8888
  }
7853
8889
  return server;
7854
8890
  }
7855
- var serverMcpRoutes = new Hono16();
8891
+ var serverMcpRoutes = new Hono19();
7856
8892
  serverMcpRoutes.all("/", async (c) => {
7857
8893
  const userId = c.get("userId");
7858
8894
  const userEmail = await getUserEmail3(userId);
@@ -7874,7 +8910,7 @@ serverMcpRoutes.all("/", async (c) => {
7874
8910
  await server.server.close();
7875
8911
  }
7876
8912
  });
7877
- var nestIndexRoutes = new Hono16();
8913
+ var nestIndexRoutes = new Hono19();
7878
8914
  nestIndexRoutes.get("/", async (c) => {
7879
8915
  const userId = c.get("userId");
7880
8916
  const nests = await buildNestIndex(userId, c.get("nestScope"));
@@ -7944,6 +8980,7 @@ Other read endpoints (all under \`${B}/nests/<nest-id>\`):
7944
8980
  - \`GET /search?q=<terms>\` \u2014 full-text search
7945
8981
  - \`GET /overview\` \u2014 node count, types, tags
7946
8982
  - \`GET /nodes\` \u2014 list; \`GET /nodes/<node-id>?format=markdown\` \u2014 one document
8983
+ - \`GET /nodes/<node-id>?format=raw\` \u2014 direct consumption path: the node BODY as plain text (no JSON envelope; add \`&frontmatter=1\` for the stored YAML block)
7947
8984
  - \`GET /export?format=markdown\` \u2014 whole nest as one markdown file (optional \`selector\`, \`max_tokens\`)
7948
8985
  - \`GET /graph\` \u2014 ontology graph (nodes, links, tags, stewards)
7949
8986
 
@@ -7996,17 +9033,145 @@ npx -y mcp-remote ${B}/mcp --header "Authorization: Bearer <api-key>"
7996
9033
  through steward review before they become AI-visible.
7997
9034
  `;
7998
9035
  }
7999
- var llmsTxtRoutes = new Hono16();
9036
+ var llmsTxtRoutes = new Hono19();
8000
9037
  llmsTxtRoutes.get("/", (c) => {
8001
9038
  const md = renderLlmsTxt(requestBaseUrl(c.req.url));
8002
9039
  return c.text(md, 200, { "Content-Type": "text/markdown; charset=utf-8" });
8003
9040
  });
8004
9041
 
8005
9042
  // src/governance/routes.ts
8006
- import { Hono as Hono17 } from "hono";
9043
+ import { Hono as Hono20 } from "hono";
9044
+
9045
+ // src/governance/feed-service.ts
9046
+ var SNIPPET_CHARS = 200;
9047
+ function snippet(body) {
9048
+ const flat = body.replace(/\s+/g, " ").trim();
9049
+ return flat.length > SNIPPET_CHARS ? `${flat.slice(0, SNIPPET_CHARS)}\u2026` : flat;
9050
+ }
9051
+ async function getNestFeed(params) {
9052
+ const db = getDb();
9053
+ const filter = params.filter ?? "all";
9054
+ const limit = Number.isFinite(params.limit) && params.limit > 0 ? Math.min(params.limit, 200) : 50;
9055
+ const offset = Number.isFinite(params.offset) && params.offset > 0 ? params.offset : 0;
9056
+ const fetchWindow = offset + limit;
9057
+ const titles = await buildTitleMap(params.nestId);
9058
+ let changes = [];
9059
+ let changesTotal = 0;
9060
+ {
9061
+ changesTotal = (await db.get(
9062
+ "SELECT COUNT(*) AS c FROM review_requests WHERE nest_id = ? AND status = 'pending'",
9063
+ [params.nestId]
9064
+ )).c;
9065
+ if (filter !== "comments" && changesTotal > 0) {
9066
+ const rows = await db.all(
9067
+ `SELECT * FROM review_requests
9068
+ WHERE nest_id = ? AND status = 'pending'
9069
+ ORDER BY requested_at DESC, id DESC
9070
+ LIMIT ?`,
9071
+ [params.nestId, fetchWindow]
9072
+ );
9073
+ const distinctNodeIds = [
9074
+ ...new Set(rows.map((r) => r.node_id))
9075
+ ];
9076
+ const canReviewByNode = new Map(
9077
+ await Promise.all(
9078
+ distinctNodeIds.map(
9079
+ async (nodeId) => [
9080
+ nodeId,
9081
+ (await canUserApprove(params.nestId, nodeId, params.userEmail)).allowed
9082
+ ]
9083
+ )
9084
+ )
9085
+ );
9086
+ changes = rows.map((row) => ({
9087
+ type: "change",
9088
+ id: row.id,
9089
+ nestId: row.nest_id,
9090
+ nodeId: row.node_id,
9091
+ title: titles.get(row.node_id),
9092
+ version: row.version,
9093
+ requestedBy: row.requested_by,
9094
+ requestedAt: row.requested_at,
9095
+ requestNote: row.request_note || void 0,
9096
+ status: row.status,
9097
+ resolvedBy: row.resolved_by || void 0,
9098
+ resolvedAt: row.resolved_at || void 0,
9099
+ resolutionNote: row.resolution_note || void 0,
9100
+ priority: row.priority,
9101
+ canReview: canReviewByNode.get(row.node_id) ?? false,
9102
+ createdAt: row.requested_at
9103
+ }));
9104
+ }
9105
+ }
9106
+ let comments = [];
9107
+ let commentsTotal = 0;
9108
+ {
9109
+ const nodeRows = await db.all(
9110
+ "SELECT DISTINCT node_id AS id FROM annotation_threads WHERE nest_id = ?",
9111
+ [params.nestId]
9112
+ );
9113
+ const accessible = await filterAccessible(
9114
+ params.nestId,
9115
+ params.userId,
9116
+ params.userEmail,
9117
+ nodeRows
9118
+ );
9119
+ if (accessible.length > 0) {
9120
+ const placeholders = accessible.map(() => "?").join(",");
9121
+ const ids = accessible.map((n) => n.id);
9122
+ commentsTotal = (await db.get(
9123
+ `SELECT COUNT(*) AS c
9124
+ FROM annotation_comments c
9125
+ JOIN annotation_threads t ON t.id = c.thread_id
9126
+ WHERE t.nest_id = ? AND t.node_id IN (${placeholders})`,
9127
+ [params.nestId, ...ids]
9128
+ )).c;
9129
+ if (filter !== "changes" && commentsTotal > 0) {
9130
+ const rows = await db.all(
9131
+ `SELECT c.id, c.thread_id, c.author, c.body, c.created_at,
9132
+ t.node_id, t.status,
9133
+ CASE WHEN c.id = (
9134
+ SELECT c2.id FROM annotation_comments c2
9135
+ WHERE c2.thread_id = c.thread_id
9136
+ ORDER BY c2.created_at ASC, c2.id ASC LIMIT 1
9137
+ ) THEN 0 ELSE 1 END AS is_reply
9138
+ FROM annotation_comments c
9139
+ JOIN annotation_threads t ON t.id = c.thread_id
9140
+ WHERE t.nest_id = ? AND t.node_id IN (${placeholders})
9141
+ ORDER BY c.created_at DESC, c.id DESC
9142
+ LIMIT ?`,
9143
+ [params.nestId, ...ids, fetchWindow]
9144
+ );
9145
+ comments = rows.map((row) => ({
9146
+ type: "comment",
9147
+ id: row.id,
9148
+ threadId: row.thread_id,
9149
+ nodeId: row.node_id,
9150
+ title: titles.get(row.node_id),
9151
+ author: row.author,
9152
+ snippet: snippet(row.body),
9153
+ createdAt: row.created_at,
9154
+ resolved: row.status === "resolved",
9155
+ isReply: Number(row.is_reply) === 1
9156
+ }));
9157
+ }
9158
+ }
9159
+ }
9160
+ const merged = [...changes, ...comments].sort((a, b) => {
9161
+ const byTime = b.createdAt.localeCompare(a.createdAt);
9162
+ if (byTime !== 0) return byTime;
9163
+ return b.id.localeCompare(a.id);
9164
+ });
9165
+ const total = filter === "changes" ? changesTotal : filter === "comments" ? commentsTotal : changesTotal + commentsTotal;
9166
+ return {
9167
+ items: merged.slice(offset, offset + limit),
9168
+ total,
9169
+ counts: { changes: changesTotal, comments: commentsTotal }
9170
+ };
9171
+ }
8007
9172
 
8008
9173
  // src/governance/comment-service.ts
8009
- import { v4 as uuid13 } from "uuid";
9174
+ import { v4 as uuid14 } from "uuid";
8010
9175
  async function createComment(params) {
8011
9176
  const db = getDb();
8012
9177
  const body = (params.body ?? "").trim();
@@ -8022,7 +9187,7 @@ async function createComment(params) {
8022
9187
  throw new Error("Parent comment not found on this node");
8023
9188
  }
8024
9189
  }
8025
- const id = uuid13();
9190
+ const id = uuid14();
8026
9191
  await db.run(
8027
9192
  `INSERT INTO comments
8028
9193
  (id, nest_id, node_id, version, anchor_start, anchor_end, anchor_text,
@@ -8307,7 +9472,7 @@ function parseLimit(raw, def = 100, max = 1e3) {
8307
9472
  if (Number.isNaN(n) || n < 1) return def;
8308
9473
  return Math.min(n, max);
8309
9474
  }
8310
- var governanceRoutes = new Hono17();
9475
+ var governanceRoutes = new Hono20();
8311
9476
  governanceRoutes.get("/stewards", async (c) => {
8312
9477
  const nestId = c.req.param("nestId");
8313
9478
  const scope = c.req.query("scope");
@@ -8424,6 +9589,25 @@ governanceRoutes.get("/review-queue", async (c) => {
8424
9589
  }
8425
9590
  return c.json({ ...result, requests });
8426
9591
  });
9592
+ governanceRoutes.get("/feed", async (c) => {
9593
+ const nestId = c.req.param("nestId");
9594
+ const filterRaw = c.req.query("filter");
9595
+ const filter = filterRaw === "changes" || filterRaw === "comments" ? filterRaw : "all";
9596
+ const limit = parseLimit(c.req.query("limit"), 50, 200);
9597
+ const offsetRaw = parseInt(c.req.query("offset") ?? "", 10);
9598
+ const offset = Number.isNaN(offsetRaw) || offsetRaw < 0 ? 0 : Math.min(offsetRaw, 1e4);
9599
+ const userId = c.get("userId");
9600
+ const userEmail = await getUserEmail4(c);
9601
+ const result = await getNestFeed({
9602
+ nestId,
9603
+ userId,
9604
+ userEmail,
9605
+ filter,
9606
+ limit,
9607
+ offset
9608
+ });
9609
+ return c.json({ ...result, filter, limit, offset });
9610
+ });
8427
9611
  governanceRoutes.get("/external-edits", async (c) => {
8428
9612
  const nestId = c.req.param("nestId");
8429
9613
  const refresh = c.req.query("refresh") === "true";
@@ -8445,7 +9629,7 @@ governanceRoutes.get("/activity", async (c) => {
8445
9629
  const activity = await getActivity({ nestId, limit });
8446
9630
  return c.json({ activity });
8447
9631
  });
8448
- var governanceNodeRoutes = new Hono17();
9632
+ var governanceNodeRoutes = new Hono20();
8449
9633
  governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
8450
9634
  const nestId = c.req.param("nestId");
8451
9635
  const nodeId = c.req.param("nodeId");
@@ -8597,6 +9781,67 @@ governanceNodeRoutes.post("/:nodeId{.+}/submit-review", async (c) => {
8597
9781
  201
8598
9782
  );
8599
9783
  });
9784
+ governanceNodeRoutes.post(
9785
+ "/:nodeId{.+}/external-edits/:suggestionId/approve",
9786
+ async (c) => {
9787
+ const nestId = c.req.param("nestId");
9788
+ const nodeId = c.req.param("nodeId");
9789
+ const suggestionId = c.req.param("suggestionId");
9790
+ const body = await c.req.json().catch(() => ({}));
9791
+ const actor = await getUserEmail4(c);
9792
+ try {
9793
+ const result = await approveExternalEdit({
9794
+ nestId,
9795
+ documentId: nodeId,
9796
+ suggestionId,
9797
+ actor,
9798
+ comment: body.comment
9799
+ });
9800
+ return c.json({
9801
+ approved: true,
9802
+ version: result.versionEntry.version,
9803
+ chainEvent: result.chainEvent.event_id
9804
+ });
9805
+ } catch (err) {
9806
+ console.error(
9807
+ `[external-edit-route] approve failed nest=${nestId} node=${nodeId} suggestion=${suggestionId}`,
9808
+ { message: err?.message, name: err?.name, stack: err?.stack }
9809
+ );
9810
+ return c.json(
9811
+ { error: err.message, name: err?.name },
9812
+ 400
9813
+ );
9814
+ }
9815
+ }
9816
+ );
9817
+ governanceNodeRoutes.post(
9818
+ "/:nodeId{.+}/external-edits/:suggestionId/reject",
9819
+ async (c) => {
9820
+ const nestId = c.req.param("nestId");
9821
+ const nodeId = c.req.param("nodeId");
9822
+ const suggestionId = c.req.param("suggestionId");
9823
+ const body = await c.req.json().catch(() => ({}));
9824
+ if (!body.reason) {
9825
+ throw new ValidationError("Rejection reason is required");
9826
+ }
9827
+ const actor = await getUserEmail4(c);
9828
+ try {
9829
+ const result = await rejectExternalEdit({
9830
+ nestId,
9831
+ documentId: nodeId,
9832
+ suggestionId,
9833
+ actor,
9834
+ reason: body.reason
9835
+ });
9836
+ return c.json({
9837
+ rejected: true,
9838
+ chainEvent: result.chainEvent.event_id
9839
+ });
9840
+ } catch (err) {
9841
+ return c.json({ error: err.message }, 400);
9842
+ }
9843
+ }
9844
+ );
8600
9845
  governanceNodeRoutes.post("/:nodeId{.+}/approve", async (c) => {
8601
9846
  const nestId = c.req.param("nestId");
8602
9847
  const nodeId = c.req.param("nodeId");
@@ -8681,67 +9926,6 @@ governanceNodeRoutes.get(
8681
9926
  return c.json({ entry: detail });
8682
9927
  }
8683
9928
  );
8684
- governanceNodeRoutes.post(
8685
- "/:nodeId{.+}/external-edits/:suggestionId/approve",
8686
- async (c) => {
8687
- const nestId = c.req.param("nestId");
8688
- const nodeId = c.req.param("nodeId");
8689
- const suggestionId = c.req.param("suggestionId");
8690
- const body = await c.req.json().catch(() => ({}));
8691
- const actor = await getUserEmail4(c);
8692
- try {
8693
- const result = await approveExternalEdit({
8694
- nestId,
8695
- documentId: nodeId,
8696
- suggestionId,
8697
- actor,
8698
- comment: body.comment
8699
- });
8700
- return c.json({
8701
- approved: true,
8702
- version: result.versionEntry.version,
8703
- chainEvent: result.chainEvent.event_id
8704
- });
8705
- } catch (err) {
8706
- console.error(
8707
- `[external-edit-route] approve failed nest=${nestId} node=${nodeId} suggestion=${suggestionId}`,
8708
- { message: err?.message, name: err?.name, stack: err?.stack }
8709
- );
8710
- return c.json(
8711
- { error: err.message, name: err?.name },
8712
- 400
8713
- );
8714
- }
8715
- }
8716
- );
8717
- governanceNodeRoutes.post(
8718
- "/:nodeId{.+}/external-edits/:suggestionId/reject",
8719
- async (c) => {
8720
- const nestId = c.req.param("nestId");
8721
- const nodeId = c.req.param("nodeId");
8722
- const suggestionId = c.req.param("suggestionId");
8723
- const body = await c.req.json().catch(() => ({}));
8724
- if (!body.reason) {
8725
- throw new ValidationError("Rejection reason is required");
8726
- }
8727
- const actor = await getUserEmail4(c);
8728
- try {
8729
- const result = await rejectExternalEdit({
8730
- nestId,
8731
- documentId: nodeId,
8732
- suggestionId,
8733
- actor,
8734
- reason: body.reason
8735
- });
8736
- return c.json({
8737
- rejected: true,
8738
- chainEvent: result.chainEvent.event_id
8739
- });
8740
- } catch (err) {
8741
- return c.json({ error: err.message }, 400);
8742
- }
8743
- }
8744
- );
8745
9929
  governanceNodeRoutes.post("/:nodeId{.+}/cancel-review", async (c) => {
8746
9930
  const nestId = c.req.param("nestId");
8747
9931
  const nodeId = c.req.param("nodeId");
@@ -8833,20 +10017,22 @@ var flexAuthMiddleware = createMiddleware2(async (c, next) => {
8833
10017
  return c.json({ error: "Missing or invalid credentials" }, 401);
8834
10018
  });
8835
10019
  function createApp() {
8836
- const app = new Hono18({ router: new LinearRouter() });
10020
+ const app = new Hono21({ router: new LinearRouter() });
8837
10021
  const corsOrigins = config.CORS_ORIGINS;
8838
10022
  app.use(
8839
10023
  "*",
8840
10024
  corsOrigins === "*" ? cors({ origin: "*" }) : cors({ origin: corsOrigins, credentials: true })
8841
10025
  );
10026
+ const ASSET_UPLOAD_PATH = /^\/nests\/[^/]+\/assets\/?$/;
8842
10027
  app.use("*", async (c, next) => {
8843
10028
  const lenStr = c.req.header("Content-Length");
8844
10029
  if (lenStr) {
8845
10030
  const len = parseInt(lenStr, 10);
8846
- if (Number.isFinite(len) && len > config.MAX_BODY_BYTES) {
10031
+ const cap = c.req.method === "POST" && ASSET_UPLOAD_PATH.test(c.req.path) ? Math.max(config.MAX_BODY_BYTES, ASSET_MAX_REQUEST_BYTES) : config.MAX_BODY_BYTES;
10032
+ if (Number.isFinite(len) && len > cap) {
8847
10033
  return c.json(
8848
10034
  {
8849
- error: `Request body too large (${len} bytes, max ${config.MAX_BODY_BYTES})`
10035
+ error: `Request body too large (${len} bytes, max ${cap})`
8850
10036
  },
8851
10037
  413
8852
10038
  );
@@ -8870,9 +10056,9 @@ function createApp() {
8870
10056
  durationMs: Date.now() - started
8871
10057
  });
8872
10058
  });
8873
- app.get(
8874
- "/health",
8875
- (c) => c.json({
10059
+ app.get("/health", (c) => {
10060
+ const oidcReady = isOidcReady();
10061
+ return c.json({
8876
10062
  status: isSuspended() ? "suspended" : "ok",
8877
10063
  service: "contextnest-community",
8878
10064
  version: SERVICE_VERSION,
@@ -8886,10 +10072,24 @@ function createApp() {
8886
10072
  // The login page reads this to show/hide the credential form, and to
8887
10073
  // switch the Register link to an "activate invite" affordance.
8888
10074
  manual_sign_in: config.MANUAL_SIGN_IN,
10075
+ // OIDC SSO — true only when enabled AND fully configured, so the login
10076
+ // page never shows a button that would bounce with an error. The issuer
10077
+ // is exposed (when live) so the client can label the button by IdP
10078
+ // (login.microsoftonline.com → Microsoft, accounts.google.com → Google).
10079
+ oidc_enabled: oidcReady,
10080
+ ...oidcReady && { oidc_issuer: config.OIDC_ISSUER },
8889
10081
  // Public feature flags the SPA needs before auth to decide what to
8890
10082
  // render. Mirrors the server-side route gate so UI and API agree:
8891
10083
  // flag off → the plane's routes 404 AND its UI stays hidden.
8892
10084
  workflow_plane_enabled: config.FEATURE_WORKFLOW_PLANE,
10085
+ // Import teams from PromptOwl (default OFF). Off → /teams/promptowl 404s
10086
+ // and the UI hides the PromptOwl Teams panel.
10087
+ promptowl_teams_enabled: config.PROMPTOWL_TEAMS_ENABLED,
10088
+ // Content-type toggles (default ON). The editor's type picker mirrors
10089
+ // these so a disabled type disappears from creation exactly when the
10090
+ // API would refuse it — existing nodes of the type stay readable.
10091
+ type_artifact_enabled: config.TYPE_ARTIFACT_ENABLED,
10092
+ type_table_enabled: config.TYPE_TABLE_ENABLED,
8893
10093
  // Sub-agent runs gate its own recursion surface separately (env-only,
8894
10094
  // must clear a security review — NOT a UI toggle). Exposed read-only so
8895
10095
  // the Runs page shows the "spawn sub-agent" option only when it's live.
@@ -8901,8 +10101,8 @@ function createApp() {
8901
10101
  subagent_max_depth: config.SUBAGENT_MAX_DEPTH,
8902
10102
  subagent_max_children: config.SUBAGENT_MAX_CHILDREN,
8903
10103
  ...isSuspended() && { suspended_reason: getSuspensionReason() }
8904
- })
8905
- );
10104
+ });
10105
+ });
8906
10106
  app.use("/auth/invite", async (c, next) => {
8907
10107
  if (!getCurrentLicense()?.valid) {
8908
10108
  return c.json(
@@ -8983,8 +10183,8 @@ function createApp() {
8983
10183
  return c.json({ error: msg }, 500);
8984
10184
  }
8985
10185
  });
10186
+ const serverAdminAllowed = async (c) => config.AUTH_MODE === "open" || await isServerAdminUserId(c.get("userId"));
8986
10187
  app.use("/admin/settings", flexAuthMiddleware);
8987
- const adminSettingsAllowed = async (c) => config.AUTH_MODE === "open" || await isLicenseAdminUserId(c.get("userId"));
8988
10188
  const currentServerSettings = () => ({
8989
10189
  promptowl_sign_in_gate: config.PROMPTOWL_SIGN_IN_GATE,
8990
10190
  manual_sign_in: config.MANUAL_SIGN_IN,
@@ -8993,10 +10193,27 @@ function createApp() {
8993
10193
  public_base_url: config.PUBLIC_BASE_URL,
8994
10194
  max_body_bytes: config.MAX_BODY_BYTES,
8995
10195
  workflow_plane_enabled: config.FEATURE_WORKFLOW_PLANE,
10196
+ promptowl_teams_enabled: config.PROMPTOWL_TEAMS_ENABLED,
10197
+ type_artifact_enabled: config.TYPE_ARTIFACT_ENABLED,
10198
+ type_table_enabled: config.TYPE_TABLE_ENABLED,
8996
10199
  subagent_runs_enabled: config.FEATURE_SUBAGENT_RUNS,
8997
10200
  subagent_max_depth: config.SUBAGENT_MAX_DEPTH,
8998
10201
  subagent_max_children: config.SUBAGENT_MAX_CHILDREN,
8999
10202
  trace_retention_days: config.TRACE_RETENTION_DAYS,
10203
+ // OIDC SSO. The client secret is WRITE-ONLY: only its presence is
10204
+ // reported, never the value (same discipline as the Anthropic key below).
10205
+ oidc_enabled: config.OIDC_ENABLED,
10206
+ oidc_issuer: config.OIDC_ISSUER,
10207
+ oidc_client_id: config.OIDC_CLIENT_ID,
10208
+ oidc_client_secret_set: !!config.OIDC_CLIENT_SECRET,
10209
+ oidc_allowed_domains: config.OIDC_ALLOWED_DOMAINS.join(", "),
10210
+ oidc_auto_provision: config.OIDC_AUTO_PROVISION,
10211
+ oidc_department_tagging: config.OIDC_DEPARTMENT_TAGGING,
10212
+ // Live readiness (enabled AND issuer+id+secret present) — the SAVED state,
10213
+ // i.e. whether the login button is currently live. The Settings UI derives
10214
+ // its own "enabled but not configured" warning from the unsaved draft
10215
+ // instead, so this is for API consumers (and tests) rather than that badge.
10216
+ oidc_ready: isOidcReady(),
9000
10217
  // Server-wide runner default. NEVER the value — masked tail only.
9001
10218
  anthropic_api_key_masked: process.env.ANTHROPIC_API_KEY ? `\u2022\u2022\u2022\u2022${process.env.ANTHROPIC_API_KEY.slice(-4)}` : null,
9002
10219
  slack_webhook_url: config.SLACK_WEBHOOK_URL ?? "",
@@ -9005,12 +10222,12 @@ function createApp() {
9005
10222
  notify_email_to: config.NOTIFY_EMAIL_TO ?? ""
9006
10223
  });
9007
10224
  app.get("/admin/settings", async (c) => {
9008
- if (!await adminSettingsAllowed(c))
10225
+ if (!await serverAdminAllowed(c))
9009
10226
  return c.json({ error: "Only the server admin can view this." }, 403);
9010
10227
  return c.json(currentServerSettings());
9011
10228
  });
9012
10229
  app.patch("/admin/settings", async (c) => {
9013
- if (!await adminSettingsAllowed(c))
10230
+ if (!await serverAdminAllowed(c))
9014
10231
  return c.json({ error: "Only the server admin can change this." }, 403);
9015
10232
  let body;
9016
10233
  try {
@@ -9052,6 +10269,24 @@ function createApp() {
9052
10269
  value: body.workflow_plane_enabled ? "true" : null
9053
10270
  });
9054
10271
  }
10272
+ if ("promptowl_teams_enabled" in body) {
10273
+ pending.push({
10274
+ name: "PROMPTOWL_TEAMS_ENABLED",
10275
+ value: body.promptowl_teams_enabled ? "true" : null
10276
+ });
10277
+ }
10278
+ if ("type_artifact_enabled" in body) {
10279
+ pending.push({
10280
+ name: "TYPE_ARTIFACT_ENABLED",
10281
+ value: body.type_artifact_enabled ? null : "false"
10282
+ });
10283
+ }
10284
+ if ("type_table_enabled" in body) {
10285
+ pending.push({
10286
+ name: "TYPE_TABLE_ENABLED",
10287
+ value: body.type_table_enabled ? null : "false"
10288
+ });
10289
+ }
9055
10290
  if ("anthropic_api_key" in body) {
9056
10291
  const v = body.anthropic_api_key;
9057
10292
  if (v === null || v === "") {
@@ -9109,6 +10344,74 @@ function createApp() {
9109
10344
  else pending.push({ name: "TRACE_RETENTION_DAYS", value: String(n) });
9110
10345
  }
9111
10346
  }
10347
+ if ("oidc_issuer" in body) {
10348
+ const v = String(body.oidc_issuer ?? "").trim().replace(/\/$/, "");
10349
+ let ok = false;
10350
+ try {
10351
+ ok = !v || new URL(v).protocol === "https:";
10352
+ } catch {
10353
+ ok = false;
10354
+ }
10355
+ if (!ok)
10356
+ addError("oidc_issuer", "OIDC issuer must be a valid https:// URL (e.g. https://accounts.google.com), or empty to clear it.");
10357
+ else pending.push({ name: "OIDC_ISSUER", value: v || null });
10358
+ }
10359
+ if ("oidc_client_id" in body) {
10360
+ const v = String(body.oidc_client_id ?? "").trim();
10361
+ pending.push({ name: "OIDC_CLIENT_ID", value: v || null });
10362
+ }
10363
+ if ("oidc_client_secret" in body) {
10364
+ const v = body.oidc_client_secret;
10365
+ if (v === null || v === "") {
10366
+ pending.push({ name: "OIDC_CLIENT_SECRET", value: null });
10367
+ } else if (typeof v !== "string") {
10368
+ addError("oidc_client_secret", "OIDC client secret must be a string (or empty to clear it).");
10369
+ } else {
10370
+ pending.push({ name: "OIDC_CLIENT_SECRET", value: v.trim() });
10371
+ }
10372
+ }
10373
+ if ("oidc_allowed_domains" in body) {
10374
+ const raw = String(body.oidc_allowed_domains ?? "").trim();
10375
+ const entries = raw.split(",").map((d) => d.trim().toLowerCase()).filter(Boolean);
10376
+ const bad = entries.find((d) => !/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/.test(d));
10377
+ if (bad !== void 0)
10378
+ addError("oidc_allowed_domains", `"${bad}" is not a valid domain \u2014 use bare domains, comma-separated (e.g. acme.com, contractors.acme.com), or leave empty to allow any.`);
10379
+ else
10380
+ pending.push({
10381
+ name: "OIDC_ALLOWED_DOMAINS",
10382
+ value: entries.length ? entries.join(",") : null
10383
+ });
10384
+ }
10385
+ if ("oidc_auto_provision" in body) {
10386
+ pending.push({
10387
+ name: "OIDC_AUTO_PROVISION",
10388
+ value: body.oidc_auto_provision ? "true" : "false"
10389
+ });
10390
+ }
10391
+ if ("oidc_department_tagging" in body) {
10392
+ pending.push({
10393
+ name: "OIDC_DEPARTMENT_TAGGING",
10394
+ value: body.oidc_department_tagging ? "true" : null
10395
+ });
10396
+ }
10397
+ const effOidcIssuer = "oidc_issuer" in body ? String(body.oidc_issuer ?? "").trim() : config.OIDC_ISSUER;
10398
+ const effOidcClientId = "oidc_client_id" in body ? String(body.oidc_client_id ?? "").trim() : config.OIDC_CLIENT_ID;
10399
+ const effOidcSecret = "oidc_client_secret" in body ? String(body.oidc_client_secret ?? "").trim() : config.OIDC_CLIENT_SECRET;
10400
+ const effOidcEnabled = "oidc_enabled" in body ? !!body.oidc_enabled : config.OIDC_ENABLED;
10401
+ const effOidcReady = effOidcEnabled && !!effOidcIssuer && !!effOidcClientId && !!effOidcSecret;
10402
+ if ("oidc_enabled" in body) {
10403
+ if (body.oidc_enabled && !effOidcReady) {
10404
+ addError(
10405
+ "oidc_enabled",
10406
+ "Set the OIDC issuer, client ID, and client secret before turning single sign-on on."
10407
+ );
10408
+ } else {
10409
+ pending.push({
10410
+ name: "OIDC_ENABLED",
10411
+ value: body.oidc_enabled ? "true" : null
10412
+ });
10413
+ }
10414
+ }
9112
10415
  if ("slack_webhook_url" in body) {
9113
10416
  const v = String(body.slack_webhook_url ?? "").trim();
9114
10417
  if (v && !/^https:\/\//i.test(v))
@@ -9143,10 +10446,13 @@ function createApp() {
9143
10446
  }
9144
10447
  const effManual = "manual_sign_in" in body ? String(body.manual_sign_in ?? "").trim().toLowerCase() : config.MANUAL_SIGN_IN;
9145
10448
  const effGate = "promptowl_sign_in_gate" in body ? String(body.promptowl_sign_in_gate ?? "").trim().toLowerCase() : config.PROMPTOWL_SIGN_IN_GATE;
9146
- if (effManual === "disabled" && effGate === "disabled") {
10449
+ if (effManual === "disabled" && effGate === "disabled" && !effOidcReady) {
10450
+ const touchedOidcOnly = !("manual_sign_in" in body) && !("promptowl_sign_in_gate" in body) && ["oidc_enabled", "oidc_issuer", "oidc_client_id", "oidc_client_secret"].some(
10451
+ (k) => k in body
10452
+ );
9147
10453
  addError(
9148
- "manual_sign_in",
9149
- "Cannot disable both manual and PromptOwl sign-in \u2014 no one would be able to log in."
10454
+ touchedOidcOnly ? "oidc_enabled" : "manual_sign_in",
10455
+ "Cannot disable both manual and PromptOwl sign-in unless SSO (OIDC) is enabled and configured \u2014 no one would be able to log in."
9150
10456
  );
9151
10457
  }
9152
10458
  const errorMessages = Object.values(fieldErrors);
@@ -9188,14 +10494,8 @@ function createApp() {
9188
10494
  return c.json({ settings: currentServerSettings() });
9189
10495
  });
9190
10496
  app.use("/admin/trace", flexAuthMiddleware);
9191
- const traceAllowed = async (c) => {
9192
- if (config.AUTH_MODE === "open") return true;
9193
- const userId = c.get("userId");
9194
- if (await isLicenseAdminUserId(userId)) return true;
9195
- return isSuperAdmin(await resolveCallerEmail(userId));
9196
- };
9197
10497
  app.get("/admin/trace", async (c) => {
9198
- if (!await traceAllowed(c))
10498
+ if (!await serverAdminAllowed(c))
9199
10499
  return c.json({ error: "Only the server admin can view this." }, 403);
9200
10500
  const limitRaw = Number(c.req.query("limit"));
9201
10501
  const offsetRaw = Number(c.req.query("offset"));
@@ -9210,6 +10510,124 @@ function createApp() {
9210
10510
  });
9211
10511
  return c.json({ count: events.length, total, limit, offset, events });
9212
10512
  });
10513
+ app.use("/admin/super-admins", flexAuthMiddleware);
10514
+ app.use("/admin/super-admins/*", flexAuthMiddleware);
10515
+ app.get("/admin/super-admins", async (c) => {
10516
+ if (!await serverAdminAllowed(c))
10517
+ return c.json({ error: "Only the server admin can view this." }, 403);
10518
+ const callerEmail = (await resolveCallerEmail(c.get("userId"))).toLowerCase();
10519
+ const byEmail = /* @__PURE__ */ new Map();
10520
+ const add = (row) => {
10521
+ if (!byEmail.has(row.email)) byEmail.set(row.email, row);
10522
+ };
10523
+ const lic = getCurrentLicense();
10524
+ if (lic?.valid && lic.ownerEmail) {
10525
+ const email = lic.ownerEmail.toLowerCase();
10526
+ add({ email, source: "license", granted_by: null, granted_at: null, self: email === callerEmail });
10527
+ }
10528
+ for (const raw of getAccessConfig()?.super_admins ?? []) {
10529
+ const email = raw.toLowerCase();
10530
+ add({ email, source: "config", granted_by: null, granted_at: null, self: email === callerEmail });
10531
+ }
10532
+ const granted = await getDb().all(
10533
+ "SELECT email, granted_by, granted_at FROM super_admins ORDER BY granted_at, email"
10534
+ );
10535
+ for (const g of granted) {
10536
+ const email = g.email.toLowerCase();
10537
+ add({ email, source: "granted", granted_by: g.granted_by, granted_at: g.granted_at, self: email === callerEmail });
10538
+ }
10539
+ return c.json({ super_admins: [...byEmail.values()] });
10540
+ });
10541
+ app.post("/admin/super-admins", async (c) => {
10542
+ if (!await serverAdminAllowed(c))
10543
+ return c.json({ error: "Only the server admin can grant superadmin." }, 403);
10544
+ let body;
10545
+ try {
10546
+ body = await c.req.json();
10547
+ } catch {
10548
+ return c.json({ error: "Invalid JSON body" }, 400);
10549
+ }
10550
+ const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : "";
10551
+ if (!email || !isEmailish(email)) {
10552
+ return c.json({ error: "A valid email is required." }, 400);
10553
+ }
10554
+ const user = await getDb().get(
10555
+ "SELECT id FROM users WHERE LOWER(email) = ?",
10556
+ [email]
10557
+ );
10558
+ if (!user) {
10559
+ return c.json(
10560
+ { error: "No user with this email exists on this server. Invite them as a teammate first." },
10561
+ 400
10562
+ );
10563
+ }
10564
+ const lic = getCurrentLicense();
10565
+ const isLicenseOwner = !!lic?.valid && lic.ownerEmail?.toLowerCase() === email;
10566
+ if (isLicenseOwner || isSuperAdmin(email)) {
10567
+ return c.json({ error: "This user is already a superadmin." }, 409);
10568
+ }
10569
+ const grantedBy = (await resolveCallerEmail(c.get("userId"))).toLowerCase();
10570
+ try {
10571
+ await getDb().run(
10572
+ "INSERT INTO super_admins (email, granted_by) VALUES (?, ?)",
10573
+ [email, grantedBy]
10574
+ );
10575
+ } catch (err) {
10576
+ const msg = err instanceof Error ? err.message : String(err);
10577
+ if (/unique|duplicate/i.test(msg)) {
10578
+ await loadGrantedSuperAdmins();
10579
+ return c.json({ error: "This user is already a superadmin." }, 409);
10580
+ }
10581
+ throw err;
10582
+ }
10583
+ await loadGrantedSuperAdmins();
10584
+ return c.json({ email, source: "granted", granted_by: grantedBy }, 201);
10585
+ });
10586
+ app.delete("/admin/super-admins/:email", async (c) => {
10587
+ if (!await serverAdminAllowed(c))
10588
+ return c.json({ error: "Only the server admin can revoke superadmin." }, 403);
10589
+ const email = c.req.param("email").trim().toLowerCase();
10590
+ const callerEmail = (await resolveCallerEmail(c.get("userId"))).toLowerCase();
10591
+ const lic = getCurrentLicense();
10592
+ if (lic?.valid && lic.ownerEmail?.toLowerCase() === email) {
10593
+ return c.json(
10594
+ { error: "The license admin can't be revoked \u2014 their access comes from owning the installed PromptOwl license." },
10595
+ 400
10596
+ );
10597
+ }
10598
+ if (isConfigSuperAdmin(email)) {
10599
+ return c.json(
10600
+ { error: "This superadmin is defined in access.yaml and can't be revoked from here. Remove them from the file and restart the server." },
10601
+ 400
10602
+ );
10603
+ }
10604
+ const db = getDb();
10605
+ const row = await db.get(
10606
+ "SELECT email FROM super_admins WHERE LOWER(email) = ?",
10607
+ [email]
10608
+ );
10609
+ if (!row) return c.json({ error: "Not a granted superadmin." }, 404);
10610
+ if (email === callerEmail) {
10611
+ const hasLicenseAdmin = !!lic?.valid && !!lic.ownerEmail;
10612
+ const hasConfigAdmins = (getAccessConfig()?.super_admins?.length ?? 0) > 0;
10613
+ const others = await db.get(
10614
+ "SELECT COUNT(*) AS c FROM super_admins WHERE LOWER(email) != ?",
10615
+ [email]
10616
+ );
10617
+ if (!hasLicenseAdmin && !hasConfigAdmins && others.c === 0) {
10618
+ return c.json(
10619
+ { error: "You're the last superadmin on this server \u2014 grant someone else superadmin before revoking yourself." },
10620
+ 400
10621
+ );
10622
+ }
10623
+ }
10624
+ await db.run("DELETE FROM super_admins WHERE LOWER(email) = ?", [email]);
10625
+ await loadGrantedSuperAdmins();
10626
+ return c.json({ ok: true, email });
10627
+ });
10628
+ app.use("/teams/*", flexAuthMiddleware);
10629
+ app.use("/teams", flexAuthMiddleware);
10630
+ app.route("/teams", teamRoutes);
9213
10631
  app.use("/me/*", flexAuthMiddleware);
9214
10632
  app.get("/me/work", async (c) => {
9215
10633
  const userId = c.get("userId");
@@ -9283,7 +10701,7 @@ function createApp() {
9283
10701
  app.route("/index", nestIndexRoutes);
9284
10702
  app.use("/mcp", flexAuthMiddleware);
9285
10703
  app.route("/mcp", serverMcpRoutes);
9286
- const nestsApp = new Hono18();
10704
+ const nestsApp = new Hono21();
9287
10705
  nestsApp.use("*", flexAuthMiddleware);
9288
10706
  nestsApp.use("*", async (c, next) => {
9289
10707
  const localPath = c.req.path.replace(/^\/nests\//, "");
@@ -9293,36 +10711,46 @@ function createApp() {
9293
10711
  const nestId = parts[0];
9294
10712
  const userId = c.get("userId");
9295
10713
  const nestScope = c.get("nestScope");
10714
+ const isMcpTransport = parts.length === 2 && parts[1] === "mcp";
10715
+ const deny = async (status, error, extra) => {
10716
+ if (!isMcpTransport) return c.json({ error, ...extra }, status);
10717
+ let id = null;
10718
+ try {
10719
+ id = (await c.req.json())?.id ?? null;
10720
+ } catch {
10721
+ }
10722
+ const message = typeof extra?.reason === "string" ? `${error} \u2014 ${extra.reason}` : error;
10723
+ return c.json(
10724
+ { jsonrpc: "2.0", id, error: { code: -32e3, message } },
10725
+ status
10726
+ );
10727
+ };
9296
10728
  if (nestScope && nestScope !== nestId) {
9297
- return c.json({ error: "API key not authorized for this nest" }, 403);
10729
+ return deny(
10730
+ 403,
10731
+ `API key not authorized for this nest \u2014 this key is scoped to nest "${nestScope}". Use that nest's MCP URL, or mint a user-level key in the Connect dialog.`
10732
+ );
9298
10733
  }
9299
10734
  if (isSuspended() && c.req.method !== "GET") {
9300
- return c.json(
9301
- {
9302
- error: "Server suspended by PromptOwl",
9303
- reason: getSuspensionReason(),
9304
- contact: "support@promptowl.ai"
9305
- },
9306
- 503
9307
- );
10735
+ return deny(503, "Server suspended by PromptOwl", {
10736
+ reason: getSuspensionReason(),
10737
+ contact: "support@promptowl.ai"
10738
+ });
9308
10739
  }
9309
10740
  {
9310
10741
  const path2 = c.req.path;
9311
10742
  const isCommentPath = /\/comments$/.test(path2) || /\/comments\/[^/]+\/resolve$/.test(path2);
9312
10743
  const isActivityPath = /\/activity$/.test(path2);
9313
- 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") || isCommentPath || isActivityPath;
10744
+ const isFeedPath = parts.length === 2 && parts[1] === "feed";
10745
+ 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") || isCommentPath || isActivityPath || isFeedPath;
9314
10746
  const needsLicense = c.req.method !== "GET" || isGovernance;
9315
10747
  if (needsLicense) {
9316
10748
  const lic = getCurrentLicense();
9317
10749
  if (!lic?.valid) {
9318
- return c.json(
9319
- {
9320
- error: "License required",
9321
- reason: "Install a PromptOwl license key via the setup screen or POST /license/install.",
9322
- setup_url: "/setup"
9323
- },
9324
- 503
9325
- );
10750
+ return deny(503, "License required", {
10751
+ reason: "This server has no valid PromptOwl license installed. Ask the server admin to install one via the setup screen or POST /license/install.",
10752
+ setup_url: "/setup"
10753
+ });
9326
10754
  }
9327
10755
  }
9328
10756
  }
@@ -9350,7 +10778,9 @@ function createApp() {
9350
10778
  grantNestVisible = await hasAnyGrant(nestId, userId);
9351
10779
  }
9352
10780
  if (!grantRole && !grantNestVisible) {
9353
- return c.json({ error: "Nest not found" }, 404);
10781
+ return deny(404, "Nest not found", {
10782
+ reason: "The nest id may be wrong, or these credentials have no access to it. Ask the nest owner to share the nest with you, then reconnect."
10783
+ });
9354
10784
  }
9355
10785
  }
9356
10786
  let required = "read";
@@ -9372,9 +10802,11 @@ function createApp() {
9372
10802
  required = "admin";
9373
10803
  } else if (resource === "collaborators") {
9374
10804
  required = c.req.method === "GET" || c.req.method === "POST" ? "write" : "admin";
10805
+ } else if (resource === "teams") {
10806
+ required = c.req.method === "GET" ? "write" : "admin";
9375
10807
  } else if (parts[1] === "hooks") {
9376
10808
  required = "write";
9377
- } else if (c.req.method !== "GET" && !isStewardActionPath && !isCommentAction && !isAnnotationAction && !isReadQuery) {
10809
+ } else if (c.req.method !== "GET" && !isStewardActionPath && !isCommentAction && !isAnnotationAction && !isReadQuery && !isMcpTransport) {
9378
10810
  required = "write";
9379
10811
  }
9380
10812
  const isNodeAction = c.req.method === "POST" && parts.length >= 4 && (parts[parts.length - 1] === "revert" || parts[parts.length - 1] === "move");
@@ -9403,13 +10835,13 @@ function createApp() {
9403
10835
  const grantLevel = grantRole ? permissionLevel(grantRole) : grantNestVisible ? permissionLevel("read") : 0;
9404
10836
  const effectiveLevel = Math.max(permissionLevel(permission), grantLevel);
9405
10837
  if (!stewardEditorBypass && effectiveLevel < permissionLevel(required)) {
9406
- return c.json(
10838
+ return deny(
10839
+ 403,
10840
+ `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.`,
9407
10841
  {
9408
- 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.`,
9409
10842
  required_permission: required,
9410
10843
  your_permission: permission
9411
- },
9412
- 403
10844
+ }
9413
10845
  );
9414
10846
  }
9415
10847
  c.set(
@@ -9454,6 +10886,7 @@ function createApp() {
9454
10886
  nestsApp.route("/:nestId", queryRoutes);
9455
10887
  nestsApp.route("/:nestId", sharingRoutes);
9456
10888
  nestsApp.route("/:nestId/grants", grantRoutes);
10889
+ nestsApp.route("/:nestId/teams", nestTeamRoutes);
9457
10890
  nestsApp.route("/:nestId/edge-types", edgeTypeRoutes);
9458
10891
  nestsApp.route("/:nestId/edges", edgeRoutes);
9459
10892
  nestsApp.route("/:nestId/run", runTriggerRoutes);
@@ -9655,7 +11088,7 @@ async function backfillNodeVersionsFromHistory(db) {
9655
11088
  }
9656
11089
 
9657
11090
  // src/workflow/scheduler.ts
9658
- import { v4 as uuid14 } from "uuid";
11091
+ import { v4 as uuid15 } from "uuid";
9659
11092
  function isDue(s, now) {
9660
11093
  if (!s.last_run_at) return true;
9661
11094
  const last = Date.parse(s.last_run_at);
@@ -9679,7 +11112,7 @@ async function schedulerTick(now = Date.now()) {
9679
11112
  );
9680
11113
  continue;
9681
11114
  }
9682
- const id = `run_${uuid14()}`;
11115
+ const id = `run_${uuid15()}`;
9683
11116
  const nowIso = new Date(now).toISOString();
9684
11117
  await db.run(
9685
11118
  `INSERT INTO runs (id, nest_id, agent_node, triggered_by, status, inputs, started_at, parent_run_id, depth)
@@ -9734,6 +11167,11 @@ async function main() {
9734
11167
  if (accessCfg) {
9735
11168
  console.log(` Loaded access.yaml (mode: ${accessCfg.mode || "open"})`);
9736
11169
  }
11170
+ try {
11171
+ await loadGrantedSuperAdmins();
11172
+ } catch (err) {
11173
+ console.error("[access] failed to load DB-granted superadmins:", err);
11174
+ }
9737
11175
  const license = await validateLicense();
9738
11176
  if (!license.valid) {
9739
11177
  console.warn(`