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,205 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import readline from "node:readline/promises";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { stdin, stderr } from "node:process";
|
|
6
|
+
import { CliError } from "../errors.mjs";
|
|
7
|
+
import { rendererFor } from "../terminal.mjs";
|
|
8
|
+
|
|
9
|
+
const DEFAULT_REGISTRY = "https://registry.npmjs.org";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {{ currentVersion: string, packageName: string, profile?: "qat" | "stg" | "prod", json?: boolean, yes?: boolean, log: (message: string) => void, warn?: (message: string) => void, fetchImpl?: typeof fetch, confirm?: (latestVersion: string) => Promise<boolean>, install?: (packageName: string, version: string) => Promise<void>, terminal?: import('../terminal.mjs').TerminalContext }} UpdateOptions
|
|
13
|
+
* @typedef {{ currentVersion: string, latestVersion: string | null, updateAvailable: boolean | null, action: "check" | "apply", outcome: "unavailable" | "up_to_date" | "available" | "installed" | "cancelled", target?: string }} UpdateResult
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** @param {UpdateOptions} options */
|
|
17
|
+
export async function checkForUpdate(options) {
|
|
18
|
+
const result = await inspectUpdate(options, "check");
|
|
19
|
+
printResult(result, options);
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** @param {UpdateOptions} options */
|
|
24
|
+
export async function applyUpdate(options) {
|
|
25
|
+
const result = await inspectUpdate(options, "apply");
|
|
26
|
+
if (result.outcome !== "available" || result.latestVersion === null) {
|
|
27
|
+
printResult(result, options);
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const warn = options.warn ?? ((message) => console.error(message));
|
|
32
|
+
const diagnostics = rendererFor(options, "stderr");
|
|
33
|
+
const target = `Update target: global npm installation of ${options.packageName}.`;
|
|
34
|
+
warn(options.json ? target : diagnostics.message("warning", target));
|
|
35
|
+
if (options.json && !options.yes) {
|
|
36
|
+
throw confirmationRequired();
|
|
37
|
+
}
|
|
38
|
+
const accepted = options.yes || await (options.confirm ?? confirmUpdate)(result.latestVersion);
|
|
39
|
+
if (!accepted) {
|
|
40
|
+
result.outcome = "cancelled";
|
|
41
|
+
printResult(result, options);
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const progress = `Installing RequestShield ${result.latestVersion} globally with npm...`;
|
|
46
|
+
warn(options.json ? progress : diagnostics.message("info", progress));
|
|
47
|
+
await (options.install ?? installWithNpm)(options.packageName, result.latestVersion);
|
|
48
|
+
result.outcome = "installed";
|
|
49
|
+
printResult(result, options);
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** @param {UpdateOptions} options @param {"check" | "apply"} action @returns {Promise<UpdateResult>} */
|
|
54
|
+
async function inspectUpdate(options, action) {
|
|
55
|
+
const profile = options.profile ?? "prod";
|
|
56
|
+
if (profile !== "prod") {
|
|
57
|
+
return { currentVersion: options.currentVersion, latestVersion: null, updateAvailable: null, action, outcome: "unavailable" };
|
|
58
|
+
}
|
|
59
|
+
const latestVersion = await fetchLatestVersion(options.packageName, options.fetchImpl ?? fetch);
|
|
60
|
+
const updateAvailable = compareVersions(latestVersion, options.currentVersion) > 0;
|
|
61
|
+
return {
|
|
62
|
+
currentVersion: options.currentVersion,
|
|
63
|
+
latestVersion,
|
|
64
|
+
updateAvailable,
|
|
65
|
+
action,
|
|
66
|
+
outcome: updateAvailable ? "available" : "up_to_date",
|
|
67
|
+
target: `global npm installation of ${options.packageName}`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** @param {UpdateResult} result @param {UpdateOptions} options */
|
|
72
|
+
function printResult(result, options) {
|
|
73
|
+
if (options.json) {
|
|
74
|
+
options.log(JSON.stringify({ data: result }, null, 2));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const renderer = rendererFor(options);
|
|
78
|
+
const sections = [renderer.heading("CLI update", "📥")];
|
|
79
|
+
if (result.outcome === "unavailable") {
|
|
80
|
+
sections.push(renderer.paragraph("Automatic updates are unavailable for this installation."));
|
|
81
|
+
options.log(sections.join("\n\n"));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
sections.push(renderer.fields([["Current version", result.currentVersion], ["Latest version", result.latestVersion ?? "unknown"]]));
|
|
85
|
+
if (result.outcome === "up_to_date") sections.push(renderer.message("success", "RequestShield is already up to date."));
|
|
86
|
+
if (result.outcome === "available") {
|
|
87
|
+
sections.push(renderer.message("info", "Run requestshield update apply to install the update."));
|
|
88
|
+
sections.push(renderer.paragraph(`Update target: ${result.target}.`));
|
|
89
|
+
}
|
|
90
|
+
if (result.outcome === "cancelled") sections.push(renderer.message("info", "Update cancelled."));
|
|
91
|
+
if (result.outcome === "installed") {
|
|
92
|
+
sections.push(renderer.message("success", `RequestShield ${result.latestVersion} was installed successfully (global npm installation).`));
|
|
93
|
+
sections.push(renderer.paragraph("Open a new terminal before running RequestShield again."));
|
|
94
|
+
}
|
|
95
|
+
options.log(sections.join("\n\n"));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** @param {string} packageName @param {typeof fetch} fetchImpl */
|
|
99
|
+
async function fetchLatestVersion(packageName, fetchImpl) {
|
|
100
|
+
const url = `${DEFAULT_REGISTRY}/${encodeURIComponent(packageName)}/latest`;
|
|
101
|
+
let response;
|
|
102
|
+
try {
|
|
103
|
+
response = await fetchImpl(url, { signal: AbortSignal.timeout(15_000) });
|
|
104
|
+
} catch {
|
|
105
|
+
throw new CliError("Could not check the latest RequestShield version. Check your connection and retry.", {
|
|
106
|
+
code: "UPDATE_CHECK_FAILED",
|
|
107
|
+
exitCode: 7,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!response.ok) {
|
|
112
|
+
throw new CliError(`Could not check the latest RequestShield version: HTTP ${response.status}`, {
|
|
113
|
+
code: "UPDATE_CHECK_FAILED",
|
|
114
|
+
exitCode: 7,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let body;
|
|
119
|
+
try {
|
|
120
|
+
body = await response.json();
|
|
121
|
+
} catch {
|
|
122
|
+
throw new CliError("The npm registry returned an invalid RequestShield version", {
|
|
123
|
+
code: "INVALID_UPDATE_RESPONSE",
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
const version = body && typeof body === "object" ? Reflect.get(body, "version") : undefined;
|
|
127
|
+
if (typeof version !== "string" || !parseVersion(version)) {
|
|
128
|
+
throw new CliError("The npm registry returned an invalid RequestShield version", {
|
|
129
|
+
code: "INVALID_UPDATE_RESPONSE",
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return version;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** @param {string} latestVersion */
|
|
136
|
+
async function confirmUpdate(latestVersion) {
|
|
137
|
+
if (!stdin.isTTY || !stderr.isTTY) throw confirmationRequired();
|
|
138
|
+
const prompt = readline.createInterface({ input: stdin, output: stderr });
|
|
139
|
+
try {
|
|
140
|
+
const answer = await prompt.question(`Install RequestShield ${latestVersion} globally with npm? (y/N): `);
|
|
141
|
+
return answer.trim().toLowerCase() === "y";
|
|
142
|
+
} finally {
|
|
143
|
+
prompt.close();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function confirmationRequired() {
|
|
148
|
+
return new CliError("Installing an update requires confirmation. Use an interactive terminal or pass --yes to requestshield update apply.", {
|
|
149
|
+
code: "UPDATE_CONFIRMATION_REQUIRED",
|
|
150
|
+
exitCode: 2,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** @param {string} packageName @param {string} version */
|
|
155
|
+
async function installWithNpm(packageName, version) {
|
|
156
|
+
await new Promise((resolve, reject) => {
|
|
157
|
+
const child = spawn("npm", ["install", "--global", `${packageName}@${version}`], {
|
|
158
|
+
shell: process.platform === "win32",
|
|
159
|
+
// npm's output is diagnostic. Keep command stdout available for JSON.
|
|
160
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
161
|
+
windowsHide: true,
|
|
162
|
+
});
|
|
163
|
+
child.stdout?.pipe(stderr, { end: false });
|
|
164
|
+
child.stderr?.pipe(stderr, { end: false });
|
|
165
|
+
child.once("error", () => {
|
|
166
|
+
reject(new CliError("Could not start npm. Check that npm is installed and available.", {
|
|
167
|
+
code: "UPDATE_FAILED",
|
|
168
|
+
exitCode: 1,
|
|
169
|
+
}));
|
|
170
|
+
});
|
|
171
|
+
child.once("exit", (code) => {
|
|
172
|
+
if (code === 0) resolve(undefined);
|
|
173
|
+
else reject(new CliError(`npm update failed with exit code ${code ?? "unknown"}`, {
|
|
174
|
+
code: "UPDATE_FAILED",
|
|
175
|
+
exitCode: 1,
|
|
176
|
+
}));
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** @param {string} left @param {string} right */
|
|
182
|
+
function compareVersions(left, right) {
|
|
183
|
+
const a = parseVersion(left);
|
|
184
|
+
const b = parseVersion(right);
|
|
185
|
+
if (!a || !b) throw new CliError("RequestShield has an invalid semantic version");
|
|
186
|
+
for (let index = 0; index < 3; index++) {
|
|
187
|
+
if (a.numbers[index] !== b.numbers[index]) return a.numbers[index] - b.numbers[index];
|
|
188
|
+
}
|
|
189
|
+
if (a.prerelease === b.prerelease) return 0;
|
|
190
|
+
if (a.prerelease === undefined) return 1;
|
|
191
|
+
if (b.prerelease === undefined) return -1;
|
|
192
|
+
return a.prerelease.localeCompare(b.prerelease, "en", { numeric: true });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** @param {string} value */
|
|
196
|
+
function parseVersion(value) {
|
|
197
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value);
|
|
198
|
+
if (!match) return undefined;
|
|
199
|
+
return {
|
|
200
|
+
numbers: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
201
|
+
prerelease: match[4],
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export { compareVersions };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { validateAppKey } from "../args.mjs";
|
|
3
|
+
import { utcTimestamp, validateUsageRange } from "../usage-time.mjs";
|
|
4
|
+
import { rendererFor } from "../terminal.mjs";
|
|
5
|
+
import { invalidResponse, responseObject } from "./application-response.mjs";
|
|
6
|
+
import { printData } from "./output.mjs";
|
|
7
|
+
|
|
8
|
+
const DAY_MS = 86_400_000;
|
|
9
|
+
const OPERATIONS = /** @type {const} */ (["challenge_create", "challenge_verify", "token_consume"]);
|
|
10
|
+
const OPERATION_LABELS = ["Challenge requests", "Challenge verification", "Token verification"];
|
|
11
|
+
|
|
12
|
+
/** @typedef {{requests: number, success: number, rejected: number, error: number}} OperationCounts */
|
|
13
|
+
|
|
14
|
+
/** @param {{appKey: string, json?: boolean, from?: string, to?: string}} options
|
|
15
|
+
* @param {{api: {getUsage(token: string, appKey: string, range: {from?: string, to?: string}): Promise<{body: unknown}>}, sessions: {loadToken(): Promise<string>}, log(message: string): void, terminal?: import('../terminal.mjs').TerminalContext}} deps
|
|
16
|
+
*/
|
|
17
|
+
export async function showUsage(options, deps) {
|
|
18
|
+
validateAppKey(options.appKey);
|
|
19
|
+
const range = validateUsageRange(options);
|
|
20
|
+
const token = await deps.sessions.loadToken();
|
|
21
|
+
const result = await deps.api.getUsage(token, options.appKey, range);
|
|
22
|
+
const data = parseUsageResponse(result.body, options);
|
|
23
|
+
if (options.json) printData(data, deps);
|
|
24
|
+
else {
|
|
25
|
+
const renderer = rendererFor(deps);
|
|
26
|
+
const sections = [renderer.heading("Application usage"), renderer.fields([
|
|
27
|
+
["App Key", data.appKey], ["From", data.from], ["To", data.to],
|
|
28
|
+
]), renderer.table(["OPERATION", "REQUESTS", "SUCCESS", "REJECTED", "ERROR"], OPERATIONS.map((operation, index) => {
|
|
29
|
+
const counts = data.operations[operation];
|
|
30
|
+
return [OPERATION_LABELS[index], ...[counts.requests, counts.success, counts.rejected, counts.error].map(String)];
|
|
31
|
+
}))];
|
|
32
|
+
if (OPERATIONS.every(operation => data.operations[operation].requests === 0)) sections.push(renderer.paragraph("No recorded requests."));
|
|
33
|
+
sections.push(renderer.paragraph("Approximate monitoring counts. Data may be delayed or incomplete; these are not billing totals."),
|
|
34
|
+
renderer.paragraph("Usage reports cover API v2 requests only. API v1 activity is not included."),
|
|
35
|
+
renderer.paragraph("Success: challenges created, successful verifies with tokens issued, and tokens accepted for the first time. Retries count separately; verifies are not unique challenges. Unattributed requests are excluded."));
|
|
36
|
+
deps.log(sections.join("\n\n"));
|
|
37
|
+
}
|
|
38
|
+
return data;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Build an allowlisted response, never forwarding unexpected server fields.
|
|
42
|
+
* @param {unknown} body @param {{appKey: string, from?: string, to?: string}} options
|
|
43
|
+
*/
|
|
44
|
+
export function parseUsageResponse(body, options) {
|
|
45
|
+
const data = responseObject(responseObject(body).data);
|
|
46
|
+
const from = utcTimestamp(data.from), to = utcTimestamp(data.to);
|
|
47
|
+
if (data.appKey !== options.appKey || data.approximate !== true || from === undefined || to === undefined || from >= to || to - from > 7 * DAY_MS) throw invalidUsage();
|
|
48
|
+
if (options.from !== undefined && (from !== utcTimestamp(options.from) || to !== utcTimestamp(options.to))) throw invalidUsage();
|
|
49
|
+
if (options.from === undefined && to - from !== DAY_MS) throw invalidUsage();
|
|
50
|
+
const operations = responseObject(data.operations);
|
|
51
|
+
if (Object.keys(operations).length !== OPERATIONS.length || OPERATIONS.some(operation => !Object.hasOwn(operations, operation))) throw invalidUsage();
|
|
52
|
+
return {
|
|
53
|
+
appKey: options.appKey, from: new Date(from).toISOString(), to: new Date(to).toISOString(), approximate: /** @type {const} */ (true),
|
|
54
|
+
operations: {
|
|
55
|
+
challenge_create: parseCounts(operations.challenge_create),
|
|
56
|
+
challenge_verify: parseCounts(operations.challenge_verify),
|
|
57
|
+
token_consume: parseCounts(operations.token_consume),
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @param {unknown} value @returns {OperationCounts} */
|
|
63
|
+
function parseCounts(value) {
|
|
64
|
+
const counts = responseObject(value);
|
|
65
|
+
const {requests, success, rejected, error} = counts;
|
|
66
|
+
if (!validCount(requests) || !validCount(success) || !validCount(rejected) || !validCount(error) || requests !== success + rejected + error) throw invalidUsage();
|
|
67
|
+
return {requests, success, rejected, error};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** @param {unknown} value @returns {value is number} */
|
|
71
|
+
function validCount(value) { return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER; }
|
|
72
|
+
function invalidUsage() { return invalidResponse("The usage response did not match the requested app, time range or count schema"); }
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { parseEnv } from "node:util";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { CliError } from "./errors.mjs";
|
|
7
|
+
import { getPackageProfile } from "./package-profile.mjs";
|
|
8
|
+
|
|
9
|
+
/** @typedef {'qat' | 'stg' | 'prod'} Profile */
|
|
10
|
+
const PROFILES = new Set(["qat", "stg", "prod"]);
|
|
11
|
+
const CONFIG_KEYS = new Set(["API_URL", "CHALLENGE_URL", "DOCS_URL", "OAUTH_ISSUER", "OAUTH_CLIENT_ID", "OAUTH_AUTHORIZATION_ISSUER"]);
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_MAVEN_REPOSITORY =
|
|
14
|
+
"https://sdk.intellifend.com/packages/maven";
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_BROWSER_SCRIPT_URL =
|
|
17
|
+
"https://intellifend.ai/intellifend.js";
|
|
18
|
+
|
|
19
|
+
export const REQUESTED_OAUTH_SCOPES = Object.freeze([
|
|
20
|
+
"offline_access",
|
|
21
|
+
"theair:applications:read",
|
|
22
|
+
"theair:applications:write",
|
|
23
|
+
"theair:secrets:read",
|
|
24
|
+
"theair:secrets:write",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
/** @typedef {{issuer: string, clientId: string, apiUrl: string}} OAuthConfig */
|
|
28
|
+
|
|
29
|
+
/** Read only the selected package file; never load settings into process.env.
|
|
30
|
+
* @param {Profile} [profile] @returns {OAuthConfig}
|
|
31
|
+
*/
|
|
32
|
+
export function getOAuthConfig(profile = "prod") {
|
|
33
|
+
const values = readProfileConfig(profile);
|
|
34
|
+
if (!values.OAUTH_ISSUER || !values.OAUTH_CLIENT_ID) {
|
|
35
|
+
throw invalidConfig("RequestShield sign-in is unavailable. Update the CLI and try again; if the problem continues, contact support.");
|
|
36
|
+
}
|
|
37
|
+
return validateOAuthConfig({issuer: values.OAUTH_ISSUER, clientId: values.OAUTH_CLIENT_ID, apiUrl: configuredApiUrl(values, profile)});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Callback-only issuer pin; it never selects discovery or token endpoints.
|
|
41
|
+
* @param {Profile | undefined} profile @param {string} fallbackIssuer
|
|
42
|
+
*/
|
|
43
|
+
export function getAuthorizationIssuer(profile, fallbackIssuer) {
|
|
44
|
+
const value = readProfileConfig(profile ?? "prod").OAUTH_AUTHORIZATION_ISSUER;
|
|
45
|
+
return value ? validateAuthorizationIssuer(value) : fallbackIssuer;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Preserve exact provider identifiers, including non-URL Stytch project IDs.
|
|
49
|
+
* @param {unknown} value @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
export function validateAuthorizationIssuer(value) {
|
|
52
|
+
if (typeof value !== "string" || !/^[\x21-\x7e]{1,2048}$/.test(value)) {
|
|
53
|
+
throw invalidConfig("RequestShield sign-in is unavailable. Update the CLI and try again; if the problem continues, contact support.");
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Validate injected OAuth clients as well as file-based configuration.
|
|
59
|
+
* @param {OAuthConfig} config @returns {OAuthConfig}
|
|
60
|
+
*/
|
|
61
|
+
export function validateOAuthConfig({issuer: issuerValue, clientId, apiUrl}) {
|
|
62
|
+
const issuer = validatedUrl(issuerValue, "OAUTH_ISSUER");
|
|
63
|
+
if (new URL(issuer).protocol !== "https:") throw invalidConfig("RequestShield sign-in is unavailable. Update the CLI and try again; if the problem continues, contact support.");
|
|
64
|
+
if (typeof clientId !== "string" || !/^[A-Za-z0-9._~-]{1,256}$/.test(clientId)) {
|
|
65
|
+
throw invalidConfig("RequestShield sign-in is unavailable. Update the CLI and try again; if the problem continues, contact support.");
|
|
66
|
+
}
|
|
67
|
+
return {issuer, clientId, apiUrl: validatedUrl(apiUrl, "API_URL")};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Shared API selection for signin binding and every authenticated command.
|
|
71
|
+
* @param {Profile} [profile]
|
|
72
|
+
*/
|
|
73
|
+
export function getApiUrl(profile = "prod") {
|
|
74
|
+
return configuredApiUrl(readProfileConfig(profile), profile);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Validate both public liveness targets before either request is dispatched.
|
|
78
|
+
* OAuth and documentation settings are not needed. @param {Profile} [profile]
|
|
79
|
+
*/
|
|
80
|
+
export function getServiceConfig(profile = "prod") {
|
|
81
|
+
let values;
|
|
82
|
+
try { values = readProfileConfig(profile); }
|
|
83
|
+
catch (error) {
|
|
84
|
+
throw new CliError(error instanceof CliError ? error.message : "Cannot read the RequestShield service configuration", {code: "SERVICE_CONFIG_INVALID", exitCode: 2});
|
|
85
|
+
}
|
|
86
|
+
return {apiUrl: validateServiceUrl(values.API_URL, "API_URL"), challengeUrl: validateServiceUrl(values.CHALLENGE_URL, "CHALLENGE_URL")};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** @param {unknown} value @param {'API_URL' | 'CHALLENGE_URL'} name */
|
|
90
|
+
export function validateServiceUrl(value, name) {
|
|
91
|
+
try {
|
|
92
|
+
if (typeof value !== "string" || !/^[\x21-\x7e]+$/.test(value) || /[\\?#]/.test(value)) throw new Error();
|
|
93
|
+
return validatedUrl(value, name);
|
|
94
|
+
} catch {
|
|
95
|
+
throw new CliError("Service status is unavailable. Update the CLI or contact support.", {code: "SERVICE_CONFIG_INVALID", exitCode: 2});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Public documentation selection is independent of API and OAuth settings.
|
|
100
|
+
* @param {Profile} [profile]
|
|
101
|
+
*/
|
|
102
|
+
export function getDocsUrl(profile = "prod") {
|
|
103
|
+
let values;
|
|
104
|
+
try { values = readProfileConfig(profile); }
|
|
105
|
+
catch (error) {
|
|
106
|
+
throw new CliError(error instanceof CliError ? error.message : "Cannot read the RequestShield documentation configuration", {code: "CONTRACT_CONFIG_INVALID", exitCode: 2});
|
|
107
|
+
}
|
|
108
|
+
if (!values.DOCS_URL) {
|
|
109
|
+
throw new CliError("Integration settings are unavailable. Update the CLI or contact support.", {code: "CONTRACT_CONFIG_INVALID", exitCode: 2});
|
|
110
|
+
}
|
|
111
|
+
return validateDocsUrl(values.DOCS_URL);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** @param {unknown} value */
|
|
115
|
+
export function validateDocsUrl(value) {
|
|
116
|
+
try {
|
|
117
|
+
if (typeof value !== "string" || !/^[\x21-\x7e]+$/.test(value) || /[\\?#]/.test(value)) throw new Error();
|
|
118
|
+
return validatedUrl(value, "DOCS_URL").replace(/\/+$/, "");
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
throw new CliError("Integration settings are unavailable. Update the CLI or contact support.", {code: "CONTRACT_CONFIG_INVALID", exitCode: 2});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** @param {Record<string, string | undefined>} values @param {Profile} profile */
|
|
126
|
+
function configuredApiUrl(values, profile) {
|
|
127
|
+
if (!values.API_URL) {
|
|
128
|
+
throw invalidConfig("RequestShield sign-in is unavailable. Update the CLI and try again; if the problem continues, contact support.");
|
|
129
|
+
}
|
|
130
|
+
return validatedUrl(values.API_URL, "API_URL");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** @param {Profile} [profile] */
|
|
134
|
+
export function getCommandName(profile = getPackageProfile()) {
|
|
135
|
+
const packageProfile = getPackageProfile();
|
|
136
|
+
validateProfile(profile);
|
|
137
|
+
return profile === packageProfile ? "requestshield" : `requestshield-${profile}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Keep suggested commands runnable from either supported source directory.
|
|
141
|
+
* @param {Profile} [profile] @param {string} [cwd]
|
|
142
|
+
*/
|
|
143
|
+
export function getCommandInvocation(profile = getPackageProfile(), cwd = process.cwd()) {
|
|
144
|
+
const command = getCommandName(profile);
|
|
145
|
+
if (command === "requestshield") return command;
|
|
146
|
+
const packageRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
147
|
+
return path.resolve(cwd) === packageRoot ? `npx --prefix ./dev ${command}` : `npx ${command}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** @param {Profile} profile */
|
|
151
|
+
function validateProfile(profile) {
|
|
152
|
+
if (!PROFILES.has(profile)) throw invalidConfig("RequestShield sign-in is unavailable. Update the CLI and try again; if the problem continues, contact support.");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** @param {Profile} profile @returns {Record<string, string | undefined>} */
|
|
156
|
+
function readProfileConfig(profile) {
|
|
157
|
+
validateProfile(profile);
|
|
158
|
+
let values;
|
|
159
|
+
try {
|
|
160
|
+
values = parseEnv(readFileSync(new URL(`../config/.env.${profile}`, import.meta.url), "utf8"));
|
|
161
|
+
} catch {
|
|
162
|
+
throw invalidConfig("RequestShield sign-in is unavailable. Update the CLI and try again; if the problem continues, contact support.");
|
|
163
|
+
}
|
|
164
|
+
if (Object.keys(values).some(key => !CONFIG_KEYS.has(key))) {
|
|
165
|
+
throw invalidConfig("RequestShield sign-in is unavailable. Update the CLI and try again; if the problem continues, contact support.");
|
|
166
|
+
}
|
|
167
|
+
return values;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** @param {unknown} value @returns {string} */
|
|
171
|
+
export function validateBearerToken(value) {
|
|
172
|
+
if (typeof value !== "string" || value.length > 16_384 || !/^[A-Za-z0-9._~+/-]+=*$/.test(value)) {
|
|
173
|
+
throw new CliError("The access token is invalid; run `requestshield login` again", {
|
|
174
|
+
code: "INVALID_ACCESS_TOKEN", exitCode: 3,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return value;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** @param {URL} url */
|
|
181
|
+
export function isLoopbackUrl(url) {
|
|
182
|
+
return url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** @param {unknown} value @param {string} name */
|
|
186
|
+
export function validatedUrl(value, name) {
|
|
187
|
+
try {
|
|
188
|
+
if (typeof value !== "string" || value.length > 2048 || /\s/.test(value)) throw new Error();
|
|
189
|
+
const url = new URL(value);
|
|
190
|
+
if (url.username || url.password || url.search || url.hash || (url.protocol !== "https:" && !isLoopbackUrl(url))) throw new Error();
|
|
191
|
+
return url.href.replace(/\/$/, "");
|
|
192
|
+
} catch {
|
|
193
|
+
throw invalidConfig("RequestShield sign-in is unavailable. Update the CLI and try again; if the problem continues, contact support.");
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** @param {string} message */
|
|
198
|
+
function invalidConfig(message) {
|
|
199
|
+
return new CliError(message, { code: "OAUTH_CONFIG_INVALID", exitCode: 2 });
|
|
200
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { run } from "./cli.mjs";
|
|
3
|
+
import { CliError } from "./errors.mjs";
|
|
4
|
+
import { CommandCompletion } from "./command-completion.mjs";
|
|
5
|
+
import { getCommandInvocation, getCommandName } from "./config.mjs";
|
|
6
|
+
import { rendererFor, resolveTerminal } from "./terminal.mjs";
|
|
7
|
+
import { getPackageProfile } from "./package-profile.mjs";
|
|
8
|
+
|
|
9
|
+
/** Render exactly one result document on JSON failures. Unknown failures are redacted.
|
|
10
|
+
* @param {string[]} argv @param {Parameters<typeof run>[1]} [deps] */
|
|
11
|
+
export async function execute(argv, deps = {}) {
|
|
12
|
+
let command = "requestshield";
|
|
13
|
+
let invocation = command;
|
|
14
|
+
try {
|
|
15
|
+
const profile = deps.profile ?? getPackageProfile();
|
|
16
|
+
command = getCommandName(profile);
|
|
17
|
+
invocation = getCommandInvocation(profile);
|
|
18
|
+
const result = await run(argv, {...deps, profile});
|
|
19
|
+
return result instanceof CommandCompletion ? result.exitCode : 0;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
const known = error instanceof CliError;
|
|
22
|
+
const message = (known ? error.message : "The command failed unexpectedly. Please try again.")
|
|
23
|
+
.replaceAll("`requestshield login`", `\`${invocation} login\``)
|
|
24
|
+
.replaceAll("`requestshield logout`", `\`${invocation} logout\``)
|
|
25
|
+
.replaceAll("Usage: requestshield ", `Usage: ${invocation} `)
|
|
26
|
+
.replace(/^ requestshield /gm, ` ${invocation} `);
|
|
27
|
+
const code = known ? error.code : "CLI_ERROR";
|
|
28
|
+
if (argv.includes("--json")) (deps.log ?? console.log)(JSON.stringify({error: {code, message}}, null, 2));
|
|
29
|
+
else {
|
|
30
|
+
const renderer = rendererFor({terminal: resolveTerminal(deps)}, "stderr");
|
|
31
|
+
(deps.warn ?? console.error)([
|
|
32
|
+
renderer.heading(command),
|
|
33
|
+
renderer.message("error", message),
|
|
34
|
+
renderer.muted(`Code: ${code}`),
|
|
35
|
+
].join("\n"));
|
|
36
|
+
}
|
|
37
|
+
return known ? error.exitCode : 1;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @param {import('./config.mjs').Profile} [profile] */
|
|
42
|
+
export async function main(profile) {
|
|
43
|
+
process.exitCode = await execute(process.argv.slice(2), {profile});
|
|
44
|
+
}
|
package/src/errors.mjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
export class CliError extends Error {
|
|
4
|
+
/**
|
|
5
|
+
* @param {string} message
|
|
6
|
+
* @param {{ code?: string, exitCode?: number, httpStatus?: number, apiErrorCode?: string }} [options]
|
|
7
|
+
*/
|
|
8
|
+
constructor(message, options = {}) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "CliError";
|
|
11
|
+
this.code = options.code ?? "CLI_ERROR";
|
|
12
|
+
this.exitCode = options.exitCode ?? 1;
|
|
13
|
+
this.httpStatus = options.httpStatus;
|
|
14
|
+
this.apiErrorCode = options.apiErrorCode;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { CliError } from "./errors.mjs";
|
|
3
|
+
import { validateDocsUrl } from "./config.mjs";
|
|
4
|
+
import { validateIntegrationContract } from "./integration-contract.mjs";
|
|
5
|
+
|
|
6
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
/** Fetch only public metadata. Never attach OAuth credentials or use Management APIs. */
|
|
9
|
+
export class IntegrationContractClient {
|
|
10
|
+
/** @param {{docsUrl: string, fetchImpl?: typeof fetch, timeoutMs?: number}} options */
|
|
11
|
+
constructor({docsUrl, fetchImpl = fetch, timeoutMs = 15_000}) {
|
|
12
|
+
this.url = `${validateDocsUrl(docsUrl)}/integration-contract.json`;
|
|
13
|
+
this.fetchImpl = fetchImpl;
|
|
14
|
+
this.timeoutMs = timeoutMs;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async getContract() {
|
|
18
|
+
const controller = new AbortController();
|
|
19
|
+
/** @type {ReadableStreamDefaultReader<Uint8Array> | undefined} */
|
|
20
|
+
let reader;
|
|
21
|
+
let expired = false;
|
|
22
|
+
/** @type {NodeJS.Timeout | undefined} */
|
|
23
|
+
let timer;
|
|
24
|
+
const deadline = new Promise((_, reject) => {
|
|
25
|
+
timer = setTimeout(() => {
|
|
26
|
+
expired = true;
|
|
27
|
+
controller.abort();
|
|
28
|
+
reject(timeout());
|
|
29
|
+
}, this.timeoutMs);
|
|
30
|
+
});
|
|
31
|
+
try {
|
|
32
|
+
const operation = (async () => {
|
|
33
|
+
const response = await this.fetchImpl(this.url, {
|
|
34
|
+
method: "GET", headers: {Accept: "application/json"},
|
|
35
|
+
credentials: "omit", redirect: "error", cache: "no-store", signal: controller.signal,
|
|
36
|
+
});
|
|
37
|
+
if (expired) { void response.body?.cancel().catch(() => {}); throw timeout(); }
|
|
38
|
+
if (!response.ok) {
|
|
39
|
+
void response.body?.cancel().catch(() => {});
|
|
40
|
+
throw new CliError(response.status === 404 ? "The public integration contract is not available yet (HTTP 404)." : `The public integration contract request failed: HTTP ${response.status}.`, {code: response.status === 404 ? "CONTRACT_NOT_PUBLISHED" : "CONTRACT_FETCH_FAILED", exitCode: 1, httpStatus: response.status});
|
|
41
|
+
}
|
|
42
|
+
const mediaType = response.headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase();
|
|
43
|
+
if (!mediaType || !/^application\/(?:json|[a-z0-9!#$&^_.+-]+\+json)$/.test(mediaType)
|
|
44
|
+
|| Number(response.headers.get("content-length")) > MAX_BODY_BYTES) {
|
|
45
|
+
void response.body?.cancel().catch(() => {});
|
|
46
|
+
throw invalidResponse();
|
|
47
|
+
}
|
|
48
|
+
const chunks = [];
|
|
49
|
+
let size = 0;
|
|
50
|
+
if (response.body) {
|
|
51
|
+
reader = response.body.getReader();
|
|
52
|
+
while (true) {
|
|
53
|
+
const {done, value} = await reader.read();
|
|
54
|
+
if (expired) throw timeout();
|
|
55
|
+
if (done) break;
|
|
56
|
+
size += value.byteLength;
|
|
57
|
+
if (size > MAX_BODY_BYTES) throw invalidResponse();
|
|
58
|
+
chunks.push(value);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const body = JSON.parse(new TextDecoder("utf-8", {fatal: true}).decode(Buffer.concat(chunks)));
|
|
63
|
+
return validateIntegrationContract(body);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (error instanceof Error && error.message === 'Unsupported integration contract schema') {
|
|
66
|
+
throw new CliError('Unsupported integration contract schema. Update the RequestShield CLI and try again. If the problem continues, contact support.', {code: 'CONTRACT_INVALID', exitCode: 1});
|
|
67
|
+
}
|
|
68
|
+
throw invalidResponse();
|
|
69
|
+
}
|
|
70
|
+
})();
|
|
71
|
+
return await Promise.race([operation, /** @type {Promise<never>} */ (deadline)]);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (expired) throw timeout();
|
|
74
|
+
if (error instanceof CliError) throw error;
|
|
75
|
+
throw new CliError("Could not retrieve the public integration contract. Check connectivity and try again.", {code: "CONTRACT_FETCH_FAILED", exitCode: 1});
|
|
76
|
+
} finally {
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
if (reader) void reader.cancel().catch(() => {});
|
|
79
|
+
controller.abort();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function timeout() { return new CliError("The public integration contract request timed out. Check connectivity and try again.", {code: "CONTRACT_FETCH_FAILED", exitCode: 1}); }
|
|
85
|
+
function invalidResponse() { return new CliError("The public integration contract is invalid or too large. Try again; if the problem continues, contact support.", {code: "CONTRACT_INVALID", exitCode: 1}); }
|