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