requestshield 0.1.5 → 0.1.6

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.
Files changed (39) hide show
  1. package/README.md +414 -276
  2. package/config/.env.prod +7 -0
  3. package/package.json +8 -5
  4. package/skills/requestshield/SKILL.md +55 -63
  5. package/skills/requestshield/assets/AGENTS.codex.md +17 -17
  6. package/skills/requestshield/references/backend-java-core.md +3 -3
  7. package/skills/requestshield/references/backend-spring-boot.md +3 -3
  8. package/skills/requestshield/references/browser-manual.md +4 -4
  9. package/skills/requestshield/references/browser-seamless.md +7 -15
  10. package/skills/requestshield/references/cli.md +93 -169
  11. package/skills/requestshield/references/integration-planning.md +20 -47
  12. package/skills/requestshield/references/troubleshooting.md +26 -30
  13. package/src/api-client.mjs +106 -165
  14. package/src/args.mjs +108 -151
  15. package/src/browser-opener.mjs +32 -0
  16. package/src/cli.mjs +50 -28
  17. package/src/commands/agent-setup.mjs +34 -37
  18. package/src/commands/application-mutations.mjs +33 -0
  19. package/src/commands/application-response.mjs +55 -0
  20. package/src/commands/apps-get.mjs +3 -47
  21. package/src/commands/apps-list.mjs +40 -36
  22. package/src/commands/auth-status.mjs +37 -0
  23. package/src/commands/keys-create.mjs +7 -38
  24. package/src/commands/mutation-support.mjs +110 -0
  25. package/src/commands/secret-commands.mjs +45 -0
  26. package/src/commands/signin.mjs +70 -57
  27. package/src/commands/signout.mjs +9 -0
  28. package/src/commands/update-check.mjs +12 -4
  29. package/src/config.mjs +145 -3
  30. package/src/entrypoint.mjs +24 -0
  31. package/src/errors.mjs +3 -1
  32. package/src/main.mjs +2 -21
  33. package/src/oauth-client.mjs +153 -0
  34. package/src/oauth-loopback.mjs +120 -0
  35. package/src/session-files.mjs +213 -0
  36. package/src/session-store.mjs +177 -64
  37. package/src/commands/billing-get.mjs +0 -110
  38. package/src/commands/challenge-volume.mjs +0 -81
  39. package/src/commands/contract.mjs +0 -106
