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