requestshield 0.0.0-stage → 1.0.0-stg.276
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/README.md +292 -2
- package/config/.env.stg +8 -0
- package/package.json +25 -4
- package/skills/requestshield/SKILL.md +240 -0
- package/skills/requestshield/assets/AGENTS.codex.md +107 -0
- package/skills/requestshield/references/backend-java-core.md +152 -0
- package/skills/requestshield/references/backend-spring-boot.md +158 -0
- package/skills/requestshield/references/browser-manual.md +244 -0
- package/skills/requestshield/references/browser-seamless.md +213 -0
- package/skills/requestshield/references/cli.md +314 -0
- package/skills/requestshield/references/integration-planning.md +357 -0
- package/skills/requestshield/references/troubleshooting.md +131 -0
- package/src/agent-detector.mjs +102 -0
- package/src/api-client.mjs +155 -0
- package/src/args.mjs +147 -0
- package/src/browser-opener.mjs +32 -0
- package/src/cli.mjs +115 -0
- package/src/command-completion.mjs +10 -0
- package/src/command-registry.mjs +151 -0
- package/src/commands/agent-setup.mjs +195 -0
- package/src/commands/agent-status.mjs +65 -0
- package/src/commands/application-mutations.mjs +37 -0
- package/src/commands/application-response.mjs +55 -0
- package/src/commands/apps-get.mjs +28 -0
- package/src/commands/apps-list.mjs +93 -0
- package/src/commands/auth-status.mjs +42 -0
- package/src/commands/billing.mjs +106 -0
- package/src/commands/contract.mjs +59 -0
- package/src/commands/keys-create.mjs +22 -0
- package/src/commands/mutation-support.mjs +135 -0
- package/src/commands/output.mjs +26 -0
- package/src/commands/secret-commands.mjs +66 -0
- package/src/commands/secret-output.mjs +52 -0
- package/src/commands/service-status.mjs +34 -0
- package/src/commands/signin.mjs +95 -0
- package/src/commands/signout.mjs +14 -0
- package/src/commands/update-check.mjs +205 -0
- package/src/commands/usage.mjs +72 -0
- package/src/config.mjs +200 -0
- package/src/entrypoint.mjs +44 -0
- package/src/errors.mjs +16 -0
- package/src/integration-contract-client.mjs +85 -0
- package/src/integration-contract.mjs +114 -0
- package/src/main.mjs +6 -0
- package/src/oauth-client.mjs +153 -0
- package/src/oauth-loopback.mjs +120 -0
- package/src/package-profile.mjs +24 -0
- package/src/private-path.mjs +33 -0
- package/src/secret-output.mjs +121 -0
- package/src/service-status-client.mjs +87 -0
- package/src/session-files.mjs +192 -0
- package/src/session-store.mjs +204 -0
- package/src/terminal.mjs +143 -0
- package/src/usage-time.mjs +26 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/** @typedef {{groupId: string, artifactId: string}} MavenArtifact */
|
|
4
|
+
/** @typedef {{
|
|
5
|
+
* schemaVersion: 2, contractVersion: string,
|
|
6
|
+
* services: {challengeUrl: string},
|
|
7
|
+
* browser: {sdkVersion: string, scriptUrlVersion: string, scriptUrlLatest: string, integrity: string,
|
|
8
|
+
* tokenHeader: 'X-IntelliFend-Token', availableModes: Array<'manual' | 'seamless'>,
|
|
9
|
+
* cspAdditions: {scriptSrc: string[], connectSrc: string[], workerSrc: string[]}},
|
|
10
|
+
* backend: {sdkVersion: string, minJdk: number, mavenRepository: string,
|
|
11
|
+
* core: MavenArtifact, springBoot3: MavenArtifact & {framework: 'Spring Boot 3 MVC'}},
|
|
12
|
+
* documentation: {browserSdk: string, javaSdk: string, domainsAndCsp: string, releaseNotes?: string}
|
|
13
|
+
* }} IntegrationContract */
|
|
14
|
+
|
|
15
|
+
const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
16
|
+
const WASM_SCRIPT_SOURCE = "'wasm-unsafe-eval'";
|
|
17
|
+
|
|
18
|
+
/** Shared by the CLI reader and customer-docs build checks. This module performs
|
|
19
|
+
* no I/O and includes no release data; customer-docs owns the selected releases.
|
|
20
|
+
* Schema 2 rejects extra fields so unreviewed content never reaches CLI output.
|
|
21
|
+
* @param {unknown} value
|
|
22
|
+
* @returns {{data: IntegrationContract}}
|
|
23
|
+
*/
|
|
24
|
+
export function validateIntegrationContract(value) {
|
|
25
|
+
const envelope = object(value, ['data']);
|
|
26
|
+
if (envelope.data && typeof envelope.data === 'object' && 'schemaVersion' in envelope.data && envelope.data.schemaVersion !== 2) {
|
|
27
|
+
throw new Error('Unsupported integration contract schema');
|
|
28
|
+
}
|
|
29
|
+
const data = object(envelope.data, ['schemaVersion', 'contractVersion', 'services', 'browser', 'backend', 'documentation']);
|
|
30
|
+
check(data.schemaVersion === 2);
|
|
31
|
+
version(data.contractVersion);
|
|
32
|
+
|
|
33
|
+
const services = object(data.services, ['challengeUrl']);
|
|
34
|
+
const challenge = publicUrl(services.challengeUrl);
|
|
35
|
+
const browser = object(data.browser, ['sdkVersion', 'scriptUrlVersion', 'scriptUrlLatest', 'integrity', 'tokenHeader', 'availableModes', 'cspAdditions']);
|
|
36
|
+
version(browser.sdkVersion);
|
|
37
|
+
const script = publicUrl(browser.scriptUrlVersion);
|
|
38
|
+
const latest = publicUrl(browser.scriptUrlLatest);
|
|
39
|
+
check(script.pathname === `/requestshield/v${browser.sdkVersion}/intellifend.js`);
|
|
40
|
+
check(latest.pathname === '/requestshield/latest/intellifend.js' && latest.origin === script.origin);
|
|
41
|
+
check(typeof browser.integrity === 'string' && /^sha384-[A-Za-z0-9+/]{64}$/.test(browser.integrity));
|
|
42
|
+
check(browser.tokenHeader === 'X-IntelliFend-Token');
|
|
43
|
+
const modes = stringList(browser.availableModes, 2);
|
|
44
|
+
check(modes.includes('manual') && modes.every(mode => mode === 'manual' || mode === 'seamless'));
|
|
45
|
+
|
|
46
|
+
const csp = object(browser.cspAdditions, ['scriptSrc', 'connectSrc', 'workerSrc']);
|
|
47
|
+
const scriptSources = stringList(csp.scriptSrc, 10);
|
|
48
|
+
for (const source of scriptSources) {
|
|
49
|
+
check(source === WASM_SCRIPT_SOURCE || publicUrl(source).origin === source);
|
|
50
|
+
}
|
|
51
|
+
// The shared additions cover latest as well as the pinned release.
|
|
52
|
+
check(scriptSources.includes(WASM_SCRIPT_SOURCE));
|
|
53
|
+
const connectSources = origins(csp.connectSrc);
|
|
54
|
+
check(scriptSources.includes(script.origin) && connectSources.includes(challenge.origin));
|
|
55
|
+
const workerSources = stringList(csp.workerSrc, 1);
|
|
56
|
+
check(workerSources.length === 1 && workerSources[0] === 'blob:');
|
|
57
|
+
|
|
58
|
+
const backend = object(data.backend, ['sdkVersion', 'minJdk', 'mavenRepository', 'core', 'springBoot3']);
|
|
59
|
+
version(backend.sdkVersion);
|
|
60
|
+
check(typeof backend.minJdk === 'number' && Number.isSafeInteger(backend.minJdk) && backend.minJdk >= 17 && backend.minJdk <= 100);
|
|
61
|
+
publicUrl(backend.mavenRepository);
|
|
62
|
+
const core = object(backend.core, ['groupId', 'artifactId']);
|
|
63
|
+
const starter = object(backend.springBoot3, ['groupId', 'artifactId', 'framework']);
|
|
64
|
+
for (const artifact of [core, starter]) {
|
|
65
|
+
check(typeof artifact.groupId === 'string' && /^[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)+$/.test(artifact.groupId) && artifact.groupId.length <= 200);
|
|
66
|
+
check(typeof artifact.artifactId === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(artifact.artifactId));
|
|
67
|
+
}
|
|
68
|
+
check(starter.framework === 'Spring Boot 3 MVC');
|
|
69
|
+
const docs = object(data.documentation, ['browserSdk', 'javaSdk', 'domainsAndCsp'], ['releaseNotes']);
|
|
70
|
+
for (const url of Object.values(docs)) publicUrl(url);
|
|
71
|
+
return /** @type {{data: IntegrationContract}} */ (value);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** @param {unknown} condition @returns {asserts condition} */
|
|
75
|
+
function check(condition) {
|
|
76
|
+
if (!condition) throw new Error('Invalid integration contract');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** @param {unknown} value @param {string[]} keys @param {string[]} [optionalKeys] @returns {Record<string, unknown>} */
|
|
80
|
+
function object(value, keys, optionalKeys = []) {
|
|
81
|
+
check(value !== null && typeof value === 'object' && !Array.isArray(value));
|
|
82
|
+
const record = /** @type {Record<string, unknown>} */ (value);
|
|
83
|
+
check(keys.every(key => Object.hasOwn(record, key))
|
|
84
|
+
&& Object.keys(record).every(key => keys.includes(key) || optionalKeys.includes(key)));
|
|
85
|
+
return record;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @param {unknown} value @returns {asserts value is string} */
|
|
89
|
+
function version(value) {
|
|
90
|
+
check(typeof value === 'string' && value.length <= 64 && VERSION.test(value));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** @param {unknown} value @returns {URL} */
|
|
94
|
+
function publicUrl(value) {
|
|
95
|
+
check(typeof value === 'string' && value.length <= 2048 && /^[\x21-\x7e]+$/.test(value) && !/[?#\\]/.test(value));
|
|
96
|
+
let url;
|
|
97
|
+
try { url = new URL(value); } catch { throw new Error('Invalid integration contract'); }
|
|
98
|
+
check(url.protocol === 'https:' && !url.username && !url.password && !url.search && !url.hash);
|
|
99
|
+
return url;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** @param {unknown} value @param {number} maximum @returns {string[]} */
|
|
103
|
+
function stringList(value, maximum) {
|
|
104
|
+
check(Array.isArray(value) && value.length >= 1 && value.length <= maximum);
|
|
105
|
+
check(value.every(item => typeof item === 'string') && new Set(value).size === value.length);
|
|
106
|
+
return /** @type {string[]} */ (value);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** @param {unknown} value @returns {string[]} */
|
|
110
|
+
function origins(value) {
|
|
111
|
+
const list = stringList(value, 10);
|
|
112
|
+
for (const item of list) check(publicUrl(item).origin === item);
|
|
113
|
+
return list;
|
|
114
|
+
}
|
package/src/main.mjs
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { CliError } from "./errors.mjs";
|
|
3
|
+
import { validateOAuthConfig, REQUESTED_OAUTH_SCOPES, validateBearerToken, validatedUrl } from "./config.mjs";
|
|
4
|
+
|
|
5
|
+
/** @typedef {{accessToken: string, refreshToken: string, expiresAt: number, scopes: string[]}} OAuthCredentials */
|
|
6
|
+
/** @typedef {{authorizationEndpoint: string, tokenEndpoint: string}} OAuthDiscovery */
|
|
7
|
+
|
|
8
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
9
|
+
|
|
10
|
+
export class OAuthClient {
|
|
11
|
+
/** @param {{config: import('./config.mjs').OAuthConfig, fetchImpl?: typeof fetch, now?: () => number, timeoutMs?: number}} options */
|
|
12
|
+
constructor({config, fetchImpl = fetch, now = Date.now, timeoutMs = 15_000}) {
|
|
13
|
+
this.config = validateOAuthConfig(config);
|
|
14
|
+
this.fetchImpl = fetchImpl;
|
|
15
|
+
this.now = now;
|
|
16
|
+
this.timeoutMs = timeoutMs;
|
|
17
|
+
/** @type {OAuthDiscovery | undefined} */
|
|
18
|
+
this.discovery = undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** @param {{signal?: AbortSignal}} [options] @returns {Promise<OAuthDiscovery>} */
|
|
22
|
+
async discover({signal} = {}) {
|
|
23
|
+
signal?.throwIfAborted();
|
|
24
|
+
if (this.discovery) return this.discovery;
|
|
25
|
+
const {body} = await this.#request(`${this.config.issuer}/.well-known/openid-configuration`, {method: "GET"}, signal);
|
|
26
|
+
if (body.issuer !== this.config.issuer) throw invalidResponse();
|
|
27
|
+
let authorizationEndpoint, tokenEndpoint;
|
|
28
|
+
try {
|
|
29
|
+
authorizationEndpoint = validatedUrl(body.authorization_endpoint, "authorization endpoint");
|
|
30
|
+
tokenEndpoint = validatedUrl(body.token_endpoint, "token endpoint");
|
|
31
|
+
} catch { throw invalidResponse(); }
|
|
32
|
+
const tokenUrl = new URL(tokenEndpoint);
|
|
33
|
+
if (tokenUrl.protocol !== "https:" || tokenUrl.origin !== new URL(this.config.issuer).origin) throw invalidResponse();
|
|
34
|
+
if (!Array.isArray(body.response_types_supported) || !body.response_types_supported.includes("code")
|
|
35
|
+
|| !Array.isArray(body.code_challenge_methods_supported) || !body.code_challenge_methods_supported.includes("S256")) throw invalidResponse();
|
|
36
|
+
this.discovery = {authorizationEndpoint, tokenEndpoint};
|
|
37
|
+
return this.discovery;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @param {{code: string, verifier: string, redirectUri: string, signal?: AbortSignal}} args @returns {Promise<OAuthCredentials>} */
|
|
41
|
+
async exchangeCode({code, verifier, redirectUri, signal}) {
|
|
42
|
+
const {tokenEndpoint} = await this.discover({signal});
|
|
43
|
+
const {body} = await this.#request(tokenEndpoint, {
|
|
44
|
+
method: "POST",
|
|
45
|
+
body: new URLSearchParams({grant_type: "authorization_code", client_id: this.config.clientId, code, code_verifier: verifier, redirect_uri: redirectUri}),
|
|
46
|
+
}, signal);
|
|
47
|
+
return this.#credentials(body);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Public refresh tokens rotate. Never retry a dispatched exchange. @param {string} refreshToken @returns {Promise<OAuthCredentials>} */
|
|
51
|
+
async refresh(refreshToken) {
|
|
52
|
+
const {tokenEndpoint} = await this.discover();
|
|
53
|
+
try {
|
|
54
|
+
const {body} = await this.#request(tokenEndpoint, {
|
|
55
|
+
method: "POST",
|
|
56
|
+
body: new URLSearchParams({grant_type: "refresh_token", client_id: this.config.clientId, refresh_token: refreshToken}),
|
|
57
|
+
});
|
|
58
|
+
return this.#credentials(body);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (error instanceof CliError && ["OAUTH_INVALID_GRANT", "OAUTH_REJECTED"].includes(error.code)) throw error;
|
|
61
|
+
throw new CliError("The refresh result is uncertain; run `requestshield login` again", {code: "OAUTH_REFRESH_UNCERTAIN", exitCode: 3});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** @param {Record<string, unknown>} body @returns {OAuthCredentials} */
|
|
66
|
+
#credentials(body) {
|
|
67
|
+
let accessToken;
|
|
68
|
+
try { accessToken = validateBearerToken(body.access_token); } catch { throw invalidResponse(); }
|
|
69
|
+
const refreshToken = body.refresh_token;
|
|
70
|
+
if (typeof refreshToken !== "string" || !/^[\x21-\x7e]{1,16384}$/.test(refreshToken)
|
|
71
|
+
|| typeof body.token_type !== "string" || body.token_type.toLowerCase() !== "bearer"
|
|
72
|
+
|| typeof body.expires_in !== "number" || !Number.isSafeInteger(body.expires_in) || body.expires_in <= 0 || body.expires_in > 900
|
|
73
|
+
|| typeof body.scope !== "string" || body.scope.length > 2048) throw invalidResponse();
|
|
74
|
+
const scopes = body.scope.split(" ");
|
|
75
|
+
if (new Set(scopes).size !== scopes.length || scopes.length !== REQUESTED_OAUTH_SCOPES.length
|
|
76
|
+
|| !REQUESTED_OAUTH_SCOPES.every(scope => scopes.includes(scope))) throw invalidResponse();
|
|
77
|
+
return {accessToken, refreshToken, expiresAt: this.now() + body.expires_in * 1000, scopes};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** @param {string} url @param {RequestInit} init @param {AbortSignal} [outerSignal] */
|
|
81
|
+
async #request(url, init, outerSignal) {
|
|
82
|
+
const controller = new AbortController();
|
|
83
|
+
const abort = () => controller.abort();
|
|
84
|
+
const timeout = setTimeout(abort, this.timeoutMs);
|
|
85
|
+
outerSignal?.addEventListener("abort", abort, {once: true});
|
|
86
|
+
if (outerSignal?.aborted) abort();
|
|
87
|
+
const signal = controller.signal;
|
|
88
|
+
let onAbort = () => {};
|
|
89
|
+
const cancelled = new Promise((_, reject) => {
|
|
90
|
+
onAbort = () => reject(new CliError("Could not connect to RequestShield sign-in. Check your connection and retry.", {code: "NETWORK_ERROR", exitCode: 7}));
|
|
91
|
+
signal.addEventListener("abort", onAbort, {once: true});
|
|
92
|
+
if (signal.aborted) onAbort();
|
|
93
|
+
});
|
|
94
|
+
try {
|
|
95
|
+
const operation = (async () => {
|
|
96
|
+
signal.throwIfAborted();
|
|
97
|
+
const response = await this.fetchImpl(url, {
|
|
98
|
+
...init, signal, redirect: "error", credentials: "omit", cache: "no-store",
|
|
99
|
+
headers: {Accept: "application/json", ...(init.body ? {"Content-Type": "application/x-www-form-urlencoded"} : {})},
|
|
100
|
+
});
|
|
101
|
+
if (response.status >= 300 && response.status < 400) throw invalidResponse();
|
|
102
|
+
if (!response.body || Number(response.headers.get("content-length")) > MAX_BODY_BYTES) {
|
|
103
|
+
void response.body?.cancel().catch(() => {});
|
|
104
|
+
throw invalidResponse();
|
|
105
|
+
}
|
|
106
|
+
const reader = response.body.getReader();
|
|
107
|
+
const cancelBody = () => { void reader.cancel().catch(() => {}); };
|
|
108
|
+
signal.addEventListener("abort", cancelBody, {once: true});
|
|
109
|
+
if (signal.aborted) cancelBody();
|
|
110
|
+
const chunks = [];
|
|
111
|
+
let size = 0;
|
|
112
|
+
try {
|
|
113
|
+
while (true) {
|
|
114
|
+
const {done, value} = await reader.read();
|
|
115
|
+
if (done) break;
|
|
116
|
+
size += value.length;
|
|
117
|
+
if (size > MAX_BODY_BYTES) { void reader.cancel().catch(() => {}); throw invalidResponse(); }
|
|
118
|
+
chunks.push(value);
|
|
119
|
+
}
|
|
120
|
+
} finally { signal.removeEventListener("abort", cancelBody); reader.releaseLock(); }
|
|
121
|
+
/** @type {Record<string, unknown>} */
|
|
122
|
+
let body;
|
|
123
|
+
try {
|
|
124
|
+
body = JSON.parse(new TextDecoder("utf-8", {fatal: true}).decode(Buffer.concat(chunks)));
|
|
125
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error();
|
|
126
|
+
} catch { throw invalidResponse(); }
|
|
127
|
+
if (!response.ok) {
|
|
128
|
+
if (body.error === "invalid_grant" || ["idp_refresh_token_invalid", "idp_authorization_code_invalid", "invalid_grant"].includes(String(body.error_type))) {
|
|
129
|
+
throw new CliError("Authorization was rejected; run `requestshield login` again", {code: "OAUTH_INVALID_GRANT", exitCode: 3});
|
|
130
|
+
}
|
|
131
|
+
if (response.status >= 400 && response.status < 500) {
|
|
132
|
+
throw new CliError("Sign-in was unsuccessful. Run `requestshield login` again; if the problem continues, contact support.", {code: "OAUTH_REJECTED", exitCode: 3});
|
|
133
|
+
}
|
|
134
|
+
throw new CliError("RequestShield sign-in is temporarily unavailable. Try again later.", {code: "NETWORK_ERROR", exitCode: 7});
|
|
135
|
+
}
|
|
136
|
+
return {body};
|
|
137
|
+
})();
|
|
138
|
+
return await Promise.race([operation, /** @type {Promise<never>} */ (cancelled)]);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (error instanceof CliError) throw error;
|
|
141
|
+
throw new CliError("Could not connect to RequestShield sign-in. Check your connection and retry.", {code: "NETWORK_ERROR", exitCode: 7});
|
|
142
|
+
} finally {
|
|
143
|
+
clearTimeout(timeout);
|
|
144
|
+
signal.removeEventListener("abort", onAbort);
|
|
145
|
+
outerSignal?.removeEventListener("abort", abort);
|
|
146
|
+
controller.abort();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function invalidResponse() {
|
|
152
|
+
return new CliError("Sign-in could not be completed. Try again; if the problem continues, contact support.", {code: "OAUTH_INVALID_RESPONSE", exitCode: 3});
|
|
153
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { timingSafeEqual } from "node:crypto";
|
|
4
|
+
import { CliError } from "./errors.mjs";
|
|
5
|
+
|
|
6
|
+
/** @typedef {{code: string}} AuthorizationResult */
|
|
7
|
+
|
|
8
|
+
/** Bind before returning, so opening the browser cannot race port allocation.
|
|
9
|
+
* @param {{state: string, issuer: string, signal: AbortSignal}} options
|
|
10
|
+
*/
|
|
11
|
+
export async function createLoopbackReceiver({state, issuer, signal}) {
|
|
12
|
+
/** @type {(value: AuthorizationResult) => void} */
|
|
13
|
+
let resolveResult;
|
|
14
|
+
/** @type {(error: Error) => void} */
|
|
15
|
+
let rejectResult;
|
|
16
|
+
const result = new Promise((resolve, reject) => { resolveResult = resolve; rejectResult = reject; });
|
|
17
|
+
// A callback can arrive while the OS browser launcher is still returning.
|
|
18
|
+
void result.catch(() => {});
|
|
19
|
+
/** @type {import('node:http').ServerResponse | undefined} */
|
|
20
|
+
let pendingResponse;
|
|
21
|
+
let accepted = false;
|
|
22
|
+
let expectedHost = "";
|
|
23
|
+
const server = createServer({maxHeaderSize: 8192, requestTimeout: 5_000, headersTimeout: 5_000}, (request, response) => {
|
|
24
|
+
response.setHeader("Cache-Control", "no-store");
|
|
25
|
+
response.setHeader("Referrer-Policy", "no-referrer");
|
|
26
|
+
response.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
27
|
+
response.setHeader("X-Content-Type-Options", "nosniff");
|
|
28
|
+
response.setHeader("Connection", "close");
|
|
29
|
+
const hostCount = request.rawHeaders.filter((_, i) => i % 2 === 0 && request.rawHeaders[i].toLowerCase() === "host").length;
|
|
30
|
+
if (request.method !== "GET" || request.headers.host !== expectedHost || hostCount !== 1
|
|
31
|
+
|| request.headers["transfer-encoding"] || (request.headers["content-length"] && request.headers["content-length"] !== "0")) {
|
|
32
|
+
response.writeHead(400).end("Invalid authorization callback."); return;
|
|
33
|
+
}
|
|
34
|
+
const raw = request.url ?? "";
|
|
35
|
+
if (raw.length > 8192 || raw.split("?")[0] !== "/callback") {
|
|
36
|
+
response.writeHead(404).end("Not found."); return;
|
|
37
|
+
}
|
|
38
|
+
let url;
|
|
39
|
+
try { url = new URL(raw, `http://${expectedHost}`); } catch {
|
|
40
|
+
response.writeHead(400).end("Invalid authorization callback."); return;
|
|
41
|
+
}
|
|
42
|
+
const params = url.searchParams;
|
|
43
|
+
const suppliedState = params.get("state") ?? "";
|
|
44
|
+
const code = params.get("code");
|
|
45
|
+
const error = params.get("error");
|
|
46
|
+
if (url.hash || [...params.keys()].some(key => params.getAll(key).length !== 1)
|
|
47
|
+
|| !equalState(suppliedState, state)
|
|
48
|
+
|| (params.has("iss") && params.get("iss") !== issuer)
|
|
49
|
+
|| (code === null) === (error === null)
|
|
50
|
+
|| (code !== null && !/^[\x21-\x7e]{1,4096}$/.test(code))
|
|
51
|
+
|| (error !== null && !/^[A-Za-z_]{1,128}$/.test(error))) {
|
|
52
|
+
response.writeHead(400).end("Invalid authorization callback."); return;
|
|
53
|
+
}
|
|
54
|
+
if (accepted) { response.writeHead(409).end("Authorization callback already received."); return; }
|
|
55
|
+
accepted = true;
|
|
56
|
+
if (error !== null) {
|
|
57
|
+
response.writeHead(400).end("Authorization was not completed. Return to your terminal.");
|
|
58
|
+
rejectResult(new CliError("Sign-in was denied or cancelled", {code: "SIGNIN_DENIED", exitCode: 3}));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
// Keep this response pending until token exchange AND protected storage succeed.
|
|
62
|
+
pendingResponse = response;
|
|
63
|
+
resolveResult({code: /** @type {string} */ (code)});
|
|
64
|
+
});
|
|
65
|
+
server.maxConnections = 8;
|
|
66
|
+
server.on("clientError", (_error, socket) => {
|
|
67
|
+
if (socket.writable) socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
|
|
68
|
+
else socket.destroy();
|
|
69
|
+
});
|
|
70
|
+
const aborted = () => {
|
|
71
|
+
rejectResult(new CliError("Sign-in was cancelled or expired; run `requestshield login` again", {code: "SIGNIN_CANCELLED", exitCode: 3}));
|
|
72
|
+
if (pendingResponse && !pendingResponse.writableEnded) pendingResponse.writeHead(400).end("Sign-in was not completed. Return to your terminal.");
|
|
73
|
+
server.close();
|
|
74
|
+
server.closeAllConnections();
|
|
75
|
+
};
|
|
76
|
+
signal.addEventListener("abort", aborted, {once: true});
|
|
77
|
+
try {
|
|
78
|
+
if (signal.aborted) throw new Error();
|
|
79
|
+
await new Promise((resolve, reject) => {
|
|
80
|
+
server.once("error", reject);
|
|
81
|
+
server.listen(0, "127.0.0.1", () => { server.removeListener("error", reject); resolve(undefined); });
|
|
82
|
+
});
|
|
83
|
+
if (signal.aborted) throw new Error();
|
|
84
|
+
const address = server.address();
|
|
85
|
+
if (!address || typeof address === "string") throw new Error();
|
|
86
|
+
expectedHost = `127.0.0.1:${address.port}`;
|
|
87
|
+
} catch {
|
|
88
|
+
signal.removeEventListener("abort", aborted);
|
|
89
|
+
server.close();
|
|
90
|
+
server.closeAllConnections();
|
|
91
|
+
throw new CliError("Could not start the local sign-in callback listener", {code: "SIGNIN_LISTENER_ERROR", exitCode: 3});
|
|
92
|
+
}
|
|
93
|
+
server.on("error", () => rejectResult(new CliError("The local sign-in listener failed", {code: "SIGNIN_LISTENER_ERROR", exitCode: 3})));
|
|
94
|
+
return {
|
|
95
|
+
redirectUri: `http://${expectedHost}/callback`,
|
|
96
|
+
result: /** @type {Promise<AuthorizationResult>} */ (result),
|
|
97
|
+
/** @param {boolean} success */
|
|
98
|
+
complete(success) {
|
|
99
|
+
if (!pendingResponse || pendingResponse.writableEnded) return;
|
|
100
|
+
pendingResponse.writeHead(success ? 200 : 400).end(success
|
|
101
|
+
? "Signed in successfully. You may close this window and return to your terminal."
|
|
102
|
+
: "Sign-in was not completed. Return to your terminal.");
|
|
103
|
+
},
|
|
104
|
+
async close() {
|
|
105
|
+
signal.removeEventListener("abort", aborted);
|
|
106
|
+
if (!server.listening) return;
|
|
107
|
+
await new Promise(resolve => {
|
|
108
|
+
const timer = setTimeout(() => server.closeAllConnections(), 1000);
|
|
109
|
+
server.close(() => { clearTimeout(timer); resolve(undefined); });
|
|
110
|
+
});
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** @param {string} actual @param {string} expected */
|
|
116
|
+
function equalState(actual, expected) {
|
|
117
|
+
const a = Buffer.from(actual);
|
|
118
|
+
const b = Buffer.from(expected);
|
|
119
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
120
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import packageJson from "../package.json" with { type: "json" };
|
|
3
|
+
import { CliError } from "./errors.mjs";
|
|
4
|
+
|
|
5
|
+
/** @typedef {'qat' | 'stg' | 'prod'} PackageProfile */
|
|
6
|
+
|
|
7
|
+
/** Validate package metadata without reading service configuration or credentials.
|
|
8
|
+
* @param {unknown} value @returns {PackageProfile}
|
|
9
|
+
*/
|
|
10
|
+
export function validatePackageProfile(value) {
|
|
11
|
+
if (value !== "qat" && value !== "stg" && value !== "prod") {
|
|
12
|
+
throw new CliError("This RequestShield installation is invalid. Reinstall the CLI or contact support.", {
|
|
13
|
+
code: "INVALID_INSTALLATION", exitCode: 2,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The installed artifact fixes its runtime profile; no environment or cwd fallback. */
|
|
20
|
+
export function getPackageProfile() {
|
|
21
|
+
const metadata = Reflect.get(packageJson, "requestshield");
|
|
22
|
+
return validatePackageProfile(metadata && typeof metadata === "object" && !Array.isArray(metadata)
|
|
23
|
+
? Reflect.get(metadata, "profile") : undefined);
|
|
24
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import * as nativeFs from "node:fs/promises";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
|
|
6
|
+
/** Establish access for the current OS user before writing any credential bytes.
|
|
7
|
+
* Callers own error translation; native errors must never reach command output.
|
|
8
|
+
* @param {string} file @param {boolean} directory
|
|
9
|
+
* @param {{platform?: NodeJS.Platform, fs?: Pick<typeof nativeFs, 'chmod'>}} [options]
|
|
10
|
+
*/
|
|
11
|
+
export async function protectPrivatePath(file, directory, options = {}) {
|
|
12
|
+
if ((options.platform ?? process.platform) !== "win32") {
|
|
13
|
+
await (options.fs ?? nativeFs).chmod(file, directory ? 0o700 : 0o600);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
// Static script, path in environment, current SID avoids domain lookup.
|
|
17
|
+
const script = [
|
|
18
|
+
'$ErrorActionPreference = "Stop"',
|
|
19
|
+
'$sessionSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User',
|
|
20
|
+
`$sessionAcl = New-Object System.Security.AccessControl.${directory ? "DirectorySecurity" : "FileSecurity"}`,
|
|
21
|
+
'$sessionAcl.SetOwner($sessionSid)',
|
|
22
|
+
'$sessionAcl.SetAccessRuleProtection($true, $false)',
|
|
23
|
+
directory
|
|
24
|
+
? '$sessionRule = New-Object System.Security.AccessControl.FileSystemAccessRule($sessionSid, "FullControl", "ContainerInherit,ObjectInherit", "None", "Allow")'
|
|
25
|
+
: '$sessionRule = New-Object System.Security.AccessControl.FileSystemAccessRule($sessionSid, "FullControl", "Allow")',
|
|
26
|
+
'$sessionAcl.AddAccessRule($sessionRule)',
|
|
27
|
+
`[System.IO.${directory ? "Directory" : "File"}]::SetAccessControl($env:REQUESTSHIELD_SESSION_ACL_PATH, $sessionAcl)`,
|
|
28
|
+
].join("\n");
|
|
29
|
+
await promisify(execFile)("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
|
|
30
|
+
windowsHide: true, timeout: 10_000,
|
|
31
|
+
env: { SystemRoot: process.env.SystemRoot, PATH: process.env.PATH, REQUESTSHIELD_SESSION_ACL_PATH: file },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import * as nativeFs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { CliError } from "./errors.mjs";
|
|
5
|
+
import { protectPrivatePath } from "./private-path.mjs";
|
|
6
|
+
|
|
7
|
+
/** @typedef {{cwd?: string, platform?: NodeJS.Platform, fs?: Partial<typeof nativeFs>, protectPath?: (file: string, directory: boolean) => Promise<void>}} SecretOutputOptions */
|
|
8
|
+
|
|
9
|
+
/** Validate syntax without touching the filesystem or exposing the supplied value.
|
|
10
|
+
* @param {string} value
|
|
11
|
+
*/
|
|
12
|
+
export function validateOutputPath(value) {
|
|
13
|
+
if (!value.trim() || value === "-" || /[\p{Cc}\p{Cs}]/u.test(value)) {
|
|
14
|
+
throw new CliError("--output requires a file path, not stdout or control characters.", {code: "INVALID_ARGUMENT", exitCode: 2});
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A new, private file reserved before requesting a secret. Never replaces a file. */
|
|
20
|
+
export class SecretOutputFile {
|
|
21
|
+
/** @param {string} file @param {SecretOutputOptions} [options] */
|
|
22
|
+
constructor(file, options = {}) {
|
|
23
|
+
this.file = path.resolve(options.cwd ?? process.cwd(), validateOutputPath(file));
|
|
24
|
+
this.platform = options.platform ?? process.platform;
|
|
25
|
+
this.fs = {...nativeFs, ...options.fs};
|
|
26
|
+
this.protectPath = options.protectPath ?? ((file, directory) => protectPrivatePath(file, directory, {platform: this.platform, fs: this.fs}));
|
|
27
|
+
/** @type {import('node:fs/promises').FileHandle | undefined} */
|
|
28
|
+
this.handle = undefined;
|
|
29
|
+
/** @type {import('node:fs').BigIntStats | undefined} */
|
|
30
|
+
this.identity = undefined;
|
|
31
|
+
this.completed = false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async prepare() {
|
|
35
|
+
// Reject Windows devices and alternate data streams, including file aliases.
|
|
36
|
+
if (this.platform === "win32") {
|
|
37
|
+
const root = path.parse(this.file).root;
|
|
38
|
+
const parts = this.file.slice(root.length).split(/[\\/]/);
|
|
39
|
+
if (/^\\\\[?.]\\/.test(this.file) || parts.some(part => /[<>:"|?*]|[. ]$/.test(part)
|
|
40
|
+
|| /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))) {
|
|
41
|
+
throw new CliError("--output must name a regular file.", {code: "INVALID_ARGUMENT", exitCode: 2});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
try { await this.#prepareDirectories(); }
|
|
45
|
+
catch { throw prepareFailure(); }
|
|
46
|
+
try { this.handle = await this.fs.open(this.file, "wx", 0o600); }
|
|
47
|
+
catch (error) {
|
|
48
|
+
if (errorCode(error) === "EEXIST") {
|
|
49
|
+
throw new CliError("The output path already exists. Choose a new file; no application or secret request was sent.", {code: "OUTPUT_EXISTS", exitCode: 2});
|
|
50
|
+
}
|
|
51
|
+
throw prepareFailure();
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
this.identity = await this.handle.stat({bigint: true});
|
|
55
|
+
await this.#assertOwnedFile();
|
|
56
|
+
await this.protectPath(this.file, false);
|
|
57
|
+
await this.#assertOwnedFile();
|
|
58
|
+
if (this.platform !== "win32" && process.platform !== "win32") {
|
|
59
|
+
const stat = await this.handle.stat();
|
|
60
|
+
if ((stat.mode & 0o777) !== 0o600 || stat.uid !== process.getuid?.()) throw new Error("permissions");
|
|
61
|
+
}
|
|
62
|
+
} catch { throw prepareFailure(); }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Only call with a validated API response. @param {string} appKey @param {string} apiSecret */
|
|
66
|
+
async commit(appKey, apiSecret) {
|
|
67
|
+
if (!this.handle) throw new Error("Output file is not reserved");
|
|
68
|
+
await this.#assertOwnedFile();
|
|
69
|
+
await this.handle.writeFile(`INTELLIFEND_REQUESTSHIELD_APP_KEY=${appKey}\nINTELLIFEND_REQUESTSHIELD_API_SECRET=${apiSecret}\n`, "utf8");
|
|
70
|
+
await this.handle.sync();
|
|
71
|
+
await this.#assertOwnedFile();
|
|
72
|
+
await this.handle.close();
|
|
73
|
+
this.handle = undefined;
|
|
74
|
+
this.completed = true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Clean only the inode reserved by this invocation; never delete a replacement.
|
|
78
|
+
* @returns {Promise<boolean>} Whether no unfinished output remains at our path.
|
|
79
|
+
*/
|
|
80
|
+
async cleanup() {
|
|
81
|
+
if (this.completed) return true;
|
|
82
|
+
const reserved = this.handle !== undefined;
|
|
83
|
+
try { await this.handle?.close(); } catch { /* Continue with best-effort removal. */ }
|
|
84
|
+
this.handle = undefined;
|
|
85
|
+
if (!this.identity) return !reserved;
|
|
86
|
+
try {
|
|
87
|
+
const current = await this.fs.lstat(this.file, {bigint: true});
|
|
88
|
+
if (!current.isFile() || current.isSymbolicLink() || current.dev !== this.identity.dev || current.ino !== this.identity.ino) return false;
|
|
89
|
+
await this.fs.unlink(this.file);
|
|
90
|
+
return true;
|
|
91
|
+
} catch (error) { return errorCode(error) === "ENOENT"; }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async #assertOwnedFile() {
|
|
95
|
+
const current = await this.fs.lstat(this.file, {bigint: true});
|
|
96
|
+
if (!current.isFile() || current.isSymbolicLink() || current.nlink !== 1n
|
|
97
|
+
|| current.dev !== this.identity?.dev || current.ino !== this.identity?.ino) throw new Error("Output path changed");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async #prepareDirectories() {
|
|
101
|
+
const directory = path.dirname(this.file);
|
|
102
|
+
const root = path.parse(directory).root;
|
|
103
|
+
let current = root;
|
|
104
|
+
for (const part of directory.slice(root.length).split(path.sep).filter(Boolean)) {
|
|
105
|
+
current = path.join(current, part);
|
|
106
|
+
let created = false;
|
|
107
|
+
try { await this.fs.mkdir(current, {mode: 0o700}); created = true; }
|
|
108
|
+
catch (error) { if (errorCode(error) !== "EEXIST") throw error; }
|
|
109
|
+
const stat = await this.fs.lstat(current);
|
|
110
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Invalid output directory");
|
|
111
|
+
// Do not change ACLs/modes on an existing user directory.
|
|
112
|
+
if (created) await this.protectPath(current, true);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** @param {unknown} error */
|
|
118
|
+
function errorCode(error) { return error && typeof error === "object" ? Reflect.get(error, "code") : undefined; }
|
|
119
|
+
function prepareFailure() {
|
|
120
|
+
return new CliError("Could not prepare a private output file. Check the path and permissions and use ordinary directories without symbolic links or junctions. No application or secret request was sent.", {code: "OUTPUT_WRITE_FAILED", exitCode: 7});
|
|
121
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
export const SERVICE_TIMEOUT_MS = 5_000;
|
|
4
|
+
const MAX_BODY_BYTES = 8 * 1024;
|
|
5
|
+
|
|
6
|
+
/** @typedef {'management-api' | 'challenge-service'} Service */
|
|
7
|
+
/** @typedef {'timeout' | 'network_error' | 'http_error' | 'invalid_response'} FailureReason */
|
|
8
|
+
/** @typedef {{service: Service, url: string, status: 'ok' | 'failed', latencyMs: number, httpStatus: number | null, reason: FailureReason | null}} ServiceCheck */
|
|
9
|
+
|
|
10
|
+
/** Public HTTP liveness only. A failure never hides the other service's result.
|
|
11
|
+
* @param {{apiUrl: string, challengeUrl: string, fetchImpl?: typeof fetch, timeoutMs?: number, clock?: () => number}} options
|
|
12
|
+
* @returns {Promise<ServiceCheck[]>}
|
|
13
|
+
*/
|
|
14
|
+
export function checkServices({apiUrl, challengeUrl, fetchImpl = fetch, timeoutMs = SERVICE_TIMEOUT_MS, clock = () => performance.now()}) {
|
|
15
|
+
return Promise.all([
|
|
16
|
+
checkService('management-api', new URL('/health', apiUrl).href, fetchImpl, timeoutMs, clock),
|
|
17
|
+
checkService('challenge-service', new URL('/health', challengeUrl).href, fetchImpl, timeoutMs, clock),
|
|
18
|
+
]);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** @param {Service} service @param {string} url @param {typeof fetch} fetchImpl
|
|
22
|
+
* @param {number} timeoutMs @param {() => number} clock @returns {Promise<ServiceCheck>}
|
|
23
|
+
*/
|
|
24
|
+
async function checkService(service, url, fetchImpl, timeoutMs, clock) {
|
|
25
|
+
const started = clock();
|
|
26
|
+
const controller = new AbortController();
|
|
27
|
+
/** @type {ReadableStreamDefaultReader<Uint8Array> | undefined} */
|
|
28
|
+
let reader;
|
|
29
|
+
/** @type {number | null} */
|
|
30
|
+
let httpStatus = null;
|
|
31
|
+
let expired = false;
|
|
32
|
+
/** @type {NodeJS.Timeout | undefined} */
|
|
33
|
+
let timer;
|
|
34
|
+
/** @type {Promise<FailureReason>} */
|
|
35
|
+
const deadline = new Promise(resolve => {
|
|
36
|
+
timer = setTimeout(() => {
|
|
37
|
+
expired = true;
|
|
38
|
+
controller.abort();
|
|
39
|
+
resolve('timeout');
|
|
40
|
+
}, timeoutMs);
|
|
41
|
+
});
|
|
42
|
+
try {
|
|
43
|
+
/** @returns {Promise<FailureReason | null>} */
|
|
44
|
+
const operation = async () => {
|
|
45
|
+
const response = await fetchImpl(url, {
|
|
46
|
+
method: 'GET', headers: {Accept: 'application/json'}, credentials: 'omit',
|
|
47
|
+
redirect: 'manual', cache: 'no-store', signal: controller.signal,
|
|
48
|
+
});
|
|
49
|
+
if (expired) { void response.body?.cancel().catch(() => {}); return 'timeout'; }
|
|
50
|
+
httpStatus = response.status;
|
|
51
|
+
if (response.status !== 200) {
|
|
52
|
+
void response.body?.cancel().catch(() => {});
|
|
53
|
+
return 'http_error';
|
|
54
|
+
}
|
|
55
|
+
const mediaType = response.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase();
|
|
56
|
+
if (!mediaType || !/^application\/(?:json|[a-z0-9!#$&^_.+-]+\+json)$/.test(mediaType)
|
|
57
|
+
|| Number(response.headers.get('content-length')) > MAX_BODY_BYTES) {
|
|
58
|
+
void response.body?.cancel().catch(() => {});
|
|
59
|
+
return 'invalid_response';
|
|
60
|
+
}
|
|
61
|
+
const chunks = [];
|
|
62
|
+
let size = 0;
|
|
63
|
+
if (response.body) {
|
|
64
|
+
reader = response.body.getReader();
|
|
65
|
+
while (true) {
|
|
66
|
+
const {done, value} = await reader.read();
|
|
67
|
+
if (expired) return 'timeout';
|
|
68
|
+
if (done) break;
|
|
69
|
+
size += value.byteLength;
|
|
70
|
+
if (size > MAX_BODY_BYTES) return 'invalid_response';
|
|
71
|
+
chunks.push(value);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const body = JSON.parse(new TextDecoder('utf-8', {fatal: true}).decode(Buffer.concat(chunks)));
|
|
76
|
+
return body && typeof body === 'object' && !Array.isArray(body)
|
|
77
|
+
&& body.status === 'ok' && body.service === service ? null : 'invalid_response';
|
|
78
|
+
} catch { return 'invalid_response'; }
|
|
79
|
+
};
|
|
80
|
+
const reason = await Promise.race([operation().catch(() => expired ? 'timeout' : /** @type {FailureReason} */ ('network_error')), deadline]);
|
|
81
|
+
return {service, url, status: reason === null ? 'ok' : 'failed', latencyMs: Math.max(0, Math.round(clock() - started)), httpStatus, reason};
|
|
82
|
+
} finally {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
if (reader) void reader.cancel().catch(() => {});
|
|
85
|
+
controller.abort();
|
|
86
|
+
}
|
|
87
|
+
}
|