@suveren/gateway 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/control-plane/index.mjs +272 -17
- package/dist/mcp-server/http.mjs +122 -7
- package/dist/ui/assets/index-BKgX8_Zh.js +111 -0
- package/dist/ui/index.html +1 -1
- package/package.json +1 -1
- package/dist/ui/assets/index-Bu58Y583.js +0 -110
|
@@ -40,6 +40,8 @@ var Vault = class {
|
|
|
40
40
|
* disk, gone the moment the process restarts or the vault locks.
|
|
41
41
|
*/
|
|
42
42
|
spSessionExpiresAt = null;
|
|
43
|
+
/** Who the current AS session belongs to (from the login response). Never persisted. */
|
|
44
|
+
sessionUser = null;
|
|
43
45
|
/** Set by lockExpired(); cleared by any fresh unseal (a new login). */
|
|
44
46
|
lockedReason = null;
|
|
45
47
|
constructor(dataDir) {
|
|
@@ -97,6 +99,7 @@ var Vault = class {
|
|
|
97
99
|
this.spSessionCookie = null;
|
|
98
100
|
this.unsealedBy = null;
|
|
99
101
|
this.spSessionExpiresAt = null;
|
|
102
|
+
this.sessionUser = null;
|
|
100
103
|
this.lockedReason = null;
|
|
101
104
|
}
|
|
102
105
|
/**
|
|
@@ -149,6 +152,13 @@ var Vault = class {
|
|
|
149
152
|
getSessionExpiresAt() {
|
|
150
153
|
return this.spSessionExpiresAt;
|
|
151
154
|
}
|
|
155
|
+
/** The signed-in person, set at login from the AS session response; cleared with the session. */
|
|
156
|
+
setSessionUser(user) {
|
|
157
|
+
this.sessionUser = user;
|
|
158
|
+
}
|
|
159
|
+
getSessionUser() {
|
|
160
|
+
return this.sessionUser;
|
|
161
|
+
}
|
|
152
162
|
/**
|
|
153
163
|
* Check if the vault was created by a different API key.
|
|
154
164
|
* Tries to decrypt the first credential — if it fails, keys don't match.
|
|
@@ -414,6 +424,11 @@ async function runCommittedProposals() {
|
|
|
414
424
|
headers: internalHeaders()
|
|
415
425
|
});
|
|
416
426
|
}
|
|
427
|
+
async function getToolDisplay() {
|
|
428
|
+
const res = await fetch(`${MCP_BASE}/internal/tool-display`, { headers: internalHeaders() });
|
|
429
|
+
if (!res.ok) throw new Error(`MCP tool-display failed: ${res.status}`);
|
|
430
|
+
return res.json();
|
|
431
|
+
}
|
|
417
432
|
async function getIntegrations() {
|
|
418
433
|
const res = await fetch(`${MCP_BASE}/internal/integrations`, {
|
|
419
434
|
headers: internalHeaders()
|
|
@@ -1726,6 +1741,10 @@ function createAuthRouter(vault2, logoutAuth, loginRateLimit2, onSessionEstablis
|
|
|
1726
1741
|
}
|
|
1727
1742
|
const sessionExpiresAt = typeof data.sessionExpiresAt === "number" ? data.sessionExpiresAt : null;
|
|
1728
1743
|
vault2.setSessionExpiresAt(sessionExpiresAt);
|
|
1744
|
+
const sessionUser = data.user;
|
|
1745
|
+
vault2.setSessionUser(
|
|
1746
|
+
typeof sessionUser?.id === "string" && typeof sessionUser?.did === "string" ? { id: sessionUser.id, did: sessionUser.did } : null
|
|
1747
|
+
);
|
|
1729
1748
|
onSessionEstablished?.();
|
|
1730
1749
|
if (sessionCookie) {
|
|
1731
1750
|
try {
|
|
@@ -3087,7 +3106,26 @@ function notify(title, message, platform = process.platform, url) {
|
|
|
3087
3106
|
|
|
3088
3107
|
// src/routes/encrypt-intent.ts
|
|
3089
3108
|
import { Router as Router7 } from "express";
|
|
3109
|
+
|
|
3110
|
+
// src/lib/intent-encryption.ts
|
|
3090
3111
|
import { computeIntentDisclosureHash } from "@hap/core";
|
|
3112
|
+
async function encryptIntentForRecipients(intent, recipients) {
|
|
3113
|
+
const parsed = recipients.map(({ userId, publicKey }) => {
|
|
3114
|
+
const bytes = new Uint8Array(Buffer.from(publicKey, "base64"));
|
|
3115
|
+
if (bytes.length !== 32) throw new Error(`publicKey for userId "${userId}" must be 32 bytes (X25519); got ${bytes.length}`);
|
|
3116
|
+
return { userId, publicKey: bytes };
|
|
3117
|
+
});
|
|
3118
|
+
const encrypted = await encryptForRecipients(intent, parsed);
|
|
3119
|
+
const encryptedKeys = {};
|
|
3120
|
+
for (const [userId, wrap] of Object.entries(encrypted.encryptedKeys)) {
|
|
3121
|
+
encryptedKeys[userId] = { ct: Buffer.from(wrap.ct).toString("base64"), enc: Buffer.from(wrap.enc).toString("base64") };
|
|
3122
|
+
}
|
|
3123
|
+
const intentCiphertext = Buffer.from(encrypted.intentCiphertext).toString("base64");
|
|
3124
|
+
const approversFrozen = parsed.map((r) => r.userId);
|
|
3125
|
+
return { intentCiphertext, encryptedKeys, approversFrozen, intentDisclosureHash: computeIntentDisclosureHash(intentCiphertext, approversFrozen) };
|
|
3126
|
+
}
|
|
3127
|
+
|
|
3128
|
+
// src/routes/encrypt-intent.ts
|
|
3091
3129
|
function createEncryptIntentRouter() {
|
|
3092
3130
|
const router = Router7();
|
|
3093
3131
|
router.post("/", async (req, res) => {
|
|
@@ -3125,23 +3163,10 @@ function createEncryptIntentRouter() {
|
|
|
3125
3163
|
parsed.push({ userId, publicKey: pubBytes });
|
|
3126
3164
|
}
|
|
3127
3165
|
try {
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
ct: Buffer.from(wrap.ct).toString("base64"),
|
|
3133
|
-
enc: Buffer.from(wrap.enc).toString("base64")
|
|
3134
|
-
};
|
|
3135
|
-
}
|
|
3136
|
-
const intentCiphertextB64 = Buffer.from(encrypted.intentCiphertext).toString("base64");
|
|
3137
|
-
const approversFrozen = parsed.map((r) => r.userId);
|
|
3138
|
-
const intentDisclosureHash = computeIntentDisclosureHash(intentCiphertextB64, approversFrozen);
|
|
3139
|
-
res.json({
|
|
3140
|
-
intentCiphertext: intentCiphertextB64,
|
|
3141
|
-
encryptedKeys,
|
|
3142
|
-
approversFrozen,
|
|
3143
|
-
intentDisclosureHash
|
|
3144
|
-
});
|
|
3166
|
+
res.json(await encryptIntentForRecipients(
|
|
3167
|
+
intent,
|
|
3168
|
+
parsed.map((r) => ({ userId: r.userId, publicKey: Buffer.from(r.publicKey).toString("base64") }))
|
|
3169
|
+
));
|
|
3145
3170
|
} catch (err) {
|
|
3146
3171
|
console.error("[Control Plane] encrypt-intent error:", err);
|
|
3147
3172
|
res.status(500).json({ error: err instanceof Error ? err.message : "Encryption failed" });
|
|
@@ -3757,6 +3782,201 @@ function createInternalEventsRouter(getSecret, onSessionExpired, onAsKeyMismatch
|
|
|
3757
3782
|
return router;
|
|
3758
3783
|
}
|
|
3759
3784
|
|
|
3785
|
+
// src/routes/internal-mandate.ts
|
|
3786
|
+
import { Router as Router15 } from "express";
|
|
3787
|
+
import { timingSafeEqual as timingSafeEqual3, randomUUID } from "crypto";
|
|
3788
|
+
|
|
3789
|
+
// src/lib/mandate-ceremony.ts
|
|
3790
|
+
import {
|
|
3791
|
+
allowedCommitmentModes,
|
|
3792
|
+
computeBoundsHash,
|
|
3793
|
+
computeContextHash,
|
|
3794
|
+
computeIntentHash,
|
|
3795
|
+
isCommitmentModeAllowed,
|
|
3796
|
+
validateBoundsParams,
|
|
3797
|
+
validateContextParams
|
|
3798
|
+
} from "@hap/core";
|
|
3799
|
+
var MandateRefused = class extends Error {
|
|
3800
|
+
};
|
|
3801
|
+
var INTENT_MAX = 2e3;
|
|
3802
|
+
var shortOf = (id) => id.replace(/@.*$/, "").split("/").pop() ?? id;
|
|
3803
|
+
var versionOf = (id) => id.split("@")[1] ?? "";
|
|
3804
|
+
function refuse(msg) {
|
|
3805
|
+
throw new MandateRefused(msg);
|
|
3806
|
+
}
|
|
3807
|
+
async function resolveProfile(as, requested) {
|
|
3808
|
+
let id = requested.trim();
|
|
3809
|
+
if (!id.includes("@")) {
|
|
3810
|
+
const list = await as("GET", "/api/profiles");
|
|
3811
|
+
const matches = (list.body?.profiles ?? []).filter((p) => shortOf(p.id) === id);
|
|
3812
|
+
if (matches.length === 0) refuse(`Unknown profile "${requested}".`);
|
|
3813
|
+
matches.sort((a, b) => versionOf(b.id).localeCompare(versionOf(a.id), void 0, { numeric: true }));
|
|
3814
|
+
id = matches[0].id;
|
|
3815
|
+
}
|
|
3816
|
+
const res = await as("GET", `/api/profiles/${encodeURIComponent(id)}`);
|
|
3817
|
+
if (res.status !== 200 || !res.body?.id) refuse(`Unknown profile "${requested}".`);
|
|
3818
|
+
return res.body;
|
|
3819
|
+
}
|
|
3820
|
+
function scalarRecord(label, value) {
|
|
3821
|
+
if (value === void 0 || value === null) return {};
|
|
3822
|
+
if (typeof value !== "object" || Array.isArray(value)) refuse(`\`${label}\` must be an object.`);
|
|
3823
|
+
const out = {};
|
|
3824
|
+
for (const [k, v] of Object.entries(value)) {
|
|
3825
|
+
if (typeof v !== "string" && typeof v !== "number") refuse(`\`${label}.${k}\` must be a string or a number.`);
|
|
3826
|
+
out[k] = v;
|
|
3827
|
+
}
|
|
3828
|
+
return out;
|
|
3829
|
+
}
|
|
3830
|
+
function approverPubkeys(body) {
|
|
3831
|
+
const map = body?.pubkeys;
|
|
3832
|
+
if (!map || typeof map !== "object" || Array.isArray(map)) return [];
|
|
3833
|
+
return Object.entries(map).filter((e) => typeof e[1] === "string" && e[1].length > 0).map(([userId, publicKey]) => ({ userId, publicKey }));
|
|
3834
|
+
}
|
|
3835
|
+
async function planMandate(req, deps) {
|
|
3836
|
+
const user = deps.user;
|
|
3837
|
+
if (!user) refuse("The gateway is not signed in.");
|
|
3838
|
+
const groupsRes = await deps.as("GET", "/api/groups");
|
|
3839
|
+
const groups = groupsRes.body?.groups ?? [];
|
|
3840
|
+
const group = req.team ? groups.find((g) => !g.isPersonal && (g.id === req.team || g.name === req.team)) : groups.find((g) => g.isPersonal);
|
|
3841
|
+
if (!group) refuse(req.team ? `You are not a member of a team "${req.team}".` : "No personal workspace found.");
|
|
3842
|
+
const isPersonal = !!group.isPersonal;
|
|
3843
|
+
const profile = await resolveProfile(deps.as, req.profile);
|
|
3844
|
+
const name = profile.name ?? shortOf(profile.id);
|
|
3845
|
+
let approvers = [];
|
|
3846
|
+
if (!isPersonal) {
|
|
3847
|
+
const cfg = await deps.as("GET", `/api/groups/${encodeURIComponent(group.id)}/profile-config/${encodeURIComponent(profile.id)}`);
|
|
3848
|
+
approvers = (cfg.status === 200 ? cfg.body?.config?.approvers : void 0) ?? [];
|
|
3849
|
+
if (approvers.length === 0) refuse(`${name} is not enabled in "${group.name}": a team admin must name who may give ${name} mandates.`);
|
|
3850
|
+
if (!approvers.includes(user.id)) refuse(`Only ${name}'s approvers in "${group.name}" can give this mandate, and the signed-in person is not one of them.`);
|
|
3851
|
+
}
|
|
3852
|
+
const bounds = { ...scalarRecord("limits", req.limits), profile: profile.id };
|
|
3853
|
+
const bv = validateBoundsParams(bounds, profile);
|
|
3854
|
+
const errors = [...bv.errors];
|
|
3855
|
+
for (const [field, value] of Object.entries(bounds)) {
|
|
3856
|
+
const bt = profile.boundsSchema?.fields[field]?.boundType;
|
|
3857
|
+
if (bt?.kind === "enum" && Array.isArray(bt.values) && !bt.values.includes(String(value))) {
|
|
3858
|
+
errors.push(`Field "${field}" must be one of ${bt.values.join(", ")}.`);
|
|
3859
|
+
}
|
|
3860
|
+
}
|
|
3861
|
+
const context = scalarRecord("scope", req.scope);
|
|
3862
|
+
errors.push(...validateContextParams(context, profile).errors);
|
|
3863
|
+
if (errors.length) refuse(`The mandate does not fit profile ${profile.id}: ${errors.join(" ")}`);
|
|
3864
|
+
if (!isCommitmentModeAllowed(profile, req.mode)) {
|
|
3865
|
+
refuse(`Profile ${profile.id} allows mode ${allowedCommitmentModes(profile).join(" or ") || "none"}, not "${req.mode}".`);
|
|
3866
|
+
}
|
|
3867
|
+
const ttlSeconds = req.durationHours === void 0 ? profile.ttl.default : Math.round(req.durationHours * 3600);
|
|
3868
|
+
if (!(ttlSeconds > 0)) refuse("`duration_hours` must be positive.");
|
|
3869
|
+
if (ttlSeconds > profile.ttl.max) refuse(`At most ${profile.ttl.max / 3600} hours for profile ${profile.id}.`);
|
|
3870
|
+
const intent = typeof req.intent === "string" ? req.intent.trim() : "";
|
|
3871
|
+
if (!intent) refuse("`intent` is required \u2014 the why, goal and watch-outs, in the person's words.");
|
|
3872
|
+
if (intent.length > INTENT_MAX) refuse(`\`intent\` is ${intent.length} characters; the limit is ${INTENT_MAX}.`);
|
|
3873
|
+
return {
|
|
3874
|
+
groupId: group.id,
|
|
3875
|
+
groupName: group.name,
|
|
3876
|
+
isPersonal,
|
|
3877
|
+
domain: isPersonal ? "owner" : user.id,
|
|
3878
|
+
profile,
|
|
3879
|
+
bounds,
|
|
3880
|
+
context,
|
|
3881
|
+
intent,
|
|
3882
|
+
mode: req.mode,
|
|
3883
|
+
ttlSeconds,
|
|
3884
|
+
title: (req.title ?? "").trim() || `${name} (proposed by AI)`,
|
|
3885
|
+
approvers
|
|
3886
|
+
};
|
|
3887
|
+
}
|
|
3888
|
+
async function createMandate(req, deps) {
|
|
3889
|
+
const plan = await planMandate(req, deps);
|
|
3890
|
+
const user = deps.user;
|
|
3891
|
+
const boundsHash = computeBoundsHash(plan.bounds, plan.profile);
|
|
3892
|
+
const contextHash = computeContextHash(plan.context, plan.profile);
|
|
3893
|
+
const intentHash = computeIntentHash(plan.intent);
|
|
3894
|
+
const ecHash = computeIntentHash(JSON.stringify({ profile: plan.profile.id, domain: plan.domain, group: plan.groupId }));
|
|
3895
|
+
let enc;
|
|
3896
|
+
if (!plan.isPersonal) {
|
|
3897
|
+
const keys = await deps.as(
|
|
3898
|
+
"GET",
|
|
3899
|
+
`/api/groups/${encodeURIComponent(plan.groupId)}/profile-config/${encodeURIComponent(plan.profile.id)}/approvers/pubkeys`
|
|
3900
|
+
);
|
|
3901
|
+
const recipients = approverPubkeys(keys.body);
|
|
3902
|
+
if (recipients.length > 0) enc = await deps.encrypt(plan.intent, recipients);
|
|
3903
|
+
}
|
|
3904
|
+
const authorizationId = deps.newAuthorizationId();
|
|
3905
|
+
const res = await deps.as("POST", "/api/as/attest", {
|
|
3906
|
+
authorization_id: authorizationId,
|
|
3907
|
+
profile_id: plan.profile.id,
|
|
3908
|
+
bounds: plan.bounds,
|
|
3909
|
+
bounds_hash: boundsHash,
|
|
3910
|
+
context_hash: contextHash,
|
|
3911
|
+
domain: plan.domain,
|
|
3912
|
+
did: user.did,
|
|
3913
|
+
gate_content_hashes: { intent: intentHash },
|
|
3914
|
+
execution_context_hash: ecHash,
|
|
3915
|
+
group_id: plan.groupId,
|
|
3916
|
+
ttl: plan.ttlSeconds,
|
|
3917
|
+
commitment_mode: plan.mode,
|
|
3918
|
+
title: plan.title,
|
|
3919
|
+
intent_ciphertext: enc?.intentCiphertext,
|
|
3920
|
+
encrypted_keys: enc?.encryptedKeys,
|
|
3921
|
+
approvers_frozen: enc?.approversFrozen,
|
|
3922
|
+
intent_disclosure_hash: enc?.intentDisclosureHash
|
|
3923
|
+
});
|
|
3924
|
+
if (res.status >= 300) {
|
|
3925
|
+
const why = res.body?.message ?? res.body?.detail ?? res.body?.error ?? `status ${res.status}`;
|
|
3926
|
+
refuse(`The Authority Server refused the mandate: ${why}`);
|
|
3927
|
+
}
|
|
3928
|
+
try {
|
|
3929
|
+
await deps.deliverGateContent({ authorizationId, boundsHash, contextHash, context: plan.context, gateContent: { intent: plan.intent } });
|
|
3930
|
+
} catch (err) {
|
|
3931
|
+
await deps.as("POST", `/api/authorizations/${encodeURIComponent(authorizationId)}/revoke`, {
|
|
3932
|
+
reason: "Auto-revoked: gate content delivery failed"
|
|
3933
|
+
}).catch(() => {
|
|
3934
|
+
});
|
|
3935
|
+
refuse(`The mandate was signed but its intent could not be delivered to the gateway, so it was revoked. (${err instanceof Error ? err.message : String(err)})`);
|
|
3936
|
+
}
|
|
3937
|
+
return { authorizationId, plan };
|
|
3938
|
+
}
|
|
3939
|
+
|
|
3940
|
+
// src/routes/internal-mandate.ts
|
|
3941
|
+
function secretMatches2(provided, expected) {
|
|
3942
|
+
if (!expected || !provided) return false;
|
|
3943
|
+
const a = Buffer.from(provided);
|
|
3944
|
+
const b = Buffer.from(expected);
|
|
3945
|
+
return a.length === b.length && timingSafeEqual3(a, b);
|
|
3946
|
+
}
|
|
3947
|
+
function createInternalMandateRouter(getSecret, deps) {
|
|
3948
|
+
const router = Router15();
|
|
3949
|
+
router.post("/mandate", async (req, res) => {
|
|
3950
|
+
if (!secretMatches2(req.header("X-Internal-Secret") ?? void 0, getSecret())) {
|
|
3951
|
+
res.status(401).json({ error: "unauthorized" });
|
|
3952
|
+
return;
|
|
3953
|
+
}
|
|
3954
|
+
const { dryRun, request } = req.body ?? {};
|
|
3955
|
+
if (!request || typeof request !== "object") {
|
|
3956
|
+
res.status(400).json({ error: "missing request" });
|
|
3957
|
+
return;
|
|
3958
|
+
}
|
|
3959
|
+
const d = { ...deps(), newAuthorizationId: () => `authz_${randomUUID()}` };
|
|
3960
|
+
try {
|
|
3961
|
+
if (dryRun === true) {
|
|
3962
|
+
const plan2 = await planMandate(request, d);
|
|
3963
|
+
res.json({ ok: true, groupName: plan2.groupName, profileId: plan2.profile.id, mode: plan2.mode, ttlSeconds: plan2.ttlSeconds });
|
|
3964
|
+
return;
|
|
3965
|
+
}
|
|
3966
|
+
const { authorizationId, plan } = await createMandate(request, d);
|
|
3967
|
+
res.json({ ok: true, authorizationId, profileId: plan.profile.id, groupName: plan.groupName, mode: plan.mode, ttlSeconds: plan.ttlSeconds });
|
|
3968
|
+
} catch (err) {
|
|
3969
|
+
if (err instanceof MandateRefused) {
|
|
3970
|
+
res.status(422).json({ error: "mandate_refused", message: err.message });
|
|
3971
|
+
return;
|
|
3972
|
+
}
|
|
3973
|
+
console.error("[Control Plane] /internal/mandate failed:", err);
|
|
3974
|
+
res.status(500).json({ error: "internal", message: err instanceof Error ? err.message : String(err) });
|
|
3975
|
+
}
|
|
3976
|
+
});
|
|
3977
|
+
return router;
|
|
3978
|
+
}
|
|
3979
|
+
|
|
3760
3980
|
// src/lib/notification-dispatcher.ts
|
|
3761
3981
|
var TRIGGER_EVENTS = ["proposal-added", "action-approval-needed"];
|
|
3762
3982
|
var TITLE = "Suveren";
|
|
@@ -4273,6 +4493,33 @@ app.get("/auth/oauth/:integrationId/health", authGuard, async (req, res) => {
|
|
|
4273
4493
|
});
|
|
4274
4494
|
app.get("/events", requireAllowedHost, requireAuthQueryOrHeader(vault), createEventsHandler());
|
|
4275
4495
|
app.use("/internal", jsonParser, createInternalEventsRouter(() => internalSecret2, lockExpiredSession, lockAsKeyMismatch, lockAsTlsMismatch));
|
|
4496
|
+
app.use("/internal", jsonParser, createInternalMandateRouter(() => internalSecret2, () => ({
|
|
4497
|
+
user: vault.getSessionUser(),
|
|
4498
|
+
as: async (method, path, body) => {
|
|
4499
|
+
const cookie = vault.getSpCookie();
|
|
4500
|
+
if (!cookie) return { status: 401, body: { error: "not signed in" } };
|
|
4501
|
+
const pin = readPairing(DATA_DIR);
|
|
4502
|
+
try {
|
|
4503
|
+
const { res } = await fetchAs(
|
|
4504
|
+
`${SP_URL}${path}`,
|
|
4505
|
+
{
|
|
4506
|
+
method,
|
|
4507
|
+
headers: { Cookie: cookie, ...body !== void 0 ? { "Content-Type": "application/json" } : {} },
|
|
4508
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0,
|
|
4509
|
+
signal: AbortSignal.timeout(15e3)
|
|
4510
|
+
},
|
|
4511
|
+
{ enforce: resolvePinTls(DATA_DIR), pinnedSpkiHex: pin && pin.asUrl === SP_URL ? pin.tlsSpkiPinHex : void 0 }
|
|
4512
|
+
);
|
|
4513
|
+
if (res.status === 401 && vault.isUnlocked()) lockExpiredSession();
|
|
4514
|
+
return { status: res.status, body: await res.json().catch(() => ({})) };
|
|
4515
|
+
} catch (err) {
|
|
4516
|
+
if (err instanceof AsTlsMismatchError) lockAsTlsMismatch();
|
|
4517
|
+
throw err;
|
|
4518
|
+
}
|
|
4519
|
+
},
|
|
4520
|
+
encrypt: encryptIntentForRecipients,
|
|
4521
|
+
deliverGateContent: (args) => pushGateContent(args)
|
|
4522
|
+
})));
|
|
4276
4523
|
app.use("/vault", jsonParser, authGuard, createVaultRouter(vault));
|
|
4277
4524
|
app.use("/ai", jsonParser, authGuard, createAIRouter(vault));
|
|
4278
4525
|
app.use("/ai-prompts", jsonParser, authGuard, createAIPromptsRouter());
|
|
@@ -4466,6 +4713,14 @@ app.post("/gate-content", jsonParser, authGuard, async (req, res) => {
|
|
|
4466
4713
|
res.status(500).json({ error: "Failed to forward gate content to MCP server" });
|
|
4467
4714
|
}
|
|
4468
4715
|
});
|
|
4716
|
+
app.get("/tool-display", authGuard, async (_req, res) => {
|
|
4717
|
+
try {
|
|
4718
|
+
res.json(await getToolDisplay());
|
|
4719
|
+
} catch (err) {
|
|
4720
|
+
console.error("[Control Plane] tool-display error:", err);
|
|
4721
|
+
res.status(502).json({ error: "MCP server unavailable" });
|
|
4722
|
+
}
|
|
4723
|
+
});
|
|
4469
4724
|
app.post("/resync-gates", authGuard, async (_req, res) => {
|
|
4470
4725
|
try {
|
|
4471
4726
|
const data = await resyncGates();
|
package/dist/mcp-server/http.mjs
CHANGED
|
@@ -2984,7 +2984,7 @@ function createGatedToolHandler(tool, integrationManager2, state2) {
|
|
|
2984
2984
|
const gateable = !!tool.gating?.profile && tool.gating.category !== "disabled";
|
|
2985
2985
|
const inner = async (args) => {
|
|
2986
2986
|
const normalized = normalizeIncomingArgs(tool, args);
|
|
2987
|
-
const refusal = gateable ? integrationManager2.precheckBuiltin?.(tool, normalized) : null;
|
|
2987
|
+
const refusal = gateable ? await integrationManager2.precheckBuiltin?.(tool, normalized) : null;
|
|
2988
2988
|
if (refusal) return { content: [{ type: "text", text: refusal }], isError: true };
|
|
2989
2989
|
return gated(normalized);
|
|
2990
2990
|
};
|
|
@@ -5238,6 +5238,28 @@ var IntegrationManager = class {
|
|
|
5238
5238
|
console.error(`[IntegrationManager] Built-in ${def.id}: ${tools.length} tool(s) under profile ${def.profile}`);
|
|
5239
5239
|
this.onToolsChanged?.();
|
|
5240
5240
|
}
|
|
5241
|
+
/** The built-in groups, for the mandate picker — never part of the connector status list. */
|
|
5242
|
+
getBuiltins() {
|
|
5243
|
+
return Array.from(this.builtins.values()).map(({ def }) => ({
|
|
5244
|
+
id: def.id,
|
|
5245
|
+
name: def.name,
|
|
5246
|
+
description: def.description ?? "",
|
|
5247
|
+
profile: def.profile,
|
|
5248
|
+
available: !def.simulationOnly || isSimulationMode()
|
|
5249
|
+
}));
|
|
5250
|
+
}
|
|
5251
|
+
/**
|
|
5252
|
+
* What the approval screen needs to show a proposal for each tool: its argument
|
|
5253
|
+
* schema (labels from the descriptions) and the optional approvalView hints.
|
|
5254
|
+
* Keyed by the namespaced tool name a proposal carries.
|
|
5255
|
+
*/
|
|
5256
|
+
getToolDisplay() {
|
|
5257
|
+
const out = {};
|
|
5258
|
+
for (const t of this.getAllTools()) {
|
|
5259
|
+
out[t.namespacedName] = { inputSchema: t.inputSchema, ...t.gating?.approvalView ? { approvalView: t.gating.approvalView } : {} };
|
|
5260
|
+
}
|
|
5261
|
+
return out;
|
|
5262
|
+
}
|
|
5241
5263
|
/** Whether `id` names a built-in (in-process) integration. */
|
|
5242
5264
|
isBuiltin(id) {
|
|
5243
5265
|
return this.builtins.has(id);
|
|
@@ -5256,14 +5278,14 @@ var IntegrationManager = class {
|
|
|
5256
5278
|
* no ticket): `simulationOnly` outside simulation mode, then the tool's `validate`.
|
|
5257
5279
|
* Null for connector tools and for calls that may proceed.
|
|
5258
5280
|
*/
|
|
5259
|
-
precheckBuiltin(tool, args) {
|
|
5281
|
+
async precheckBuiltin(tool, args) {
|
|
5260
5282
|
const builtin = this.builtins.get(tool.integrationId);
|
|
5261
5283
|
if (!builtin) return null;
|
|
5262
5284
|
if (builtin.def.simulationOnly && !isSimulationMode()) {
|
|
5263
5285
|
return `Refused: "${tool.namespacedName}" is not available. No ticket was requested.`;
|
|
5264
5286
|
}
|
|
5265
5287
|
const def = builtin.def.tools.find((t) => t.name === tool.originalName);
|
|
5266
|
-
const refusal = def?.validate?.(args);
|
|
5288
|
+
const refusal = await def?.validate?.(args);
|
|
5267
5289
|
return refusal ? `Refused: ${refusal} No ticket was requested.` : null;
|
|
5268
5290
|
}
|
|
5269
5291
|
/**
|
|
@@ -5474,7 +5496,8 @@ var IntegrationManager = class {
|
|
|
5474
5496
|
readGovernance: ext.readGovernance,
|
|
5475
5497
|
readGovernanceReason: ext.readGovernanceReason,
|
|
5476
5498
|
blockedArgs: ext.blockedArgs,
|
|
5477
|
-
hideUnlessAuthorized: ext.hideUnlessAuthorized
|
|
5499
|
+
hideUnlessAuthorized: ext.hideUnlessAuthorized,
|
|
5500
|
+
approvalView: ext.approvalView
|
|
5478
5501
|
};
|
|
5479
5502
|
}
|
|
5480
5503
|
return {
|
|
@@ -5489,7 +5512,8 @@ var IntegrationManager = class {
|
|
|
5489
5512
|
blockedArgs: ext.blockedArgs,
|
|
5490
5513
|
argEncoding: ext.argEncoding,
|
|
5491
5514
|
argNormalization: ext.argNormalization,
|
|
5492
|
-
hideUnlessAuthorized: ext.hideUnlessAuthorized
|
|
5515
|
+
hideUnlessAuthorized: ext.hideUnlessAuthorized,
|
|
5516
|
+
approvalView: ext.approvalView
|
|
5493
5517
|
};
|
|
5494
5518
|
}
|
|
5495
5519
|
return {
|
|
@@ -5596,6 +5620,27 @@ function builtinText(text) {
|
|
|
5596
5620
|
return { content: [{ type: "text", text }] };
|
|
5597
5621
|
}
|
|
5598
5622
|
|
|
5623
|
+
// src/lib/cp-mandate.ts
|
|
5624
|
+
var CP_PORT2 = process.env.SUVEREN_CP_PORT ?? "3402";
|
|
5625
|
+
var CP_BASE2 = process.env.SUVEREN_CP_INTERNAL_URL ?? `http://127.0.0.1:${CP_PORT2}`;
|
|
5626
|
+
async function controlPlaneMandate(dryRun, request) {
|
|
5627
|
+
const secret = process.env.SUVEREN_INTERNAL_SECRET ?? "";
|
|
5628
|
+
if (!secret) return { ok: false, message: "the gateway is not fully started (no control plane connection)." };
|
|
5629
|
+
try {
|
|
5630
|
+
const res = await fetch(`${CP_BASE2}/internal/mandate`, {
|
|
5631
|
+
method: "POST",
|
|
5632
|
+
headers: { "Content-Type": "application/json", "X-Internal-Secret": secret },
|
|
5633
|
+
body: JSON.stringify({ dryRun, request }),
|
|
5634
|
+
signal: AbortSignal.timeout(3e4)
|
|
5635
|
+
});
|
|
5636
|
+
const body = await res.json().catch(() => ({}));
|
|
5637
|
+
if (res.ok) return { ok: true, ...body };
|
|
5638
|
+
return { ok: false, message: typeof body.message === "string" ? body.message : `control plane answered ${res.status}` };
|
|
5639
|
+
} catch (err) {
|
|
5640
|
+
return { ok: false, message: `the control plane could not be reached (${err instanceof Error ? err.message : String(err)}).` };
|
|
5641
|
+
}
|
|
5642
|
+
}
|
|
5643
|
+
|
|
5599
5644
|
// src/lib/builtins/setup.ts
|
|
5600
5645
|
var DELEGATION_PROFILE = "github.com/humanagencyprotocol/hap-profiles/delegation@0.1";
|
|
5601
5646
|
function briefRefusal(args) {
|
|
@@ -5608,6 +5653,7 @@ function setupBuiltin(_deps) {
|
|
|
5608
5653
|
return {
|
|
5609
5654
|
id: "setup",
|
|
5610
5655
|
name: "Test setup",
|
|
5656
|
+
description: "Your AI proposes its own setup for a test \u2014 its agent brief and its mandates. Every proposal waits for your approval. Simulation mode only.",
|
|
5611
5657
|
profile: DELEGATION_PROFILE,
|
|
5612
5658
|
simulation: true,
|
|
5613
5659
|
simulationOnly: true,
|
|
@@ -5616,7 +5662,24 @@ function setupBuiltin(_deps) {
|
|
|
5616
5662
|
set_agent_brief: {
|
|
5617
5663
|
executionMapping: {},
|
|
5618
5664
|
staticExecution: { action_type: "brief" },
|
|
5619
|
-
hideUnlessAuthorized: true
|
|
5665
|
+
hideUnlessAuthorized: true,
|
|
5666
|
+
approvalView: { content: { label: "New agent brief", kind: "markdown" } }
|
|
5667
|
+
},
|
|
5668
|
+
create_mandate: {
|
|
5669
|
+
executionMapping: {},
|
|
5670
|
+
staticExecution: { action_type: "mandate" },
|
|
5671
|
+
hideUnlessAuthorized: true,
|
|
5672
|
+
// Shown like the mandate screen: limits and scope by the profile's own names.
|
|
5673
|
+
approvalView: {
|
|
5674
|
+
team: { label: "Team" },
|
|
5675
|
+
profile: { label: "Profile", kind: "profile" },
|
|
5676
|
+
limits: { label: "Limits", kind: "profile-limits", profileArg: "profile" },
|
|
5677
|
+
scope: { label: "Scope", kind: "profile-scope", profileArg: "profile" },
|
|
5678
|
+
intent: { label: "Intent", kind: "markdown" },
|
|
5679
|
+
mode: { label: "Mode" },
|
|
5680
|
+
duration_hours: { label: "Valid for (hours)" },
|
|
5681
|
+
title: { label: "Title" }
|
|
5682
|
+
}
|
|
5620
5683
|
}
|
|
5621
5684
|
}
|
|
5622
5685
|
},
|
|
@@ -5645,10 +5708,56 @@ function setupBuiltin(_deps) {
|
|
|
5645
5708
|
`Agent brief replaced (${Buffer.byteLength(args.content, "utf-8")} bytes). It applies from the next agent session.`
|
|
5646
5709
|
);
|
|
5647
5710
|
}
|
|
5711
|
+
},
|
|
5712
|
+
{
|
|
5713
|
+
name: "create_mandate",
|
|
5714
|
+
description: "Simulation mode only: propose a new mandate for the person running this gateway \u2014 the authority another tool needs (e.g. sales quotes up to a value). A person approves or rejects the proposal; only after approval is the mandate created, exactly as if they had signed it on the mandate screen. Checked against the profile before the proposal: unknown limits, a mode the profile does not allow, or a team where the person may not give this mandate are refused at once. Create only.",
|
|
5715
|
+
inputSchema: {
|
|
5716
|
+
type: "object",
|
|
5717
|
+
properties: {
|
|
5718
|
+
team: { type: "string", description: "Team name or id. Omit for the personal workspace." },
|
|
5719
|
+
profile: { type: "string", description: 'Profile, e.g. "sales" (newest version) or a full profile id.' },
|
|
5720
|
+
limits: { type: "object", description: "The limits (bounds) by field name, as the profile defines them." },
|
|
5721
|
+
scope: { type: "object", description: "The scope (context) by field name, as the profile defines it." },
|
|
5722
|
+
intent: { type: "string", description: "Why, goal and watch-outs \u2014 signed with the mandate (at most 2000 characters)." },
|
|
5723
|
+
mode: { type: "string", enum: ["review", "automatic"], description: "review = each action needs approval; automatic = within the limits." },
|
|
5724
|
+
duration_hours: { type: "number", description: "How long the mandate is valid, in hours. Omit for the profile default." },
|
|
5725
|
+
title: { type: "string", description: "A short name for the mandate." },
|
|
5726
|
+
receipt_id: {
|
|
5727
|
+
type: "string",
|
|
5728
|
+
description: "Authorization reference for this call, set by the governing gateway \u2014 agents do not set this."
|
|
5729
|
+
}
|
|
5730
|
+
},
|
|
5731
|
+
required: ["profile", "limits", "intent", "mode"]
|
|
5732
|
+
},
|
|
5733
|
+
validate: async (args) => {
|
|
5734
|
+
const check = await controlPlaneMandate(true, mandateRequest(args));
|
|
5735
|
+
return check.ok ? void 0 : check.message;
|
|
5736
|
+
},
|
|
5737
|
+
handler: async (args) => {
|
|
5738
|
+
if (!isSimulationMode()) return { ...builtinText("Refused: not available outside simulation mode."), isError: true };
|
|
5739
|
+
const r = await controlPlaneMandate(false, mandateRequest(args));
|
|
5740
|
+
if (!r.ok) return { ...builtinText(`Refused: ${r.message}`), isError: true };
|
|
5741
|
+
return builtinText(
|
|
5742
|
+
`Mandate created: ${r.authorizationId} \u2014 profile ${r.profileId}, ${r.groupName}, mode ${r.mode}, valid for ${Math.round((r.ttlSeconds ?? 0) / 3600)} h. It is active now.`
|
|
5743
|
+
);
|
|
5744
|
+
}
|
|
5648
5745
|
}
|
|
5649
5746
|
]
|
|
5650
5747
|
};
|
|
5651
5748
|
}
|
|
5749
|
+
function mandateRequest(args) {
|
|
5750
|
+
return {
|
|
5751
|
+
team: typeof args.team === "string" && args.team.trim() ? args.team.trim() : void 0,
|
|
5752
|
+
profile: args.profile,
|
|
5753
|
+
limits: args.limits,
|
|
5754
|
+
scope: args.scope,
|
|
5755
|
+
intent: args.intent,
|
|
5756
|
+
mode: args.mode,
|
|
5757
|
+
durationHours: args.duration_hours,
|
|
5758
|
+
title: args.title
|
|
5759
|
+
};
|
|
5760
|
+
}
|
|
5652
5761
|
|
|
5653
5762
|
// src/lib/builtins/index.ts
|
|
5654
5763
|
var BUILTIN_FACTORIES = [setupBuiltin];
|
|
@@ -6134,9 +6243,15 @@ app.get("/health", (_req, res) => {
|
|
|
6134
6243
|
activeSessions: activeSessions.size,
|
|
6135
6244
|
storedGates: state.gateStore.getAll().length,
|
|
6136
6245
|
serviceCredentials: Array.from(serviceCredentials.keys()),
|
|
6137
|
-
integrations: integrationManager.getStatus(integrationRegistry.getAll())
|
|
6246
|
+
integrations: integrationManager.getStatus(integrationRegistry.getAll()),
|
|
6247
|
+
// The gateway's own tool groups (built-ins) — not connectors; the mandate
|
|
6248
|
+
// picker offers them so a person can give a mandate for them.
|
|
6249
|
+
builtins: integrationManager.getBuiltins()
|
|
6138
6250
|
});
|
|
6139
6251
|
});
|
|
6252
|
+
app.get("/internal/tool-display", internalOnly, (_req, res) => {
|
|
6253
|
+
res.json({ tools: integrationManager.getToolDisplay() });
|
|
6254
|
+
});
|
|
6140
6255
|
app.get("/internal/gate-content", internalOnly, (req, res) => {
|
|
6141
6256
|
const path = req.query.path;
|
|
6142
6257
|
const gates = state.gateStore.getAll();
|