@@ -0,0 +1,110 @@
1
+ // @ts-check
2
+ import { randomUUID } from "node:crypto";
3
+ import readline from "node:readline/promises";
4
+ import { stdin, stdout } from "node:process";
5
+ import { CliError } from "../errors.mjs";
6
+ import { getCommandInvocation } from "../config.mjs";
7
+ import { APPLICATION_STATUSES, invalidResponse, responseObject } from "./application-response.mjs";
8
+
9
+ /** @typedef {{body: unknown, status?: number, replayed?: boolean}} MutationResponse */
10
+ /** @typedef {{log: (message: string) => void, warn?: (message: string) => void, profile?: import('../config.mjs').Profile, confirm?: () => Promise<boolean>}} CommandOutput */
11
+
12
+ /** @param {string | undefined} supplied */
13
+ export function mutationKey(supplied) {
14
+ const key = supplied ?? randomUUID();
15
+ if (!/^[A-Za-z0-9._~-]{1,128}$/.test(key)) {
16
+ throw new CliError("Idempotency key must contain 1-128 letters, numbers, '.', '_', '~', or '-'", {code: "INVALID_IDEMPOTENCY_KEY", exitCode: 2});
17
+ }
18
+ return key;
19
+ }
20
+
21
+ /** @param {{yes?: boolean}} options @param {CommandOutput} deps @param {string} prompt @param {string} word */
22
+ export async function confirmAction(options, deps, prompt, word) {
23
+ if (options.yes) return;
24
+ let accepted;
25
+ if (deps.confirm) accepted = await deps.confirm();
26
+ else {
27
+ if (!stdin.isTTY || !stdout.isTTY) {
28
+ throw new CliError("This action requires confirmation; use --yes in a non-interactive terminal", {code: "CONFIRMATION_REQUIRED", exitCode: 2});
29
+ }
30
+ const terminal = readline.createInterface({input: stdin, output: stdout});
31
+ try { accepted = await terminal.question(`${prompt} Type ${word} to continue: `) === word; }
32
+ finally { terminal.close(); }
33
+ }
34
+ if (!accepted) throw new CliError("Action cancelled", {code: "CANCELLED", exitCode: 2});
35
+ }
36
+
37
+ /** The caller wraps dispatch and response validation, never session loading.
38
+ * @template T @param {string} idempotencyKey @param {CommandOutput} deps @param {() => Promise<T>} operation @returns {Promise<T>}
39
+ */
40
+ export async function withMutationRecovery(idempotencyKey, deps, operation) {
41
+ // Print before dispatch so interruption cannot hide the identity needed to retry.
42
+ (deps.warn ?? deps.log)(`Idempotency key: ${idempotencyKey}`);
43
+ try { return await operation(); }
44
+ catch (error) {
45
+ const status = error instanceof CliError ? Reflect.get(error, "httpStatus") : undefined;
46
+ const uncertain = !(error instanceof CliError)
47
+ || ["NETWORK_ERROR", "REQUEST_TIMEOUT", "INVALID_RESPONSE"].includes(error.code)
48
+ || (error.code === "API_ERROR" && (typeof status !== "number" || status >= 500));
49
+ if (!uncertain) throw error;
50
+ throw new CliError(
51
+ `The mutation result could not be confirmed; it may have completed. Retry the same command and arguments with --idempotency-key ${idempotencyKey} within seven days of the original attempt. Do not use a new key to retry. After seven days, inspect the application before taking further action.`,
52
+ {code: "MUTATION_UNCERTAIN", exitCode: 7},
53
+ );
54
+ }
55
+ }
56
+
57
+ /** @param {MutationResponse} result @param {number} expectedStatus */
58
+ export function validateHttpStatus(result, expectedStatus) {
59
+ if (result.status !== undefined && result.status !== expectedStatus) {
60
+ throw invalidResponse("The API returned an unexpected success status");
61
+ }
62
+ }
63
+
64
+ /** @param {MutationResponse} result @param {number} expectedStatus @param {string} [requestedAppKey] */
65
+ export function parseIssuance(result, expectedStatus, requestedAppKey) {
66
+ validateHttpStatus(result, expectedStatus);
67
+ const {appKey, status, apiSecret} = responseObject(result.body);
68
+ if (typeof appKey !== "string" || !/^[A-Za-z0-9._~-]{1,128}$/.test(appKey)
69
+ || (requestedAppKey !== undefined && appKey !== requestedAppKey)
70
+ || typeof status !== "string" || !APPLICATION_STATUSES.has(status)
71
+ || (apiSecret !== null && !isApiSecret(apiSecret))
72
+ || (result.replayed === true && apiSecret !== null)
73
+ || (result.replayed === false && apiSecret === null)) {
74
+ throw invalidResponse("The issuance response did not match the expected application, status and secret contract");
75
+ }
76
+ return {appKey, status, apiSecret};
77
+ }
78
+
79
+ /** @param {unknown} value @returns {value is string} */
80
+ export function isApiSecret(value) {
81
+ return typeof value === "string" && /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/.test(value);
82
+ }
83
+
84
+ /** @param {{appKey: string, status: string, apiSecret: string | null}} issuance @param {CommandOutput} deps */
85
+ export function printIssuance(issuance, deps) {
86
+ const invocation = getCommandInvocation(deps.profile);
87
+ deps.log(`App Key: ${issuance.appKey}`);
88
+ deps.log(`Status: ${issuance.status}`);
89
+ if (issuance.apiSecret === null) {
90
+ deps.log(`This is an idempotency replay; the API did not return a secret. Run \`${invocation} keys reveal ${issuance.appKey}\` to retrieve the current active secret.`);
91
+ } else {
92
+ deps.log(`Secret Key: ${issuance.apiSecret}`);
93
+ deps.log("Store this secret in your backend secret manager. The CLI did not save it.");
94
+ }
95
+ deps.log("Configuration publication is asynchronous; this response does not confirm propagation.");
96
+ }
97
+
98
+ /** @param {MutationResponse} result */
99
+ export function parseAccepted(result) {
100
+ validateHttpStatus(result, 202);
101
+ if (responseObject(result.body).status !== "accepted") {
102
+ throw invalidResponse("The mutation response did not acknowledge acceptance");
103
+ }
104
+ }
105
+
106
+ /** @param {string} action @param {string} appKey @param {CommandOutput} deps */
107
+ export function printAccepted(action, appKey, deps) {
108
+ deps.log(`${action} accepted for ${appKey}. Configuration publication is asynchronous.`);
109
+ deps.log(`Run \`${getCommandInvocation(deps.profile)} apps get ${appKey}\` to check the current application status.`);
110
+ }
@@ -0,0 +1,45 @@
1
+ // @ts-check
2
+ import { invalidResponse, responseObject } from "./application-response.mjs";
3
+ import { confirmAction, isApiSecret, mutationKey, parseAccepted, parseIssuance, printAccepted, printIssuance, validateHttpStatus, withMutationRecovery } from "./mutation-support.mjs";
4
+
5
+ /** @typedef {import('./mutation-support.mjs').MutationResponse} MutationResponse */
6
+ /** @typedef {import('./mutation-support.mjs').CommandOutput & {sessions: {loadToken(): Promise<string>}}} Dependencies */
7
+ /** @typedef {{appKey: string, yes?: boolean, idempotencyKey?: string}} MutationOptions */
8
+
9
+ /** @param {MutationOptions} options
10
+ * @param {Dependencies & {api: {rotateSecret(token: string, appKey: string, options: {idempotencyKey: string}): Promise<MutationResponse>}}} deps
11
+ */
12
+ export async function rotateSecret(options, deps) {
13
+ await confirmAction(options, deps, `Replace the current secret for ${options.appKey}? Existing backend configuration will need the new secret.`, "ROTATE");
14
+ const idempotencyKey = mutationKey(options.idempotencyKey);
15
+ const token = await deps.sessions.loadToken();
16
+ const issuance = await withMutationRecovery(idempotencyKey, deps, async () =>
17
+ parseIssuance(await deps.api.rotateSecret(token, options.appKey, {idempotencyKey}), 200, options.appKey));
18
+ printIssuance(issuance, deps);
19
+ }
20
+
21
+ /** @param {{appKey: string, yes?: boolean}} options
22
+ * @param {Dependencies & {api: {revealSecret(token: string, appKey: string): Promise<MutationResponse>}}} deps
23
+ */
24
+ export async function revealSecret(options, deps) {
25
+ await confirmAction(options, deps, `Display the current API secret for ${options.appKey} in this terminal?`, "REVEAL");
26
+ const token = await deps.sessions.loadToken();
27
+ const result = await deps.api.revealSecret(token, options.appKey);
28
+ validateHttpStatus(result, 200);
29
+ const {apiSecret} = responseObject(result.body);
30
+ if (!isApiSecret(apiSecret)) throw invalidResponse("The reveal response did not contain a valid API secret");
31
+ deps.log(`Secret Key: ${apiSecret}`);
32
+ deps.log("Store this secret in your backend secret manager. The CLI did not save it.");
33
+ }
34
+
35
+ /** @param {MutationOptions} options
36
+ * @param {Dependencies & {api: {revokeSecret(token: string, appKey: string, options: {idempotencyKey: string}): Promise<MutationResponse>}}} deps
37
+ */
38
+ export async function revokeSecret(options, deps) {
39
+ await confirmAction(options, deps, `Revoke the current API secret for ${options.appKey}?`, "REVOKE");
40
+ const idempotencyKey = mutationKey(options.idempotencyKey);
41
+ const token = await deps.sessions.loadToken();
42
+ await withMutationRecovery(idempotencyKey, deps, async () =>
43
+ parseAccepted(await deps.api.revokeSecret(token, options.appKey, {idempotencyKey})));
44
+ printAccepted("Secret revocation", options.appKey, deps);
45
+ }
@@ -1,65 +1,78 @@
1
1
  // @ts-check
