@xns-cloud/relayer-mcp 0.3.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/LICENSE +201 -0
- package/NOTICE +7 -0
- package/README.md +72 -0
- package/package.json +53 -0
- package/src/index.js +35 -0
- package/src/lib/dockerUtil.js +68 -0
- package/src/lib/ensureToken.js +77 -0
- package/src/lib/httpClient.js +45 -0
- package/src/lib/oidcAuth.js +256 -0
- package/src/lib/pollUntil.js +31 -0
- package/src/lib/s3Client.js +73 -0
- package/src/lib/tokenState.js +24 -0
- package/src/templates/docker-compose.yml +27 -0
- package/src/tools/checkClaimStatus.js +154 -0
- package/src/tools/checkEmailVerified.js +165 -0
- package/src/tools/checkPrerequisites.js +123 -0
- package/src/tools/checkRelayerHealth.js +144 -0
- package/src/tools/configureVpd.js +145 -0
- package/src/tools/getHostTags.js +104 -0
- package/src/tools/installRelayer.js +102 -0
- package/src/tools/registerAccount.js +123 -0
- package/src/tools/setupCliCredentials.js +117 -0
- package/src/tools/startClaim.js +99 -0
- package/src/tools/verifyStorage.js +95 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const http = require('http');
|
|
5
|
+
const { createHttpClient } = require('./httpClient');
|
|
6
|
+
|
|
7
|
+
const DEFAULT_KEYCLOAK_URL = 'https://auth.xns.tech/auth';
|
|
8
|
+
const DEFAULT_REALM = 'scprime';
|
|
9
|
+
const DEFAULT_CLIENT_ID = 'relayer-native';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* OIDC Authorization Code + PKCE (S256) token acquisition.
|
|
13
|
+
*
|
|
14
|
+
* Performs a browser-based OIDC sign-in flow:
|
|
15
|
+
* 1. Generate PKCE verifier + S256 challenge
|
|
16
|
+
* 2. Start a 127.0.0.1 loopback HTTP listener on an ephemeral port
|
|
17
|
+
* 3. Build the authorize URL and open the browser (or return the URL)
|
|
18
|
+
* 4. Capture the authorization code on the redirect
|
|
19
|
+
* 5. Exchange code for access_token + refresh_token
|
|
20
|
+
*
|
|
21
|
+
* Interface is kept clean for future extraction to @xns-cloud/relayer-auth (CLI reuse).
|
|
22
|
+
*
|
|
23
|
+
* @param {object} opts
|
|
24
|
+
* @param {string} [opts.clientId] - Keycloak public client ID
|
|
25
|
+
* @param {string} [opts.realm] - Keycloak realm
|
|
26
|
+
* @param {string} [opts.keycloakUrl] - Base Keycloak URL
|
|
27
|
+
* @param {string[]} [opts.scopes] - OIDC scopes
|
|
28
|
+
* @param {object} [opts.httpClient] - Injected HTTP client (testing)
|
|
29
|
+
* @param {function} [opts.openBrowser] - Injected browser opener (testing)
|
|
30
|
+
* @param {function} [opts.createServer] - Injected http.createServer (testing)
|
|
31
|
+
* @returns {Promise<{access_token: string, expires_at: number, refresh_token: string, authorize_url: string}>}
|
|
32
|
+
*/
|
|
33
|
+
async function acquireToken(opts = {}) {
|
|
34
|
+
const clientId = opts.clientId || DEFAULT_CLIENT_ID;
|
|
35
|
+
const realm = opts.realm || DEFAULT_REALM;
|
|
36
|
+
const keycloakUrl = opts.keycloakUrl || DEFAULT_KEYCLOAK_URL;
|
|
37
|
+
const scopes = opts.scopes || ['openid'];
|
|
38
|
+
const httpClient = opts.httpClient || createHttpClient({ timeout: 15000 });
|
|
39
|
+
const openBrowser = opts.openBrowser || defaultOpenBrowser;
|
|
40
|
+
const _createServer = opts.createServer || http.createServer.bind(http);
|
|
41
|
+
|
|
42
|
+
// 1. PKCE verifier + S256 challenge
|
|
43
|
+
const verifier = generateVerifier();
|
|
44
|
+
const challenge = generateChallenge(verifier);
|
|
45
|
+
|
|
46
|
+
// 2. Loopback listener on ephemeral port (R7: platform-agnostic bind)
|
|
47
|
+
const { code, redirectUri } = await captureAuthCode({
|
|
48
|
+
clientId,
|
|
49
|
+
realm,
|
|
50
|
+
keycloakUrl,
|
|
51
|
+
scopes,
|
|
52
|
+
verifier,
|
|
53
|
+
challenge,
|
|
54
|
+
openBrowser,
|
|
55
|
+
createServer: _createServer,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// 5. Exchange code for tokens
|
|
59
|
+
const tokenUrl = `${keycloakUrl}/realms/${realm}/protocol/openid-connect/token`;
|
|
60
|
+
const body = new URLSearchParams({
|
|
61
|
+
grant_type: 'authorization_code',
|
|
62
|
+
client_id: clientId,
|
|
63
|
+
code,
|
|
64
|
+
redirect_uri: redirectUri,
|
|
65
|
+
code_verifier: verifier,
|
|
66
|
+
}).toString();
|
|
67
|
+
|
|
68
|
+
const { status, data } = await httpClient.post(tokenUrl, body, {
|
|
69
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (status !== 200) {
|
|
73
|
+
throw new Error(`Token exchange failed (HTTP ${status}): ${JSON.stringify(data)}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
access_token: data.access_token,
|
|
78
|
+
refresh_token: data.refresh_token,
|
|
79
|
+
expires_at: Date.now() + (data.expires_in * 1000),
|
|
80
|
+
authorize_url: undefined, // already consumed
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Refresh an access token using a refresh_token.
|
|
86
|
+
*
|
|
87
|
+
* @param {object} opts
|
|
88
|
+
* @param {string} opts.refreshToken
|
|
89
|
+
* @param {string} [opts.clientId]
|
|
90
|
+
* @param {string} [opts.realm]
|
|
91
|
+
* @param {string} [opts.keycloakUrl]
|
|
92
|
+
* @param {object} [opts.httpClient]
|
|
93
|
+
* @returns {Promise<{access_token: string, expires_at: number, refresh_token: string}>}
|
|
94
|
+
*/
|
|
95
|
+
async function refreshToken(opts = {}) {
|
|
96
|
+
const clientId = opts.clientId || DEFAULT_CLIENT_ID;
|
|
97
|
+
const realm = opts.realm || DEFAULT_REALM;
|
|
98
|
+
const keycloakUrl = opts.keycloakUrl || DEFAULT_KEYCLOAK_URL;
|
|
99
|
+
const httpClient = opts.httpClient || createHttpClient({ timeout: 15000 });
|
|
100
|
+
|
|
101
|
+
const tokenUrl = `${keycloakUrl}/realms/${realm}/protocol/openid-connect/token`;
|
|
102
|
+
const body = new URLSearchParams({
|
|
103
|
+
grant_type: 'refresh_token',
|
|
104
|
+
client_id: clientId,
|
|
105
|
+
refresh_token: opts.refreshToken,
|
|
106
|
+
}).toString();
|
|
107
|
+
|
|
108
|
+
const { status, data } = await httpClient.post(tokenUrl, body, {
|
|
109
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (status !== 200) {
|
|
113
|
+
throw new Error(`Token refresh failed (HTTP ${status}): ${JSON.stringify(data)}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
access_token: data.access_token,
|
|
118
|
+
refresh_token: data.refresh_token,
|
|
119
|
+
expires_at: Date.now() + (data.expires_in * 1000),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Generate a PKCE code verifier (43-128 chars, URL-safe base64).
|
|
125
|
+
* @returns {string}
|
|
126
|
+
*/
|
|
127
|
+
function generateVerifier() {
|
|
128
|
+
return crypto.randomBytes(32).toString('base64url');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Generate a PKCE S256 challenge from a verifier.
|
|
133
|
+
* @param {string} verifier
|
|
134
|
+
* @returns {string}
|
|
135
|
+
*/
|
|
136
|
+
function generateChallenge(verifier) {
|
|
137
|
+
return crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Start a loopback HTTP server, open the browser to the authorize URL,
|
|
142
|
+
* and wait for the authorization code redirect.
|
|
143
|
+
*
|
|
144
|
+
* @param {object} opts
|
|
145
|
+
* @returns {Promise<{code: string, redirectUri: string}>}
|
|
146
|
+
*/
|
|
147
|
+
function captureAuthCode(opts) {
|
|
148
|
+
const { clientId, realm, keycloakUrl, scopes, verifier, challenge, openBrowser, createServer } = opts;
|
|
149
|
+
|
|
150
|
+
return new Promise((resolve, reject) => {
|
|
151
|
+
let timeoutHandle;
|
|
152
|
+
let capturedPort;
|
|
153
|
+
let expectedState;
|
|
154
|
+
|
|
155
|
+
const srv = createServer((req, res) => {
|
|
156
|
+
const url = new URL(req.url, `http://127.0.0.1`);
|
|
157
|
+
|
|
158
|
+
const error = url.searchParams.get('error');
|
|
159
|
+
if (error) {
|
|
160
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
161
|
+
res.end('<html><body><h1>Authentication failed</h1><p>You can close this window.</p></body></html>');
|
|
162
|
+
clearTimeout(timeoutHandle);
|
|
163
|
+
srv.close();
|
|
164
|
+
return reject(new Error(`OIDC error: ${error} — ${url.searchParams.get('error_description') || ''}`));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const code = url.searchParams.get('code');
|
|
168
|
+
if (!code) {
|
|
169
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
170
|
+
res.end('<html><body><h1>Missing authorization code</h1></body></html>');
|
|
171
|
+
return; // Don't close server — wait for the real redirect
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// R5-STATE-1: Validate OIDC state parameter to prevent CSRF
|
|
175
|
+
const returnedState = url.searchParams.get('state');
|
|
176
|
+
if (returnedState !== expectedState) {
|
|
177
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
178
|
+
res.end('<html><body><h1>Invalid state parameter</h1><p>Possible CSRF attack. Please try again.</p></body></html>');
|
|
179
|
+
clearTimeout(timeoutHandle);
|
|
180
|
+
srv.close();
|
|
181
|
+
return reject(new Error('OIDC callback state mismatch — possible CSRF. Please try again.'));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
185
|
+
res.end('<html><body><h1>Authentication successful</h1><p>You can close this window and return to the agent.</p></body></html>');
|
|
186
|
+
clearTimeout(timeoutHandle);
|
|
187
|
+
|
|
188
|
+
// Capture redirect URI from the port stored in listen callback
|
|
189
|
+
// BEFORE calling srv.close() — after close, srv.address() may return null
|
|
190
|
+
const redirectUri = `http://127.0.0.1:${capturedPort}/callback`;
|
|
191
|
+
srv.close();
|
|
192
|
+
resolve({ code, redirectUri });
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Bind to 127.0.0.1:0 (ephemeral port) — R7 platform-agnostic
|
|
196
|
+
srv.listen(0, '127.0.0.1', () => {
|
|
197
|
+
capturedPort = srv.address().port;
|
|
198
|
+
const redirectUri = `http://127.0.0.1:${capturedPort}/callback`;
|
|
199
|
+
expectedState = crypto.randomBytes(16).toString('hex');
|
|
200
|
+
|
|
201
|
+
const params = new URLSearchParams({
|
|
202
|
+
response_type: 'code',
|
|
203
|
+
client_id: clientId,
|
|
204
|
+
redirect_uri: redirectUri,
|
|
205
|
+
scope: scopes.join(' '),
|
|
206
|
+
code_challenge: challenge,
|
|
207
|
+
code_challenge_method: 'S256',
|
|
208
|
+
state: expectedState,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const authorizeUrl = `${keycloakUrl}/realms/${realm}/protocol/openid-connect/auth?${params}`;
|
|
212
|
+
|
|
213
|
+
// Open browser or return URL for the agent to present
|
|
214
|
+
openBrowser(authorizeUrl).catch(() => {
|
|
215
|
+
// If browser open fails, the authorize_url is still available
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// Timeout: 5 minutes for user to complete browser sign-in
|
|
220
|
+
timeoutHandle = setTimeout(() => {
|
|
221
|
+
srv.close();
|
|
222
|
+
reject(new Error('OIDC sign-in timed out after 5 minutes. Please try again.'));
|
|
223
|
+
}, 300000);
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Default browser opener — platform-aware.
|
|
229
|
+
* Uses execFile (not exec) to avoid shell interpolation of the URL.
|
|
230
|
+
* @param {string} url
|
|
231
|
+
* @returns {Promise<void>}
|
|
232
|
+
*/
|
|
233
|
+
function defaultOpenBrowser(url) {
|
|
234
|
+
const { execFile } = require('child_process');
|
|
235
|
+
return new Promise((resolve, reject) => {
|
|
236
|
+
const platform = process.platform;
|
|
237
|
+
let cmd, args;
|
|
238
|
+
if (platform === 'darwin') {
|
|
239
|
+
cmd = 'open';
|
|
240
|
+
args = [url];
|
|
241
|
+
} else if (platform === 'win32') {
|
|
242
|
+
cmd = 'cmd';
|
|
243
|
+
args = ['/c', 'start', '', url];
|
|
244
|
+
} else {
|
|
245
|
+
cmd = 'xdg-open';
|
|
246
|
+
args = [url];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
execFile(cmd, args, (err) => {
|
|
250
|
+
if (err) return reject(err);
|
|
251
|
+
resolve();
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
module.exports = { acquireToken, refreshToken, generateVerifier, generateChallenge };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Poll a check function at a fixed interval until it resolves truthy
|
|
5
|
+
* or the timeout elapses.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} opts
|
|
8
|
+
* @param {function(): Promise<*>} opts.fn - Async check function. Return truthy to stop.
|
|
9
|
+
* @param {number} opts.intervalMs - Poll interval in ms
|
|
10
|
+
* @param {number} opts.timeoutMs - Total timeout in ms
|
|
11
|
+
* @param {function} [opts.sleep] - Injectable sleep (testing). Default: real setTimeout.
|
|
12
|
+
* @returns {Promise<{result: *, timedOut: boolean}>}
|
|
13
|
+
*/
|
|
14
|
+
async function pollUntil({ fn, intervalMs, timeoutMs, sleep }) {
|
|
15
|
+
const _sleep = sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
16
|
+
const deadline = Date.now() + timeoutMs;
|
|
17
|
+
|
|
18
|
+
while (Date.now() < deadline) {
|
|
19
|
+
const result = await fn();
|
|
20
|
+
if (result) {
|
|
21
|
+
return { result, timedOut: false };
|
|
22
|
+
}
|
|
23
|
+
const remaining = deadline - Date.now();
|
|
24
|
+
if (remaining <= 0) break;
|
|
25
|
+
await _sleep(Math.min(intervalMs, remaining));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return { result: null, timedOut: true };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = { pollUntil };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
S3Client,
|
|
5
|
+
CreateBucketCommand,
|
|
6
|
+
PutObjectCommand,
|
|
7
|
+
GetObjectCommand,
|
|
8
|
+
} = require('@aws-sdk/client-s3');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* S3 client for verify_storage (Tool 10).
|
|
12
|
+
* Targets localhost:9000 plain HTTP with forcePathStyle.
|
|
13
|
+
*
|
|
14
|
+
* @param {object} [options]
|
|
15
|
+
* @param {string} [options.endpoint] - S3 endpoint (default: http://localhost:9000)
|
|
16
|
+
* @param {string} [options.region] - AWS region (default: us-east-1)
|
|
17
|
+
* @param {string} options.accessKeyId
|
|
18
|
+
* @param {string} options.secretAccessKey
|
|
19
|
+
* @param {object} [options.s3Client] - Injected S3Client instance (testing)
|
|
20
|
+
*/
|
|
21
|
+
function createS3Client(options = {}) {
|
|
22
|
+
const client = options.s3Client || new S3Client({
|
|
23
|
+
endpoint: options.endpoint || 'http://localhost:9000',
|
|
24
|
+
region: options.region || 'us-east-1',
|
|
25
|
+
forcePathStyle: true,
|
|
26
|
+
credentials: {
|
|
27
|
+
accessKeyId: options.accessKeyId,
|
|
28
|
+
secretAccessKey: options.secretAccessKey,
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
/**
|
|
34
|
+
* @param {string} bucket
|
|
35
|
+
* @returns {Promise<void>}
|
|
36
|
+
*/
|
|
37
|
+
async createBucket(bucket) {
|
|
38
|
+
await client.send(new CreateBucketCommand({ Bucket: bucket }));
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {string} bucket
|
|
43
|
+
* @param {string} key
|
|
44
|
+
* @param {Buffer|string} body
|
|
45
|
+
* @returns {Promise<void>}
|
|
46
|
+
*/
|
|
47
|
+
async putObject(bucket, key, body) {
|
|
48
|
+
await client.send(new PutObjectCommand({
|
|
49
|
+
Bucket: bucket,
|
|
50
|
+
Key: key,
|
|
51
|
+
Body: body,
|
|
52
|
+
}));
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {string} bucket
|
|
57
|
+
* @param {string} key
|
|
58
|
+
* @returns {Promise<string>}
|
|
59
|
+
*/
|
|
60
|
+
async getObject(bucket, key) {
|
|
61
|
+
const resp = await client.send(new GetObjectCommand({
|
|
62
|
+
Bucket: bucket,
|
|
63
|
+
Key: key,
|
|
64
|
+
}));
|
|
65
|
+
return resp.Body.transformToString();
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
/** Expose the raw client for advanced use */
|
|
69
|
+
client,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { createS3Client };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared OIDC token state — single module-level cache shared by
|
|
5
|
+
* getHostTags and configureVpd (and any future authenticated tools).
|
|
6
|
+
*
|
|
7
|
+
* Eliminates duplicate browser sign-ins caused by independent
|
|
8
|
+
* `let tokenState = null` declarations in each tool module.
|
|
9
|
+
*/
|
|
10
|
+
let tokenState = null;
|
|
11
|
+
|
|
12
|
+
function get() {
|
|
13
|
+
return tokenState;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function set(state) {
|
|
17
|
+
tokenState = state;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function clear() {
|
|
21
|
+
tokenState = null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = { get, set, clear };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Bundled by @xns-cloud/relayer-mcp — the documented released XNS Relayer install.
|
|
2
|
+
# Source of truth: https://xns.tech/docs/windows-ui/ (Docker Hub scprime/xns-relayer).
|
|
3
|
+
#
|
|
4
|
+
# Pre-release: pinned to the :beta channel so alpha/beta testers exercise the
|
|
5
|
+
# current Relayer agentically. Flip this one tag to :stable at general release.
|
|
6
|
+
#
|
|
7
|
+
# A first-time user no longer hand-writes this file or the .env — install_relayer
|
|
8
|
+
# writes both. Differences from the hand-written docs compose, by design:
|
|
9
|
+
# - data lives in ./data (relative to this file) instead of a Windows-only
|
|
10
|
+
# C:\XNS-Relayer bind, so the same file works on Windows, macOS, and Linux.
|
|
11
|
+
# - SMB ports 139/445 are intentionally NOT exposed: 445 is held by Windows
|
|
12
|
+
# SMB on consumer hosts and would fail the install. S3 onboarding uses :9000.
|
|
13
|
+
# - pull_policy: always so `docker compose up -d` fetches the current :beta
|
|
14
|
+
# image without a separate `docker compose pull` step.
|
|
15
|
+
services:
|
|
16
|
+
xns:
|
|
17
|
+
container_name: xns-relayer
|
|
18
|
+
image: scprime/xns-relayer:beta
|
|
19
|
+
pull_policy: always
|
|
20
|
+
restart: unless-stopped
|
|
21
|
+
volumes:
|
|
22
|
+
- ./data:/relayer
|
|
23
|
+
ports:
|
|
24
|
+
- ${UI_PORT:-8888}:8888
|
|
25
|
+
- ${MINIO_PORT:-9000}:9000
|
|
26
|
+
env_file:
|
|
27
|
+
- .env
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { z } = require('zod');
|
|
4
|
+
const { createHttpClient } = require('../lib/httpClient');
|
|
5
|
+
const { pollUntil } = require('../lib/pollUntil');
|
|
6
|
+
|
|
7
|
+
const RELAYER_UI_BASE = 'http://localhost:8888';
|
|
8
|
+
const POLL_INTERVAL_MS = 10000; // 10s (AC-15)
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Tool 7: check_claim_status
|
|
12
|
+
* AC-15: poll every 10s; surface STATE_1->2->3; auto-proceed on STATE_3.
|
|
13
|
+
* AC-15a: 402 → {continue_polling:false, message}, no TTL loop.
|
|
14
|
+
* AC-16: TTL expiry (no 402) → "session expired" + new start_claim.
|
|
15
|
+
*
|
|
16
|
+
* Consumes relayer-ui: GET /api/v1/claim/status?claim_id=...
|
|
17
|
+
* → {state:'STATE_1'|'STATE_2'|'STATE_3', detail, installation_id?, claimed_at?}
|
|
18
|
+
* → 402: payment method required
|
|
19
|
+
* → 404: claim not found (expired or invalid)
|
|
20
|
+
*/
|
|
21
|
+
module.exports = function registerCheckClaimStatus(server, options = {}) {
|
|
22
|
+
const http = options.httpClient || createHttpClient(options);
|
|
23
|
+
const relayerUiBase = options.relayerUiBase || RELAYER_UI_BASE;
|
|
24
|
+
const pollInterval = options.pollIntervalMs ?? POLL_INTERVAL_MS;
|
|
25
|
+
const sleep = options.sleep;
|
|
26
|
+
|
|
27
|
+
server.tool(
|
|
28
|
+
'check_claim_status',
|
|
29
|
+
'Poll the status of a claim session. Checks every 10 seconds. States: STATE_1 (pending — user has not yet opened the claim URL), STATE_2 (in progress — user is completing the claim in browser), STATE_3 (completed — claim successful). Automatically proceeds when STATE_3 is reached.',
|
|
30
|
+
{
|
|
31
|
+
claim_id: z.string().describe('The claim_id returned by start_claim'),
|
|
32
|
+
timeout_ms: z.number().optional().default(600000).describe('Maximum time to poll in milliseconds (default: 10 minutes)'),
|
|
33
|
+
},
|
|
34
|
+
async ({ claim_id, timeout_ms }) => {
|
|
35
|
+
try {
|
|
36
|
+
const checkOnce = async () => {
|
|
37
|
+
const { status, data } = await http.get(
|
|
38
|
+
`${relayerUiBase}/api/v1/claim/status?claim_id=${encodeURIComponent(claim_id)}`,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// AC-15a: 402 → stop, no loop
|
|
42
|
+
if (status === 402) {
|
|
43
|
+
return {
|
|
44
|
+
done: true,
|
|
45
|
+
stop: true,
|
|
46
|
+
result: {
|
|
47
|
+
success: false,
|
|
48
|
+
continue_polling: false,
|
|
49
|
+
message: 'A payment method is required before the claim can complete. Please add a payment method at console.xns.tech, then run start_claim again.',
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// AC-16: 404 → expired
|
|
55
|
+
if (status === 404) {
|
|
56
|
+
return {
|
|
57
|
+
done: true,
|
|
58
|
+
result: {
|
|
59
|
+
success: false,
|
|
60
|
+
message: 'Claim session has expired or was not found. Run start_claim to create a new claim session.',
|
|
61
|
+
expired: true,
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (status >= 400) {
|
|
67
|
+
return {
|
|
68
|
+
done: true,
|
|
69
|
+
result: {
|
|
70
|
+
success: false,
|
|
71
|
+
error: data.error || `Claim status check failed (HTTP ${status}).`,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const state = data.state;
|
|
77
|
+
|
|
78
|
+
if (state === 'STATE_3') {
|
|
79
|
+
return {
|
|
80
|
+
done: true,
|
|
81
|
+
result: {
|
|
82
|
+
success: true,
|
|
83
|
+
message: 'Claim completed successfully. The Relayer is now linked to your account.',
|
|
84
|
+
state: 'STATE_3',
|
|
85
|
+
installation_id: data.installation_id,
|
|
86
|
+
claimed_at: data.claimed_at,
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// STATE_1 or STATE_2 — still in progress
|
|
92
|
+
const stateMessages = {
|
|
93
|
+
STATE_1: 'Waiting for the user to open the claim URL in a browser.',
|
|
94
|
+
STATE_2: 'Claim is in progress — the user is completing the browser flow.',
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
done: false,
|
|
99
|
+
result: {
|
|
100
|
+
success: false,
|
|
101
|
+
state,
|
|
102
|
+
message: stateMessages[state] || `Claim in state: ${state}`,
|
|
103
|
+
detail: data.detail,
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const { result, timedOut } = await pollUntil({
|
|
109
|
+
fn: async () => {
|
|
110
|
+
const check = await checkOnce();
|
|
111
|
+
if (check.done) return check.result;
|
|
112
|
+
return null;
|
|
113
|
+
},
|
|
114
|
+
intervalMs: pollInterval,
|
|
115
|
+
timeoutMs: timeout_ms,
|
|
116
|
+
sleep,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
if (timedOut) {
|
|
120
|
+
// AC-16: TTL expiry
|
|
121
|
+
return {
|
|
122
|
+
content: [{
|
|
123
|
+
type: 'text',
|
|
124
|
+
text: JSON.stringify({
|
|
125
|
+
success: false,
|
|
126
|
+
message: 'Claim status polling timed out. The claim session may have expired. Run start_claim to create a new claim session.',
|
|
127
|
+
expired: true,
|
|
128
|
+
}, null, 2),
|
|
129
|
+
}],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
content: [{
|
|
135
|
+
type: 'text',
|
|
136
|
+
text: JSON.stringify(result, null, 2),
|
|
137
|
+
}],
|
|
138
|
+
isError: (result.success === false && result.continue_polling !== false) ? true : undefined,
|
|
139
|
+
};
|
|
140
|
+
} catch (err) {
|
|
141
|
+
return {
|
|
142
|
+
content: [{
|
|
143
|
+
type: 'text',
|
|
144
|
+
text: JSON.stringify({
|
|
145
|
+
success: false,
|
|
146
|
+
error: `Claim status check failed: ${err.message}`,
|
|
147
|
+
}),
|
|
148
|
+
}],
|
|
149
|
+
isError: true,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
);
|
|
154
|
+
};
|