@akira-tl/forgerelay 0.6.0 → 0.6.2
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/CHANGELOG.md +36 -0
- package/README.md +47 -12
- package/dist/activity/mcp-query-tools.js +48 -2
- package/dist/activity/query-service.js +1 -0
- package/dist/cli.js +181 -4
- package/dist/composite-activity.js +155 -0
- package/dist/composite-workspaces.js +197 -0
- package/dist/config.js +4 -1
- package/dist/oauth/router.js +21 -1
- package/dist/oauth-provider.js +32 -4
- package/dist/oauth-store.js +9 -0
- package/dist/remote-auth.js +110 -0
- package/dist/remote-transport.js +196 -0
- package/dist/remote-workspace-relay.js +473 -0
- package/dist/server.js +824 -121
- package/dist/ui/.vite/manifest.json +33 -33
- package/dist/ui/activity-panel-app.html +3 -3
- package/dist/ui/assets/{activity-panel-app-CjZVvVNc.js → activity-panel-app-E1ju2dqI.js} +1 -1
- package/dist/ui/assets/{heavy-payload-vGgBRvNX.js → heavy-payload-CeW-n9w5.js} +1 -1
- package/dist/ui/assets/{review-payload-4erWKckt.js → review-payload-B9CO298v.js} +1 -1
- package/dist/ui/assets/{scrollbar-CaOPzUJd.js → scrollbar-C2twAENW.js} +1 -1
- package/dist/ui/assets/workspace-app-BztEvZIC.js +5 -0
- package/dist/ui/assets/{workspace-app-DkAiSl_0.js → workspace-app-CwbJnb_w.js} +1 -1
- package/dist/ui/assets/workspace-app-YnUST8IP.css +1 -0
- package/dist/ui/assets/workspace-app-rKuhdae8.js +1 -0
- package/dist/ui/assets/workspace-lifecycle-app-CEfMdudP.js +1 -0
- package/dist/ui/workspace-app.html +4 -4
- package/dist/ui/workspace-lifecycle-app.html +4 -4
- package/dist/user-config.js +131 -5
- package/docs/configuration.md +26 -3
- package/docs/debugging.md +7 -0
- package/docs/versioning.md +11 -19
- package/package.json +5 -2
- package/scripts/ci/verify.mjs +38 -0
- package/scripts/debug/runtime.mjs +23 -1
- package/scripts/debug/runtime.test.mjs +14 -2
- package/scripts/debug/serve.mjs +4 -4
- package/scripts/release/pack.mjs +36 -0
- package/scripts/release/publish.mjs +157 -0
- package/scripts/release/release-gate.test.mjs +73 -16
- package/scripts/release-parity.mjs +5 -13
- package/scripts/release-proof.mjs +28 -19
- package/scripts/release-proof.test.mjs +32 -11
- package/scripts/release-version.mjs +1 -1
- package/dist/ui/assets/workspace-app-CcrHAUIn.css +0 -1
- package/dist/ui/assets/workspace-app-DJmkPYJC.js +0 -1
- package/dist/ui/assets/workspace-app-QyauBrJX.js +0 -5
- package/dist/ui/assets/workspace-lifecycle-app-BIXEo53I.js +0 -1
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
export class CompositeWorkspaceRegistry {
|
|
5
|
+
stateDir;
|
|
6
|
+
statePath;
|
|
7
|
+
records = new Map();
|
|
8
|
+
constructor(stateDir) {
|
|
9
|
+
this.stateDir = stateDir;
|
|
10
|
+
this.statePath = join(stateDir, "composite-workspaces.json");
|
|
11
|
+
this.load();
|
|
12
|
+
}
|
|
13
|
+
has(workspaceId) {
|
|
14
|
+
return this.records.has(workspaceId);
|
|
15
|
+
}
|
|
16
|
+
create(name) {
|
|
17
|
+
const normalized = normalizeName(name);
|
|
18
|
+
const existing = [...this.records.values()].find((record) => record.name === normalized);
|
|
19
|
+
if (existing)
|
|
20
|
+
return this.touch(existing.id);
|
|
21
|
+
const now = new Date().toISOString();
|
|
22
|
+
const record = {
|
|
23
|
+
id: `cws_${randomBytes(5).toString("hex")}`,
|
|
24
|
+
kind: "composite",
|
|
25
|
+
name: normalized,
|
|
26
|
+
members: [],
|
|
27
|
+
createdAt: now,
|
|
28
|
+
lastUsedAt: now,
|
|
29
|
+
};
|
|
30
|
+
this.records.set(record.id, record);
|
|
31
|
+
this.persist();
|
|
32
|
+
return cloneRecord(record);
|
|
33
|
+
}
|
|
34
|
+
get(workspaceId) {
|
|
35
|
+
const record = this.records.get(workspaceId);
|
|
36
|
+
if (!record)
|
|
37
|
+
throw new Error(`Unknown Composite Workspace ${workspaceId}.`);
|
|
38
|
+
return cloneRecord(record);
|
|
39
|
+
}
|
|
40
|
+
open(workspaceId) {
|
|
41
|
+
return this.touch(workspaceId);
|
|
42
|
+
}
|
|
43
|
+
list() {
|
|
44
|
+
return [...this.records.values()]
|
|
45
|
+
.map(cloneRecord)
|
|
46
|
+
.sort((left, right) => right.lastUsedAt.localeCompare(left.lastUsedAt));
|
|
47
|
+
}
|
|
48
|
+
addMember(workspaceId, input) {
|
|
49
|
+
const record = this.requireRecord(workspaceId);
|
|
50
|
+
const name = normalizeMemberName(input.name);
|
|
51
|
+
const purpose = input.purpose.trim();
|
|
52
|
+
if (!purpose)
|
|
53
|
+
throw new Error("Composite Workspace member purpose must not be empty.");
|
|
54
|
+
const existing = record.members.find((member) => member.name === name);
|
|
55
|
+
if (existing) {
|
|
56
|
+
if (existing.purpose === purpose && existing.workspaceId === input.workspaceId) {
|
|
57
|
+
return cloneRecord(record);
|
|
58
|
+
}
|
|
59
|
+
throw new Error(`Composite Workspace ${workspaceId} already has member ${name} with a different definition.`);
|
|
60
|
+
}
|
|
61
|
+
record.members.push({ name, purpose, workspaceId: input.workspaceId });
|
|
62
|
+
record.lastUsedAt = new Date().toISOString();
|
|
63
|
+
this.persist();
|
|
64
|
+
return cloneRecord(record);
|
|
65
|
+
}
|
|
66
|
+
updateMember(workspaceId, memberName, input) {
|
|
67
|
+
const record = this.requireRecord(workspaceId);
|
|
68
|
+
const currentName = normalizeMemberName(memberName);
|
|
69
|
+
const index = record.members.findIndex((member) => member.name === currentName);
|
|
70
|
+
if (index < 0)
|
|
71
|
+
throw new Error(`Composite Workspace ${workspaceId} has no member ${currentName}.`);
|
|
72
|
+
const current = record.members[index];
|
|
73
|
+
const nextName = input.name === undefined ? current.name : normalizeMemberName(input.name);
|
|
74
|
+
const nextPurpose = input.purpose === undefined ? current.purpose : input.purpose.trim();
|
|
75
|
+
if (!nextPurpose)
|
|
76
|
+
throw new Error("Composite Workspace member purpose must not be empty.");
|
|
77
|
+
const nextWorkspaceId = input.workspaceId ?? current.workspaceId;
|
|
78
|
+
if (nextName !== current.name &&
|
|
79
|
+
record.members.some((member, memberIndex) => memberIndex !== index && member.name === nextName)) {
|
|
80
|
+
throw new Error(`Composite Workspace ${workspaceId} already has member ${nextName}.`);
|
|
81
|
+
}
|
|
82
|
+
if (nextName === current.name &&
|
|
83
|
+
nextPurpose === current.purpose &&
|
|
84
|
+
nextWorkspaceId === current.workspaceId) {
|
|
85
|
+
return cloneRecord(record);
|
|
86
|
+
}
|
|
87
|
+
record.members[index] = {
|
|
88
|
+
name: nextName,
|
|
89
|
+
purpose: nextPurpose,
|
|
90
|
+
workspaceId: nextWorkspaceId,
|
|
91
|
+
};
|
|
92
|
+
record.lastUsedAt = new Date().toISOString();
|
|
93
|
+
this.persist();
|
|
94
|
+
return cloneRecord(record);
|
|
95
|
+
}
|
|
96
|
+
removeMember(workspaceId, memberName) {
|
|
97
|
+
const record = this.requireRecord(workspaceId);
|
|
98
|
+
const name = normalizeMemberName(memberName);
|
|
99
|
+
const index = record.members.findIndex((member) => member.name === name);
|
|
100
|
+
if (index < 0)
|
|
101
|
+
throw new Error(`Composite Workspace ${workspaceId} has no member ${name}.`);
|
|
102
|
+
record.members.splice(index, 1);
|
|
103
|
+
record.lastUsedAt = new Date().toISOString();
|
|
104
|
+
this.persist();
|
|
105
|
+
return cloneRecord(record);
|
|
106
|
+
}
|
|
107
|
+
member(workspaceId, memberName) {
|
|
108
|
+
const record = this.requireRecord(workspaceId);
|
|
109
|
+
const name = normalizeMemberName(memberName);
|
|
110
|
+
const member = record.members.find((entry) => entry.name === name);
|
|
111
|
+
if (!member)
|
|
112
|
+
throw new Error(`Composite Workspace ${workspaceId} has no member ${name}.`);
|
|
113
|
+
return { ...member };
|
|
114
|
+
}
|
|
115
|
+
dissolve(workspaceId) {
|
|
116
|
+
const record = this.requireRecord(workspaceId);
|
|
117
|
+
this.records.delete(workspaceId);
|
|
118
|
+
this.persist();
|
|
119
|
+
return cloneRecord(record);
|
|
120
|
+
}
|
|
121
|
+
touch(workspaceId) {
|
|
122
|
+
const record = this.requireRecord(workspaceId);
|
|
123
|
+
record.lastUsedAt = new Date().toISOString();
|
|
124
|
+
this.persist();
|
|
125
|
+
return cloneRecord(record);
|
|
126
|
+
}
|
|
127
|
+
requireRecord(workspaceId) {
|
|
128
|
+
const record = this.records.get(workspaceId);
|
|
129
|
+
if (!record)
|
|
130
|
+
throw new Error(`Unknown Composite Workspace ${workspaceId}.`);
|
|
131
|
+
return record;
|
|
132
|
+
}
|
|
133
|
+
load() {
|
|
134
|
+
let parsed;
|
|
135
|
+
try {
|
|
136
|
+
parsed = JSON.parse(readFileSync(this.statePath, "utf8"));
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (isMissingFile(error))
|
|
140
|
+
return;
|
|
141
|
+
throw new Error(`Failed to load Composite Workspace state: ${errorMessage(error)}`);
|
|
142
|
+
}
|
|
143
|
+
if (parsed?.version !== 1 || !Array.isArray(parsed.workspaces)) {
|
|
144
|
+
throw new Error("Composite Workspace state has an unsupported format.");
|
|
145
|
+
}
|
|
146
|
+
for (const record of parsed.workspaces) {
|
|
147
|
+
if (!record?.id?.startsWith("cws_") || record.kind !== "composite")
|
|
148
|
+
continue;
|
|
149
|
+
this.records.set(record.id, {
|
|
150
|
+
...record,
|
|
151
|
+
members: Array.isArray(record.members) ? record.members.map((member) => ({ ...member })) : [],
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
persist() {
|
|
156
|
+
mkdirSync(this.stateDir, { recursive: true });
|
|
157
|
+
const state = {
|
|
158
|
+
version: 1,
|
|
159
|
+
workspaces: [...this.records.values()].map(cloneRecord),
|
|
160
|
+
};
|
|
161
|
+
const tempPath = `${this.statePath}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
162
|
+
try {
|
|
163
|
+
writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
164
|
+
renameSync(tempPath, this.statePath);
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
rmSync(tempPath, { force: true });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function normalizeName(name) {
|
|
172
|
+
const value = name.trim();
|
|
173
|
+
if (!value)
|
|
174
|
+
throw new Error("Composite Workspace name must not be empty.");
|
|
175
|
+
if (value.length > 120)
|
|
176
|
+
throw new Error("Composite Workspace name must be at most 120 characters.");
|
|
177
|
+
return value;
|
|
178
|
+
}
|
|
179
|
+
function normalizeMemberName(name) {
|
|
180
|
+
const value = name.trim();
|
|
181
|
+
if (!/^[a-z][a-z0-9_-]{0,31}$/.test(value)) {
|
|
182
|
+
throw new Error("Composite Workspace member name must match /^[a-z][a-z0-9_-]{0,31}$/.");
|
|
183
|
+
}
|
|
184
|
+
return value;
|
|
185
|
+
}
|
|
186
|
+
function cloneRecord(record) {
|
|
187
|
+
return {
|
|
188
|
+
...record,
|
|
189
|
+
members: record.members.map((member) => ({ ...member })),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
function isMissingFile(error) {
|
|
193
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
194
|
+
}
|
|
195
|
+
function errorMessage(error) {
|
|
196
|
+
return error instanceof Error ? error.message : String(error);
|
|
197
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -3,7 +3,7 @@ import { homedir } from "node:os";
|
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { expandHomePath } from "./roots.js";
|
|
5
5
|
import { mergeHookConfigs, parseHookConfig } from "./hooks.js";
|
|
6
|
-
import { forgerelayAgentsDir, forgerelaySkillsDir, loadForgeRelayFiles, } from "./user-config.js";
|
|
6
|
+
import { forgerelayAgentsDir, forgerelaySkillsDir, generateInstanceId, loadForgeRelayFiles, } from "./user-config.js";
|
|
7
7
|
const DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
|
|
8
8
|
const DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;
|
|
9
9
|
const DEFAULT_ARTIFACT_MAX_FILE_BYTES = 100 * 1024 * 1024;
|
|
@@ -211,6 +211,7 @@ function resolvePublicDeployment(env, fileConfig, host, port) {
|
|
|
211
211
|
}
|
|
212
212
|
export function loadConfig(env = process.env) {
|
|
213
213
|
const files = loadForgeRelayFiles(env);
|
|
214
|
+
const instanceId = files.auth.instanceId?.trim() || generateInstanceId();
|
|
214
215
|
const host = env.HOST ?? files.config.host ?? "127.0.0.1";
|
|
215
216
|
const port = parsePort(env.PORT ?? files.config.port);
|
|
216
217
|
const publicDeployment = resolvePublicDeployment(env, files.config, host, port);
|
|
@@ -224,6 +225,8 @@ export function loadConfig(env = process.env) {
|
|
|
224
225
|
...(files.config.allowedHosts ?? []),
|
|
225
226
|
];
|
|
226
227
|
return {
|
|
228
|
+
instanceId,
|
|
229
|
+
configDir: files.dir,
|
|
227
230
|
host,
|
|
228
231
|
port,
|
|
229
232
|
oauth: parseOAuthConfig(env, files.auth.ownerToken),
|
package/dist/oauth/router.js
CHANGED
|
@@ -7,7 +7,7 @@ import { revocationHandler } from "@modelcontextprotocol/sdk/server/auth/handler
|
|
|
7
7
|
import { metadataHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/metadata.js";
|
|
8
8
|
import { oauthAuthorizationServerMetadataPath, publicEndpointUrl, } from "./public-url.js";
|
|
9
9
|
export function createForgeRelayAuthRouter(options) {
|
|
10
|
-
const { provider, issuerUrl, resourceServerUrl, scopesSupported, resourceName } = options;
|
|
10
|
+
const { provider, cliAuthenticationProvider, instanceId, issuerUrl, resourceServerUrl, scopesSupported, resourceName, } = options;
|
|
11
11
|
const authorizationEndpoint = publicEndpointUrl(issuerUrl, "authorize");
|
|
12
12
|
const tokenEndpoint = publicEndpointUrl(issuerUrl, "token");
|
|
13
13
|
const registrationEndpoint = provider.clientsStore.registerClient
|
|
@@ -35,6 +35,26 @@ export function createForgeRelayAuthRouter(options) {
|
|
|
35
35
|
resource_name: resourceName,
|
|
36
36
|
};
|
|
37
37
|
const router = express.Router();
|
|
38
|
+
if (cliAuthenticationProvider) {
|
|
39
|
+
router.post("/auth/cli", express.json({ limit: "4kb" }), (req, res) => {
|
|
40
|
+
const ownerToken = typeof req.body?.owner_token === "string" ? req.body.owner_token : undefined;
|
|
41
|
+
const refreshToken = typeof req.body?.refresh_token === "string" ? req.body.refresh_token : undefined;
|
|
42
|
+
if ((ownerToken ? 1 : 0) + (refreshToken ? 1 : 0) !== 1) {
|
|
43
|
+
res.status(400).json({ error: "invalid_request" });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const tokens = ownerToken
|
|
47
|
+
? cliAuthenticationProvider.issueCliTokens(ownerToken)
|
|
48
|
+
: cliAuthenticationProvider.exchangeCliRefreshToken(refreshToken);
|
|
49
|
+
if (!tokens) {
|
|
50
|
+
res.status(401).json({ error: "invalid_grant" });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
res.setHeader("Cache-Control", "no-store");
|
|
54
|
+
res.setHeader("Pragma", "no-cache");
|
|
55
|
+
res.status(200).json({ ...tokens, ...(instanceId ? { instance_id: instanceId } : {}) });
|
|
56
|
+
});
|
|
57
|
+
}
|
|
38
58
|
router.use("/authorize", authorizationHandler({ provider }));
|
|
39
59
|
router.use("/token", tokenHandler({ provider }));
|
|
40
60
|
if (provider.clientsStore.registerClient) {
|
package/dist/oauth-provider.js
CHANGED
|
@@ -3,6 +3,7 @@ import { AccessDeniedError, InvalidGrantError, InvalidRequestError, InvalidToken
|
|
|
3
3
|
import { checkResourceAllowed, resourceUrlFromServerUrl } from "@modelcontextprotocol/sdk/shared/auth-utils.js";
|
|
4
4
|
import { SqliteOAuthClientsStore, SqliteOAuthStore } from "./oauth-store.js";
|
|
5
5
|
const CODE_TTL_MS = 5 * 60 * 1000;
|
|
6
|
+
const CLI_CLIENT_ID = "forgerelay-cli";
|
|
6
7
|
function randomToken() {
|
|
7
8
|
return randomBytes(32).toString("base64url");
|
|
8
9
|
}
|
|
@@ -85,6 +86,15 @@ export class SingleUserOAuthProvider {
|
|
|
85
86
|
this.config = config;
|
|
86
87
|
this.resourceServerUrl = resourceUrlFromServerUrl(resourceServerUrl);
|
|
87
88
|
this.oauthStore = new SqliteOAuthStore(stateDir);
|
|
89
|
+
this.oauthStore.ensureClient({
|
|
90
|
+
client_id: CLI_CLIENT_ID,
|
|
91
|
+
client_id_issued_at: Math.floor(Date.now() / 1000),
|
|
92
|
+
client_name: "ForgeRelay CLI",
|
|
93
|
+
redirect_uris: [],
|
|
94
|
+
token_endpoint_auth_method: "none",
|
|
95
|
+
grant_types: ["refresh_token"],
|
|
96
|
+
response_types: [],
|
|
97
|
+
});
|
|
88
98
|
this.clientsStore = new SqliteOAuthClientsStore(this.oauthStore, config.allowedRedirectHosts);
|
|
89
99
|
}
|
|
90
100
|
async authorize(client, params, res) {
|
|
@@ -145,11 +155,10 @@ export class SingleUserOAuthProvider {
|
|
|
145
155
|
return this.issueTokens(client.client_id, record.params.scopes ?? this.config.scopes, record.params.resource);
|
|
146
156
|
}
|
|
147
157
|
async exchangeRefreshToken(client, refreshToken, scopes, resource) {
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
if (!record || record.clientId !== client.client_id || record.expiresAt < Math.floor(Date.now() / 1000)) {
|
|
158
|
+
const refresh = this.validRefreshToken(refreshToken, client.client_id);
|
|
159
|
+
if (!refresh)
|
|
151
160
|
throw new InvalidGrantError("Invalid refresh token");
|
|
152
|
-
}
|
|
161
|
+
const { refreshTokenHash, record } = refresh;
|
|
153
162
|
if (resource && !checkResourceAllowed({ requestedResource: resource, configuredResource: this.resourceServerUrl })) {
|
|
154
163
|
throw new InvalidGrantError("Invalid resource");
|
|
155
164
|
}
|
|
@@ -159,6 +168,17 @@ export class SingleUserOAuthProvider {
|
|
|
159
168
|
}
|
|
160
169
|
return this.issueTokens(client.client_id, requestedScopes, resource ?? (record.resource ? new URL(record.resource) : undefined), refreshTokenHash);
|
|
161
170
|
}
|
|
171
|
+
issueCliTokens(ownerToken) {
|
|
172
|
+
if (!safeEquals(ownerToken, this.config.ownerToken))
|
|
173
|
+
return undefined;
|
|
174
|
+
return this.issueTokens(CLI_CLIENT_ID, this.config.scopes, this.resourceServerUrl);
|
|
175
|
+
}
|
|
176
|
+
exchangeCliRefreshToken(refreshToken) {
|
|
177
|
+
const refresh = this.validRefreshToken(refreshToken, CLI_CLIENT_ID);
|
|
178
|
+
if (!refresh)
|
|
179
|
+
return undefined;
|
|
180
|
+
return this.issueTokens(CLI_CLIENT_ID, refresh.record.scopes, refresh.record.resource ? new URL(refresh.record.resource) : this.resourceServerUrl, refresh.refreshTokenHash);
|
|
181
|
+
}
|
|
162
182
|
async verifyAccessToken(token) {
|
|
163
183
|
const record = this.oauthStore.getAccessToken(hashToken(token));
|
|
164
184
|
if (!record || record.expiresAt < Math.floor(Date.now() / 1000)) {
|
|
@@ -181,6 +201,14 @@ export class SingleUserOAuthProvider {
|
|
|
181
201
|
this.codes.clear();
|
|
182
202
|
this.oauthStore.close();
|
|
183
203
|
}
|
|
204
|
+
validRefreshToken(refreshToken, clientId) {
|
|
205
|
+
const refreshTokenHash = hashToken(refreshToken);
|
|
206
|
+
const record = this.oauthStore.getRefreshToken(refreshTokenHash);
|
|
207
|
+
if (!record || record.clientId !== clientId || record.expiresAt < Math.floor(Date.now() / 1000)) {
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
return { refreshTokenHash, record };
|
|
211
|
+
}
|
|
184
212
|
pruneExpiredAuthorizationCodes(nowMs = Date.now()) {
|
|
185
213
|
for (const [code, record] of this.codes) {
|
|
186
214
|
if (record.expiresAtMs < nowMs)
|
package/dist/oauth-store.js
CHANGED
|
@@ -25,6 +25,15 @@ export class SqliteOAuthStore {
|
|
|
25
25
|
.get(clientId);
|
|
26
26
|
return row ? JSON.parse(row.client_json) : undefined;
|
|
27
27
|
}
|
|
28
|
+
ensureClient(client) {
|
|
29
|
+
const existing = this.getClient(client.client_id);
|
|
30
|
+
if (existing)
|
|
31
|
+
return existing;
|
|
32
|
+
this.database.sqlite
|
|
33
|
+
.prepare("insert into oauth_clients (client_id, client_json, issued_at) values (?, ?, ?)")
|
|
34
|
+
.run(client.client_id, JSON.stringify(client), client.client_id_issued_at);
|
|
35
|
+
return client;
|
|
36
|
+
}
|
|
28
37
|
registerClient(client, allowedRedirectHosts) {
|
|
29
38
|
if (!client.redirect_uris.every((uri) => redirectHostAllowed(String(uri), allowedRedirectHosts))) {
|
|
30
39
|
throw new InvalidRequestError("Client redirect_uri is not allowed for this ForgeRelay server");
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
3
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
4
|
+
import { StreamableHTTPClientTransport, StreamableHTTPError, } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
5
|
+
import { publicEndpointUrl } from "./oauth/public-url.js";
|
|
6
|
+
const REMOTE_AUTH_TIMEOUT_MS = 15_000;
|
|
7
|
+
const packageVersion = readForgeRelayVersion();
|
|
8
|
+
export function normalizeRemoteServiceTarget(value) {
|
|
9
|
+
const trimmed = value.trim();
|
|
10
|
+
if (!trimmed)
|
|
11
|
+
throw new Error("Missing remote service target.");
|
|
12
|
+
const url = new URL(trimmed.includes("://") ? trimmed : `http://${trimmed}`);
|
|
13
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
14
|
+
throw new Error(`Unsupported remote service protocol: ${url.protocol}`);
|
|
15
|
+
}
|
|
16
|
+
if (url.username || url.password) {
|
|
17
|
+
throw new Error("Remote service target must not contain credentials.");
|
|
18
|
+
}
|
|
19
|
+
if (url.search || url.hash) {
|
|
20
|
+
throw new Error("Remote service target must not contain a query or fragment.");
|
|
21
|
+
}
|
|
22
|
+
url.pathname = url.pathname.replace(/\/+$/, "");
|
|
23
|
+
return url.toString().replace(/\/$/, "");
|
|
24
|
+
}
|
|
25
|
+
export function defaultRemoteAlias(target) {
|
|
26
|
+
const hostname = new URL(target).hostname;
|
|
27
|
+
return hostname.replace(/^\[|\]$/g, "");
|
|
28
|
+
}
|
|
29
|
+
export async function authenticateRemote(targetInput, ownerToken) {
|
|
30
|
+
const target = normalizeRemoteServiceTarget(targetInput);
|
|
31
|
+
return exchangeCliCredential(target, { owner_token: ownerToken });
|
|
32
|
+
}
|
|
33
|
+
export async function refreshRemoteAuthentication(remote, endpointInput = remote.target) {
|
|
34
|
+
const refreshed = await exchangeCliCredential(normalizeRemoteServiceTarget(endpointInput), { refresh_token: remote.refreshToken });
|
|
35
|
+
if (refreshed.instanceId !== remote.instanceId) {
|
|
36
|
+
throw new Error(`Remote instance changed from ${remote.instanceId} to ${refreshed.instanceId}; refusing to update stored credentials.`);
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
...remote,
|
|
40
|
+
accessToken: refreshed.accessToken,
|
|
41
|
+
refreshToken: refreshed.refreshToken,
|
|
42
|
+
accessTokenExpiresAt: refreshed.accessTokenExpiresAt,
|
|
43
|
+
scope: refreshed.scope,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export function isRemoteMcpUnauthorized(error) {
|
|
47
|
+
return error instanceof UnauthorizedError ||
|
|
48
|
+
(error instanceof StreamableHTTPError && error.code === 401);
|
|
49
|
+
}
|
|
50
|
+
export async function withRemoteMcpClient(remote, endpointInput, operation) {
|
|
51
|
+
const endpoint = normalizeRemoteServiceTarget(endpointInput);
|
|
52
|
+
const client = new Client({ name: "forgerelay-cli", version: packageVersion });
|
|
53
|
+
const transport = new StreamableHTTPClientTransport(publicEndpointUrl(endpoint, "mcp"), {
|
|
54
|
+
requestInit: {
|
|
55
|
+
headers: { Authorization: `Bearer ${remote.accessToken}` },
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
try {
|
|
59
|
+
await client.connect(transport);
|
|
60
|
+
return await operation(client);
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
await client.close().catch(() => undefined);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export async function verifyRemoteMcp(remote, endpointInput = remote.target) {
|
|
67
|
+
await withRemoteMcpClient(remote, endpointInput, async () => undefined);
|
|
68
|
+
}
|
|
69
|
+
async function exchangeCliCredential(target, credential) {
|
|
70
|
+
const response = await fetch(publicEndpointUrl(target, "auth/cli"), {
|
|
71
|
+
method: "POST",
|
|
72
|
+
headers: { "content-type": "application/json" },
|
|
73
|
+
body: JSON.stringify(credential),
|
|
74
|
+
signal: AbortSignal.timeout(REMOTE_AUTH_TIMEOUT_MS),
|
|
75
|
+
});
|
|
76
|
+
if (!response.ok) {
|
|
77
|
+
let reason = response.statusText || `HTTP ${response.status}`;
|
|
78
|
+
try {
|
|
79
|
+
const body = await response.json();
|
|
80
|
+
if (typeof body.error === "string" && body.error)
|
|
81
|
+
reason = body.error;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// Keep the status-derived error without exposing the submitted secret.
|
|
85
|
+
}
|
|
86
|
+
throw new Error(`Remote authentication failed: ${reason}`);
|
|
87
|
+
}
|
|
88
|
+
const body = await response.json();
|
|
89
|
+
if (typeof body.instance_id !== "string" || !body.instance_id.trim()) {
|
|
90
|
+
throw new Error("Remote authentication response did not include an instance id.");
|
|
91
|
+
}
|
|
92
|
+
if (typeof body.access_token !== "string" || typeof body.refresh_token !== "string") {
|
|
93
|
+
throw new Error("Remote authentication response did not include the required tokens.");
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
instanceId: body.instance_id,
|
|
97
|
+
target,
|
|
98
|
+
accessToken: body.access_token,
|
|
99
|
+
refreshToken: body.refresh_token,
|
|
100
|
+
accessTokenExpiresAt: Math.floor(Date.now() / 1000) + (body.expires_in ?? 0),
|
|
101
|
+
...(body.scope ? { scope: body.scope } : {}),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function readForgeRelayVersion() {
|
|
105
|
+
const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
106
|
+
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
|
|
107
|
+
throw new Error("Unable to read ForgeRelay package version.");
|
|
108
|
+
}
|
|
109
|
+
return packageJson.version;
|
|
110
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { once } from "node:events";
|
|
3
|
+
import { createConnection, createServer } from "node:net";
|
|
4
|
+
const SSH_START_TIMEOUT_MS = 15_000;
|
|
5
|
+
const SSH_COMMAND_TIMEOUT_MS = 15_000;
|
|
6
|
+
const SSH_STOP_TIMEOUT_MS = 2_000;
|
|
7
|
+
export function parseSshRoute(value) {
|
|
8
|
+
const route = value.split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
9
|
+
if (route.length === 0)
|
|
10
|
+
throw new Error("SSH route must contain at least one target.");
|
|
11
|
+
for (const entry of route) {
|
|
12
|
+
if (entry.startsWith("-") || /\s/.test(entry)) {
|
|
13
|
+
throw new Error(`Invalid SSH route target: ${entry}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return route;
|
|
17
|
+
}
|
|
18
|
+
export function defaultSshRouteAlias(route) {
|
|
19
|
+
const finalTarget = route.at(-1);
|
|
20
|
+
if (!finalTarget)
|
|
21
|
+
throw new Error("SSH route must contain a final target.");
|
|
22
|
+
const at = finalTarget.lastIndexOf("@");
|
|
23
|
+
return (at >= 0 ? finalTarget.slice(at + 1) : finalTarget).trim();
|
|
24
|
+
}
|
|
25
|
+
export async function readRemoteOwnerToken(sshRoute) {
|
|
26
|
+
const { prefix, target } = sshDestinationArgs(sshRoute);
|
|
27
|
+
const result = await runSshCommand([
|
|
28
|
+
...prefix,
|
|
29
|
+
target,
|
|
30
|
+
"forgerelay",
|
|
31
|
+
"auth",
|
|
32
|
+
"__owner-token",
|
|
33
|
+
]);
|
|
34
|
+
const token = result.stdout.trim();
|
|
35
|
+
if (!token)
|
|
36
|
+
throw new Error("SSH owner-token command returned an empty token.");
|
|
37
|
+
return token;
|
|
38
|
+
}
|
|
39
|
+
export async function withRemoteServiceEndpoint(target, sshRoute, operation) {
|
|
40
|
+
if (!sshRoute)
|
|
41
|
+
return operation(target);
|
|
42
|
+
const url = new URL(target);
|
|
43
|
+
if (url.protocol === "https:") {
|
|
44
|
+
throw new Error("SSH-routed HTTPS service targets are not supported because loopback forwarding breaks TLS hostname verification; use the remote ForgeRelay HTTP loopback endpoint through SSH or direct HTTPS.");
|
|
45
|
+
}
|
|
46
|
+
const remotePort = url.port || "80";
|
|
47
|
+
const localPort = await allocateLoopbackPort();
|
|
48
|
+
const { prefix, target: sshTarget } = sshDestinationArgs(sshRoute);
|
|
49
|
+
const forwardSpec = `127.0.0.1:${localPort}:${url.hostname}:${remotePort}`;
|
|
50
|
+
const tunnel = spawn("ssh", [
|
|
51
|
+
...prefix,
|
|
52
|
+
"-v",
|
|
53
|
+
"-N",
|
|
54
|
+
"-T",
|
|
55
|
+
"-o",
|
|
56
|
+
"ExitOnForwardFailure=yes",
|
|
57
|
+
"-L",
|
|
58
|
+
forwardSpec,
|
|
59
|
+
sshTarget,
|
|
60
|
+
], {
|
|
61
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
62
|
+
windowsHide: true,
|
|
63
|
+
});
|
|
64
|
+
let forwardingReady = false;
|
|
65
|
+
const readyMarker = `Local forwarding listening on 127.0.0.1 port ${localPort}`;
|
|
66
|
+
const stderr = collectStream(tunnel.stderr, (value) => {
|
|
67
|
+
if (value.includes(readyMarker))
|
|
68
|
+
forwardingReady = true;
|
|
69
|
+
});
|
|
70
|
+
let spawnError;
|
|
71
|
+
tunnel.once("error", (error) => {
|
|
72
|
+
spawnError = error;
|
|
73
|
+
});
|
|
74
|
+
try {
|
|
75
|
+
await waitForLoopbackPort(tunnel, localPort, stderr, () => spawnError, () => forwardingReady);
|
|
76
|
+
const mapped = new URL(url);
|
|
77
|
+
mapped.hostname = "127.0.0.1";
|
|
78
|
+
mapped.port = String(localPort);
|
|
79
|
+
try {
|
|
80
|
+
return await operation(mapped.toString().replace(/\/$/, ""));
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
84
|
+
throw new Error(`Remote service request through SSH tunnel failed: ${message}`, { cause: error });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
await stopTunnel(tunnel);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function sshDestinationArgs(sshRoute) {
|
|
92
|
+
if (sshRoute.length === 0)
|
|
93
|
+
throw new Error("SSH route must contain a final target.");
|
|
94
|
+
const target = sshRoute[sshRoute.length - 1];
|
|
95
|
+
const jumps = sshRoute.slice(0, -1);
|
|
96
|
+
return {
|
|
97
|
+
prefix: jumps.length > 0 ? ["-J", jumps.join(",")] : [],
|
|
98
|
+
target,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
async function allocateLoopbackPort() {
|
|
102
|
+
const server = createServer();
|
|
103
|
+
server.listen(0, "127.0.0.1");
|
|
104
|
+
await once(server, "listening");
|
|
105
|
+
const address = server.address();
|
|
106
|
+
if (!address || typeof address === "string") {
|
|
107
|
+
server.close();
|
|
108
|
+
throw new Error("Unable to allocate a local SSH forwarding port.");
|
|
109
|
+
}
|
|
110
|
+
const port = address.port;
|
|
111
|
+
await new Promise((resolve, reject) => {
|
|
112
|
+
server.close((error) => error ? reject(error) : resolve());
|
|
113
|
+
});
|
|
114
|
+
return port;
|
|
115
|
+
}
|
|
116
|
+
async function waitForLoopbackPort(process, port, stderr, spawnError, forwardingReady) {
|
|
117
|
+
const deadline = Date.now() + SSH_START_TIMEOUT_MS;
|
|
118
|
+
while (Date.now() < deadline) {
|
|
119
|
+
const error = spawnError();
|
|
120
|
+
if (error)
|
|
121
|
+
throw new Error(`Unable to start SSH tunnel: ${error.message}`);
|
|
122
|
+
if (process.exitCode !== null) {
|
|
123
|
+
throw new Error(formatSshFailure("SSH tunnel exited before forwarding was ready", process.exitCode, stderr()));
|
|
124
|
+
}
|
|
125
|
+
if (forwardingReady() && await canConnect(port))
|
|
126
|
+
return;
|
|
127
|
+
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
128
|
+
}
|
|
129
|
+
throw new Error("SSH tunnel did not become ready before the connection timeout.");
|
|
130
|
+
}
|
|
131
|
+
function canConnect(port) {
|
|
132
|
+
return new Promise((resolve) => {
|
|
133
|
+
const socket = createConnection({ host: "127.0.0.1", port });
|
|
134
|
+
socket.once("connect", () => {
|
|
135
|
+
socket.destroy();
|
|
136
|
+
resolve(true);
|
|
137
|
+
});
|
|
138
|
+
socket.once("error", () => resolve(false));
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
async function runSshCommand(args) {
|
|
142
|
+
const child = spawn("ssh", args, {
|
|
143
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
144
|
+
windowsHide: true,
|
|
145
|
+
});
|
|
146
|
+
const stdout = collectStream(child.stdout);
|
|
147
|
+
const stderr = collectStream(child.stderr);
|
|
148
|
+
const timeout = setTimeout(() => child.kill("SIGKILL"), SSH_COMMAND_TIMEOUT_MS);
|
|
149
|
+
let code;
|
|
150
|
+
let signal;
|
|
151
|
+
try {
|
|
152
|
+
[code, signal] = await once(child, "close");
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
156
|
+
throw new Error(`Unable to start SSH command: ${message}`);
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
clearTimeout(timeout);
|
|
160
|
+
}
|
|
161
|
+
if (code !== 0) {
|
|
162
|
+
const suffix = signal ? ` (${signal})` : "";
|
|
163
|
+
throw new Error(formatSshFailure(`SSH command failed${suffix}`, code, stderr()));
|
|
164
|
+
}
|
|
165
|
+
return { stdout: stdout(), stderr: stderr() };
|
|
166
|
+
}
|
|
167
|
+
function collectStream(stream, observe) {
|
|
168
|
+
let value = "";
|
|
169
|
+
stream.setEncoding?.("utf8");
|
|
170
|
+
stream.on("data", (chunk) => {
|
|
171
|
+
value += String(chunk);
|
|
172
|
+
observe?.(value);
|
|
173
|
+
if (value.length > 16_384)
|
|
174
|
+
value = value.slice(-16_384);
|
|
175
|
+
});
|
|
176
|
+
return () => value;
|
|
177
|
+
}
|
|
178
|
+
async function stopTunnel(process) {
|
|
179
|
+
if (process.exitCode !== null)
|
|
180
|
+
return;
|
|
181
|
+
const closed = once(process, "close").then(() => true).catch(() => true);
|
|
182
|
+
process.kill("SIGTERM");
|
|
183
|
+
const timedOut = new Promise((resolve) => setTimeout(() => resolve(false), SSH_STOP_TIMEOUT_MS));
|
|
184
|
+
if (await Promise.race([closed, timedOut]))
|
|
185
|
+
return;
|
|
186
|
+
if (process.exitCode !== null)
|
|
187
|
+
return;
|
|
188
|
+
const killed = once(process, "close").then(() => undefined).catch(() => undefined);
|
|
189
|
+
process.kill("SIGKILL");
|
|
190
|
+
const killTimedOut = new Promise((resolve) => setTimeout(resolve, SSH_STOP_TIMEOUT_MS));
|
|
191
|
+
await Promise.race([killed, killTimedOut]);
|
|
192
|
+
}
|
|
193
|
+
function formatSshFailure(prefix, code, stderr) {
|
|
194
|
+
const detail = stderr.trim();
|
|
195
|
+
return `${prefix} (exit ${code ?? "unknown"})${detail ? `: ${detail}` : ""}`;
|
|
196
|
+
}
|