2
-
3
- import { setTimeout as delay } from "node:timers/promises";
2
+ import { createHash, randomBytes } from "node:crypto";
4
3
  import { CliError } from "../errors.mjs";
5
- import { objectString } from "../api-client.mjs";
4
+ import { REQUESTED_OAUTH_SCOPES, validateAuthorizationIssuer } from "../config.mjs";
5
+ import { createLoopbackReceiver } from "../oauth-loopback.mjs";
6
+ import { openBrowser } from "../browser-opener.mjs";
6
7
 
7
- /**
8
- * @param {{ api: { startSignin(): Promise<{body: unknown}>, pollSignin(deviceCode: string): Promise<{ok: boolean, body: unknown}> }, sessions: { save(session: {accessToken: string, account?: unknown}): Promise<void> }, log: (message: string) => void, wait?: (milliseconds: number) => Promise<unknown> }} deps
8
+ /** @typedef {{
9
+ * oauth: Pick<import('../oauth-client.mjs').OAuthClient, 'discover' | 'exchangeCode' | 'config'>,
10
+ * authorizationIssuer?: string,
11
+ * sessions: {save(credentials: import('../oauth-client.mjs').OAuthCredentials): Promise<void>},
12
+ * log: (message: string) => void,
13
+ * openBrowser?: typeof openBrowser,
14
+ * signal?: AbortSignal,
15
+ * timeoutMs?: number,
16
+ * interruptSource?: Pick<NodeJS.Process, 'on' | 'removeListener'>,
17
+ * }} SigninDependencies
9
18
  */
10
- export async function signin(deps) {
11
- const started = await deps.api.startSignin();
12
- const deviceCode = required(started.body, "deviceCode");
13
- const userCode = required(started.body, "userCode");
14
- const verificationUri = required(started.body, "verificationUri");
15
- const expiresIn = positiveNumber(started.body, "expiresIn", 600);
16
- let interval = positiveNumber(started.body, "interval", 5);
17
-
18
- deps.log(`Open ${verificationUri}`);
19
- deps.log(`Enter code: ${userCode}`);
20
19
 
21
- const deadline = Date.now() + expiresIn * 1000;
22
- while (Date.now() < deadline) {
23
- await (deps.wait ?? delay)(interval * 1000);
24
- const result = await deps.api.pollSignin(deviceCode);
25
- const accessToken = objectString(result.body, "accessToken");
26
- if (result.ok && accessToken) {
27
- const account = result.body && typeof result.body === "object" ? Reflect.get(result.body, "account") : undefined;
28
- await deps.sessions.save({ accessToken, account });
29
- deps.log("Signed in successfully. The session is stored for the current user.");
30
- return;
31
- }
32
- const code = objectString(result.body, "code");
33
- if (code === "authorization_pending") continue;
34
- if (code === "slow_down") {
35
- interval += 5;
36
- continue;
37
- }
38
- if (code === "access_denied") {
39
- throw new CliError("Sign-in was denied", { code, exitCode: 3 });
20
+ /** @param {{noOpen?: boolean}} options @param {SigninDependencies} deps */
21
+ export async function signin({noOpen = false}, deps) {
22
+ const authorizationIssuer = validateAuthorizationIssuer(deps.authorizationIssuer ?? deps.oauth.config.issuer);
23
+ const controller = new AbortController();
24
+ let expired = false;
25
+ const interruptSource = deps.interruptSource ?? process;
26
+ const cancel = () => controller.abort();
27
+ const timer = setTimeout(() => { expired = true; cancel(); }, deps.timeoutMs ?? 600_000);
28
+ interruptSource.on("SIGINT", cancel);
29
+ deps.signal?.addEventListener("abort", cancel, {once: true});
30
+ if (deps.signal?.aborted) cancel();
31
+ /** @type {Awaited<ReturnType<typeof createLoopbackReceiver>> | undefined} */
32
+ let receiver;
33
+ try {
34
+ const {signal} = controller;
35
+ const {authorizationEndpoint} = await deps.oauth.discover({signal});
36
+ signal.throwIfAborted();
37
+ const state = randomBytes(32).toString("base64url");
38
+ const verifier = randomBytes(32).toString("base64url");
39
+ receiver = await createLoopbackReceiver({state, issuer: authorizationIssuer, signal});
40
+ const authorizeUrl = new URL(authorizationEndpoint);
41
+ authorizeUrl.search = new URLSearchParams({
42
+ client_id: deps.oauth.config.clientId,
43
+ response_type: "code", redirect_uri: receiver.redirectUri,
44
+ code_challenge: createHash("sha256").update(verifier).digest("base64url"),
45
+ code_challenge_method: "S256", state, scope: REQUESTED_OAUTH_SCOPES.join(" "),
46
+ }).toString();
47
+ if (noOpen) {
48
+ deps.log(`Open this authorization URL in a browser on this computer:\n${authorizeUrl.href}`);
49
+ } else {
50
+ deps.log("Opening your browser to sign in. Complete authorization on this computer.");
51
+ try { await (deps.openBrowser ?? openBrowser)(authorizeUrl.href, {signal}); }
52
+ catch {
53
+ signal.throwIfAborted();
54
+ deps.log(`Open this authorization URL in a browser on this computer:\n${authorizeUrl.href}`);
55
+ }
40
56
  }
41
- if (code === "expired_token") break;
42
- throw new CliError(objectString(result.body, "message") ?? "Sign-in failed", {
43
- code: code ?? "SIGNIN_FAILED",
44
- exitCode: 3,
45
- });
57
+ const {code} = await receiver.result;
58
+ signal.throwIfAborted();
59
+ const credentials = await deps.oauth.exchangeCode({code, verifier, redirectUri: receiver.redirectUri, signal});
60
+ signal.throwIfAborted();
61
+ await deps.sessions.save(credentials);
62
+ signal.throwIfAborted();
63
+ receiver.complete(true);
64
+ deps.log("Signed in successfully. The session is stored for the current user.");
65
+ } catch (error) {
66
+ receiver?.complete(false);
67
+ if (controller.signal.aborted) throw new CliError(expired
68
+ ? "Sign-in expired; run `requestshield signin` again"
69
+ : "Sign-in was cancelled", {code: expired ? "SIGNIN_EXPIRED" : "SIGNIN_CANCELLED", exitCode: 3});
70
+ if (error instanceof CliError) throw error;
71
+ throw new CliError("Sign-in failed; run `requestshield signin` again", {code: "SIGNIN_FAILED", exitCode: 3});
72
+ } finally {
73
+ clearTimeout(timer);
74
+ interruptSource.removeListener("SIGINT", cancel);
75
+ deps.signal?.removeEventListener("abort", cancel);
76
+ await receiver?.close();
46
77
  }
47
- throw new CliError("The sign-in code expired; run `requestshield signin` again", {
48
- code: "SIGNIN_EXPIRED",
49
- exitCode: 3,
50
- });
51
- }
52
-
53
- /** @param {unknown} body @param {string} name */
54
- function required(body, name) {
55
- const value = objectString(body, name);
56
- if (!value) throw new CliError(`Sign-in response is missing ${name}`);
57
- return value;
58
- }
59
-
60
- /** @param {unknown} body @param {string} name @param {number} fallback */
61
- function positiveNumber(body, name, fallback) {
62
- if (!body || typeof body !== "object") return fallback;
63
- const value = Reflect.get(body, name);
64
- return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
65
78
  }
@@ -0,0 +1,9 @@
1
+ // @ts-check
2
+
3
+ /** @param {{}} _options
4
+ * @param {{sessions: {signout(): Promise<{profile: import('../config.mjs').Profile, removed: boolean}>}, log: (message: string) => void}} deps
5
+ */
6
+ export async function signout(_options, deps) {
7
+ const { profile, removed } = await deps.sessions.signout();
8
+ deps.log(removed ? `Signed out locally from ${profile}.` : `No saved local session for ${profile}.`);
9
+ }
@@ -8,9 +8,16 @@ import { CliError } from "../errors.mjs";
8
8
  const DEFAULT_REGISTRY = "https://registry.npmjs.org";
9
9
 
10
10
  /**
11
- * @param {{ currentVersion: string, packageName: string, log: (message: string) => void, fetchImpl?: typeof fetch, confirm?: () => Promise<boolean>, install?: (packageName: string, version: string) => Promise<void> }} options
11
+ * @param {{ currentVersion: string, packageName: string, profile?: "qat" | "stg" | "prod", log: (message: string) => void, fetchImpl?: typeof fetch, confirm?: (latestVersion: string) => Promise<boolean>, install?: (packageName: string, version: string) => Promise<void> }} options
12
12
  */
13
13
  export async function checkForUpdate(options) {
14
+ const profile = options.profile ?? "prod";
15
+ if (profile !== "prod") {
16
+ options.log(`requestshield-${profile} runs this local repository checkout and has no separately published npm release.`);
17
+ options.log("Update the checkout with your normal Git workflow.");
18
+ options.log(`Then run from requestshield-cli/: npx --offline --prefix ./dev requestshield-${profile} <command>`);
19
+ return;
20
+ }
14
21
  const latestVersion = await fetchLatestVersion(
15
22
  options.packageName,
16
23
  options.fetchImpl ?? fetch,
@@ -24,15 +31,16 @@ export async function checkForUpdate(options) {
24
31
  return;
25
32
  }
26
33
 
34
+ options.log(`Update target: global npm installation of ${options.packageName}.`);
27
35
  const accepted = await (options.confirm ?? confirmUpdate)(latestVersion);
28
36
  if (!accepted) {
29
37
  options.log("Update cancelled.");
30
38
  return;
31
39
  }
32
40
 
33
- options.log(`Updating RequestShield to ${latestVersion}...`);
41
+ options.log(`Installing RequestShield ${latestVersion} globally with npm...`);
34
42
  await (options.install ?? installWithNpm)(options.packageName, latestVersion);
35
- options.log(`RequestShield ${latestVersion} was installed successfully.`);
43
+ options.log(`RequestShield ${latestVersion} was installed successfully (global npm installation).`);
36
44
  options.log("Open a new terminal before running RequestShield again.");
37
45
  }
38
46
 
@@ -76,7 +84,7 @@ async function confirmUpdate(latestVersion) {
76
84
  }
77
85
  const prompt = readline.createInterface({ input: stdin, output: stdout });
78
86
  try {
79
- const answer = await prompt.question(`Update to RequestShield ${latestVersion}? (y/N): `);
87
+ const answer = await prompt.question(`Install RequestShield ${latestVersion} globally with npm? (y/N): `);
80
88
  return answer.trim().toLowerCase() === "y";
81
89
  } finally {
82
90
  prompt.close();
package/src/config.mjs CHANGED
@@ -1,8 +1,150 @@
1
- export const DEFAULT_MANAGEMENT_API_URL =
2
- "https://api.intellifend.ai";
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
+
8
+ /** @typedef {'qat' | 'stg' | 'prod'} Profile */
9
+ const PROFILES = new Set(["qat", "stg", "prod"]);
10
+ const CONFIG_KEYS = new Set(["API_URL", "OAUTH_ISSUER", "OAUTH_CLIENT_ID", "OAUTH_AUTHORIZATION_ISSUER"]);
3
11
 
4
12
  export const DEFAULT_MAVEN_REPOSITORY =
5
13
  "https://sdk.intellifend.com/packages/maven";
6
14
 
7
15
  export const DEFAULT_BROWSER_SCRIPT_URL =
8
- "https://intellifend.ai/intellifend.js";
16
+ "https://intellifend.ai/intellifend.js";
17
+
18
+ export const REQUESTED_OAUTH_SCOPES = Object.freeze([
19
+ "offline_access",
20
+ "theair:applications:read",
21
+ "theair:applications:write",
22
+ "theair:secrets:read",
23
+ "theair:secrets:write",
24
+ ]);
25
+
26
+ /** @typedef {{issuer: string, clientId: string, apiUrl: string}} OAuthConfig */
27
+
28
+ /** Read only the selected package file; never load settings into process.env.
29
+ * @param {Profile} [profile] @returns {OAuthConfig}
30
+ */
31
+ export function getOAuthConfig(profile = "prod") {
32
+ const values = readProfileConfig(profile);
33
+ if (!values.OAUTH_ISSUER || !values.OAUTH_CLIENT_ID) {
34
+ throw invalidConfig(`OAuth is not configured for ${getCommandName(profile)}. Set OAUTH_ISSUER and OAUTH_CLIENT_ID in config/.env.${profile}.`);
35
+ }
36
+ return validateOAuthConfig({issuer: values.OAUTH_ISSUER, clientId: values.OAUTH_CLIENT_ID, apiUrl: configuredApiUrl(values, profile)});
37
+ }
38
+
39
+ /** Callback-only issuer pin; it never selects discovery or token endpoints.
40
+ * @param {Profile | undefined} profile @param {string} fallbackIssuer
41
+ */
42
+ export function getAuthorizationIssuer(profile, fallbackIssuer) {
43
+ const value = readProfileConfig(profile ?? "prod").OAUTH_AUTHORIZATION_ISSUER;
44
+ return value ? validateAuthorizationIssuer(value) : fallbackIssuer;
45
+ }
46
+
47
+ /** Preserve exact provider identifiers, including non-URL Stytch project IDs.
48
+ * @param {unknown} value @returns {string}
49
+ */
50
+ export function validateAuthorizationIssuer(value) {
51
+ if (typeof value !== "string" || !/^[\x21-\x7e]{1,2048}$/.test(value)) {
52
+ throw invalidConfig("OAUTH_AUTHORIZATION_ISSUER must be a nonempty identifier of at most 2048 printable ASCII characters without spaces");
53
+ }
54
+ return value;
55
+ }
56
+
57
+ /** Validate injected OAuth clients as well as file-based configuration.
58
+ * @param {OAuthConfig} config @returns {OAuthConfig}
59
+ */
60
+ export function validateOAuthConfig({issuer: issuerValue, clientId, apiUrl}) {
61
+ const issuer = validatedUrl(issuerValue, "OAUTH_ISSUER");
62
+ if (new URL(issuer).protocol !== "https:") throw invalidConfig("OAUTH_ISSUER must use HTTPS");
63
+ if (typeof clientId !== "string" || !/^[A-Za-z0-9._~-]{1,256}$/.test(clientId)) {
64
+ throw invalidConfig("Set OAUTH_CLIENT_ID to the public Connected App client ID");
65
+ }
66
+ return {issuer, clientId, apiUrl: validatedUrl(apiUrl, "API_URL")};
67
+ }
68
+
69
+ /** Shared API selection for signin binding and every authenticated command.
70
+ * @param {Profile} [profile]
71
+ */
72
+ export function getApiUrl(profile = "prod") {
73
+ return configuredApiUrl(readProfileConfig(profile), profile);
74
+ }
75
+
76
+ /** @param {Record<string, string | undefined>} values @param {Profile} profile */
77
+ function configuredApiUrl(values, profile) {
78
+ if (!values.API_URL) {
79
+ throw invalidConfig(`The Management API is not configured for ${getCommandName(profile)}. Set API_URL in config/.env.${profile}.`);
80
+ }
81
+ return validatedUrl(values.API_URL, "API_URL");
82
+ }
83
+
84
+ /** @param {Profile} [profile] */
85
+ export function getCommandName(profile = "prod") {
86
+ validateProfile(profile);
87
+ return profile === "prod" ? "requestshield" : `requestshield-${profile}`;
88
+ }
89
+
90
+ /** Keep suggested commands runnable from either supported source directory.
91
+ * @param {Profile} [profile] @param {string} [cwd]
92
+ */
93
+ export function getCommandInvocation(profile = "prod", cwd = process.cwd()) {
94
+ const command = getCommandName(profile);
95
+ if (profile === "prod") return command;
96
+ const packageRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
97
+ return path.resolve(cwd) === packageRoot ? `npx --prefix ./dev ${command}` : `npx ${command}`;
98
+ }
99
+
100
+ /** @param {Profile} profile */
101
+ function validateProfile(profile) {
102
+ if (!PROFILES.has(profile)) throw invalidConfig("Unknown RequestShield environment profile");
103
+ }
104
+
105
+ /** @param {Profile} profile @returns {Record<string, string | undefined>} */
106
+ function readProfileConfig(profile) {
107
+ validateProfile(profile);
108
+ let values;
109
+ try {
110
+ values = parseEnv(readFileSync(new URL(`../config/.env.${profile}`, import.meta.url), "utf8"));
111
+ } catch {
112
+ throw invalidConfig(`Cannot read config/.env.${profile} for ${getCommandName(profile)}. Restore the package configuration file.`);
113
+ }
114
+ if (Object.keys(values).some(key => !CONFIG_KEYS.has(key))) {
115
+ throw invalidConfig(`config/.env.${profile} accepts only API_URL, OAUTH_ISSUER, OAUTH_CLIENT_ID and OAUTH_AUTHORIZATION_ISSUER; do not store credentials in this file.`);
116
+ }
117
+ return values;
118
+ }
119
+
120
+ /** @param {unknown} value @returns {string} */
121
+ export function validateBearerToken(value) {
122
+ if (typeof value !== "string" || value.length > 16_384 || !/^[A-Za-z0-9._~+/-]+=*$/.test(value)) {
123
+ throw new CliError("The access token is invalid; run `requestshield signin` again", {
124
+ code: "INVALID_ACCESS_TOKEN", exitCode: 3,
125
+ });
126
+ }
127
+ return value;
128
+ }
129
+
130
+ /** @param {URL} url */
131
+ export function isLoopbackUrl(url) {
132
+ return url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
133
+ }
134
+
135
+ /** @param {unknown} value @param {string} name */
136
+ export function validatedUrl(value, name) {
137
+ try {
138
+ if (typeof value !== "string" || value.length > 2048 || /\s/.test(value)) throw new Error();
139
+ const url = new URL(value);
140
+ if (url.username || url.password || url.search || url.hash || (url.protocol !== "https:" && !isLoopbackUrl(url))) throw new Error();
141
+ return url.href.replace(/\/$/, "");
142
+ } catch {
143
+ throw invalidConfig(`${name} must be a valid HTTPS URL (HTTP is allowed only on loopback)`);
144
+ }
145
+ }
146
+
147
+ /** @param {string} message */
148
+ function invalidConfig(message) {
149
+ return new CliError(message, { code: "OAUTH_CONFIG_INVALID", exitCode: 2 });
150
+ }
@@ -0,0 +1,24 @@
1
+ // @ts-check
2
+ import { run } from "./cli.mjs";
3
+ import { CliError } from "./errors.mjs";
4
+ import { getCommandInvocation, getCommandName } from "./config.mjs";
5
+
6
+ /** @param {import('./config.mjs').Profile} profile */
7
+ export async function main(profile) {
8
+ const command = getCommandName(profile);
9
+ const invocation = getCommandInvocation(profile);
10
+ try {
11
+ await run(process.argv.slice(2), {profile});
12
+ } catch (error) {
13
+ const message = error instanceof Error ? error.message : String(error);
14
+ // Rewrite only fixed CLI guidance on stderr. Successful JSON and app names
15
+ // pass through untouched, including names containing "requestshield".
16
+ const guidance = message
17
+ .replaceAll("`requestshield signin`", `\`${invocation} signin\``)
18
+ .replaceAll("`requestshield signout`", `\`${invocation} signout\``)
19
+ .replaceAll("Usage: requestshield ", `Usage: ${invocation} `)
20
+ .replace(/^ requestshield /gm, ` ${invocation} `);
21
+ console.error(`${command}: ${guidance}`);
22
+ process.exitCode = error instanceof CliError ? error.exitCode : 1;
23
+ }
24
+ }
package/src/errors.mjs CHANGED
@@ -3,12 +3,14 @@
3
3
  export class CliError extends Error {
4
4
  /**
5
5
  * @param {string} message
6
- * @param {{ code?: string, exitCode?: number }} [options]
6
+ * @param {{ code?: string, exitCode?: number, httpStatus?: number, apiErrorCode?: string }} [options]
7
7
  */
8
8
  constructor(message, options = {}) {
9
9
  super(message);
10
10
  this.name = "CliError";
11
11
  this.code = options.code ?? "CLI_ERROR";
12
12
  this.exitCode = options.exitCode ?? 1;
13
+ this.httpStatus = options.httpStatus;
14
+ this.apiErrorCode = options.apiErrorCode;
13
15
  }
14
16
  }
package/src/main.mjs CHANGED
@@ -1,25 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // @ts-check
3
3
 
4
- import { run } from "./cli.mjs";
5
- import { CliError } from "./errors.mjs";
4
+ import { main } from "./entrypoint.mjs";
6
5
 
7
- async function main() {
8
- try {
9
- await run(process.argv.slice(2));
10
- } catch (error) {
11
- const message =
12
- error instanceof Error
13
- ? error.message
14
- : String(error);
15
-
16
- console.error(`requestshield: ${message}`);
17
-
18
- process.exitCode =
19
- error instanceof CliError
20
- ? error.exitCode
21
- : 1;
22
- }
23
- }
24
-
25
- void main();
6
+ void main("prod");