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.
Files changed (54) hide show
  1. package/README.md +292 -2
  2. package/config/.env.stg +8 -0
  3. package/package.json +25 -4
  4. package/skills/requestshield/SKILL.md +240 -0
  5. package/skills/requestshield/assets/AGENTS.codex.md +107 -0
  6. package/skills/requestshield/references/backend-java-core.md +152 -0
  7. package/skills/requestshield/references/backend-spring-boot.md +158 -0
  8. package/skills/requestshield/references/browser-manual.md +244 -0
  9. package/skills/requestshield/references/browser-seamless.md +213 -0
  10. package/skills/requestshield/references/cli.md +314 -0
  11. package/skills/requestshield/references/integration-planning.md +357 -0
  12. package/skills/requestshield/references/troubleshooting.md +131 -0
  13. package/src/agent-detector.mjs +102 -0
  14. package/src/api-client.mjs +155 -0
  15. package/src/args.mjs +147 -0
  16. package/src/browser-opener.mjs +32 -0
  17. package/src/cli.mjs +115 -0
  18. package/src/command-completion.mjs +10 -0
  19. package/src/command-registry.mjs +151 -0
  20. package/src/commands/agent-setup.mjs +195 -0
  21. package/src/commands/agent-status.mjs +65 -0
  22. package/src/commands/application-mutations.mjs +37 -0
  23. package/src/commands/application-response.mjs +55 -0
  24. package/src/commands/apps-get.mjs +28 -0
  25. package/src/commands/apps-list.mjs +93 -0
  26. package/src/commands/auth-status.mjs +42 -0
  27. package/src/commands/billing.mjs +106 -0
  28. package/src/commands/contract.mjs +59 -0
  29. package/src/commands/keys-create.mjs +22 -0
  30. package/src/commands/mutation-support.mjs +135 -0
  31. package/src/commands/output.mjs +26 -0
  32. package/src/commands/secret-commands.mjs +66 -0
  33. package/src/commands/secret-output.mjs +52 -0
  34. package/src/commands/service-status.mjs +34 -0
  35. package/src/commands/signin.mjs +95 -0
  36. package/src/commands/signout.mjs +14 -0
  37. package/src/commands/update-check.mjs +205 -0
  38. package/src/commands/usage.mjs +72 -0
  39. package/src/config.mjs +200 -0
  40. package/src/entrypoint.mjs +44 -0
  41. package/src/errors.mjs +16 -0
  42. package/src/integration-contract-client.mjs +85 -0
  43. package/src/integration-contract.mjs +114 -0
  44. package/src/main.mjs +6 -0
  45. package/src/oauth-client.mjs +153 -0
  46. package/src/oauth-loopback.mjs +120 -0
  47. package/src/package-profile.mjs +24 -0
  48. package/src/private-path.mjs +33 -0
  49. package/src/secret-output.mjs +121 -0
  50. package/src/service-status-client.mjs +87 -0
  51. package/src/session-files.mjs +192 -0
  52. package/src/session-store.mjs +204 -0
  53. package/src/terminal.mjs +143 -0
  54. package/src/usage-time.mjs +26 -0
@@ -0,0 +1,59 @@
1
+ // @ts-check
2
+ import { getDocsUrl } from "../config.mjs";
3
+ import { IntegrationContractClient } from "../integration-contract-client.mjs";
4
+ import { rendererFor } from "../terminal.mjs";
5
+
6
+ /**
7
+ * @param {{json?: boolean}} options
8
+ * @param {{profile?: import('../config.mjs').Profile, fetchImpl?: typeof fetch, log(message: string): void, terminal?: import('../terminal.mjs').TerminalContext}} deps
9
+ */
10
+ export async function showContract(options, deps) {
11
+ const profile = deps.profile ?? "prod";
12
+ const contract = await new IntegrationContractClient({
13
+ docsUrl: getDocsUrl(profile), fetchImpl: deps.fetchImpl,
14
+ }).getContract();
15
+ if (options.json) deps.log(JSON.stringify(contract, null, 2));
16
+ else {
17
+ const {data} = contract;
18
+ const renderer = rendererFor(deps);
19
+ /** @type {Array<[string, string]>} */
20
+ const documentationFields = [
21
+ ["Browser SDK", data.documentation.browserSdk],
22
+ ["Java SDK", data.documentation.javaSdk],
23
+ ["Domains and CSP", data.documentation.domainsAndCsp],
24
+ ];
25
+ if (data.documentation.releaseNotes) documentationFields.push(["Release notes", data.documentation.releaseNotes]);
26
+ deps.log([
27
+ renderer.heading("Integration contract", "📖"),
28
+ renderer.fields([
29
+ ["Version", `${data.contractVersion} (schema ${data.schemaVersion})`],
30
+ ["Challenge service", data.services.challengeUrl],
31
+ ]),
32
+ renderer.section("Browser SDK"),
33
+ renderer.fields([
34
+ ["Versioned script version", data.browser.sdkVersion],
35
+ ["Versioned script", data.browser.scriptUrlVersion],
36
+ ["Latest script", data.browser.scriptUrlLatest],
37
+ ["Versioned script integrity", data.browser.integrity],
38
+ ["Token header", data.browser.tokenHeader],
39
+ ["Versioned script modes", data.browser.availableModes.join(", ")],
40
+ ]),
41
+ renderer.section("CSP additions"),
42
+ renderer.fields([
43
+ ["script-src", data.browser.cspAdditions.scriptSrc.join(" ")],
44
+ ["connect-src", data.browser.cspAdditions.connectSrc.join(" ")],
45
+ ["worker-src", data.browser.cspAdditions.workerSrc.join(" ")],
46
+ ]),
47
+ renderer.section("Java SDK"),
48
+ renderer.fields([
49
+ ["Version", `${data.backend.sdkVersion} (JDK ${data.backend.minJdk}+)`],
50
+ ["Maven repository", data.backend.mavenRepository],
51
+ ["Manual — Java core", `${data.backend.core.groupId}:${data.backend.core.artifactId}:${data.backend.sdkVersion}`],
52
+ [`Seamless — ${data.backend.springBoot3.framework} starter`, `${data.backend.springBoot3.groupId}:${data.backend.springBoot3.artifactId}:${data.backend.sdkVersion}`],
53
+ ]),
54
+ renderer.section("Documentation"),
55
+ renderer.fields(documentationFields),
56
+ ].join("\n\n"));
57
+ }
58
+ return contract;
59
+ }
@@ -0,0 +1,22 @@
1
+ // @ts-check
2
+
3
+ import { mutationKey, parseIssuance, printIssuance, withMutationRecovery } from "./mutation-support.mjs";
4
+ import { outputContext } from "./output.mjs";
5
+ import { saveSecretOutput, withSecretOutput } from "./secret-output.mjs";
6
+
7
+ /**
8
+ * @param {{appName: string, json?: boolean, yes?: boolean, idempotencyKey?: string, output?: string}} options
9
+ * @param {import('./mutation-support.mjs').CommandOutput & {api: {createApp(token: string, options: {name: string, idempotencyKey: string}): Promise<import('./mutation-support.mjs').MutationResponse>}, sessions: {loadToken(): Promise<string>}}} deps
10
+ */
11
+ export async function createKeys(options, deps) {
12
+ deps = outputContext(options, deps);
13
+ const idempotencyKey = mutationKey(options.idempotencyKey);
14
+ return withSecretOutput(options.output, deps, async (file) => {
15
+ const token = await deps.sessions.loadToken();
16
+ const issuance = await withMutationRecovery(idempotencyKey, deps, async () =>
17
+ parseIssuance(await deps.api.createApp(token, {name: options.appName, idempotencyKey}), 201));
18
+ if (file) return saveSecretOutput(file, issuance, options, deps);
19
+ printIssuance(issuance, deps, options);
20
+ return issuance;
21
+ });
22
+ }
@@ -0,0 +1,135 @@
1
+ // @ts-check
2
+ import { randomUUID } from "node:crypto";
3
+ import readline from "node:readline/promises";
4
+ import { stdin, stderr } from "node:process";
5
+ import { printData } from "./output.mjs";
6
+ import { CliError } from "../errors.mjs";
7
+ import { getCommandInvocation } from "../config.mjs";
8
+ import { APPLICATION_STATUSES, invalidResponse, responseObject } from "./application-response.mjs";
9
+ import { rendererFor } from "../terminal.mjs";
10
+
11
+ /** @typedef {{body: unknown, status?: number, replayed?: boolean}} MutationResponse */
12
+ /** @typedef {{log: (message: string) => void, warn?: (message: string) => void, profile?: import('../config.mjs').Profile, terminal?: import('../terminal.mjs').TerminalContext, confirm?: () => Promise<boolean>, secretOutput?: import('../secret-output.mjs').SecretOutputOptions}} CommandOutput */
13
+
14
+ /** @param {string | undefined} supplied */
15
+ export function mutationKey(supplied) {
16
+ const key = supplied ?? randomUUID();
17
+ if (!/^[A-Za-z0-9._~-]{1,128}$/.test(key)) {
18
+ throw new CliError("Idempotency key must contain 1-128 letters, numbers, '.', '_', '~', or '-'", {code: "INVALID_IDEMPOTENCY_KEY", exitCode: 2});
19
+ }
20
+ return key;
21
+ }
22
+
23
+ /** @param {{yes?: boolean, json?: boolean}} options @param {CommandOutput} deps @param {string} prompt @param {string} word */
24
+ export async function confirmAction(options, deps, prompt, word) {
25
+ if (options.yes) return;
26
+ if (options.json) throw new CliError("This action requires confirmation; use --yes with --json", {code: "CONFIRMATION_REQUIRED", exitCode: 2});
27
+ let accepted;
28
+ if (deps.confirm) accepted = await deps.confirm();
29
+ else {
30
+ if (!stdin.isTTY || !stderr.isTTY) {
31
+ throw new CliError("This action requires confirmation; use --yes in a non-interactive terminal", {code: "CONFIRMATION_REQUIRED", exitCode: 2});
32
+ }
33
+ const terminal = readline.createInterface({input: stdin, output: stderr});
34
+ try { accepted = await terminal.question(`${rendererFor(deps, "stderr").message("warning", prompt)}\nType ${word} to continue: `) === word; }
35
+ finally { terminal.close(); }
36
+ }
37
+ if (!accepted) throw new CliError("Action cancelled", {code: "CANCELLED", exitCode: 2});
38
+ }
39
+
40
+ /** The caller wraps dispatch and response validation, never session loading.
41
+ * @template T @param {string} idempotencyKey @param {CommandOutput} deps @param {() => Promise<T>} operation @returns {Promise<T>}
42
+ */
43
+ export async function withMutationRecovery(idempotencyKey, deps, operation) {
44
+ // Print before dispatch so interruption cannot hide the identity needed to retry.
45
+ (deps.warn ?? deps.log)(`Idempotency key: ${idempotencyKey}`);
46
+ try { return await operation(); }
47
+ catch (error) {
48
+ const status = error instanceof CliError ? Reflect.get(error, "httpStatus") : undefined;
49
+ const uncertain = !(error instanceof CliError)
50
+ || ["NETWORK_ERROR", "REQUEST_TIMEOUT", "INVALID_RESPONSE"].includes(error.code)
51
+ || (error.code === "API_ERROR" && (typeof status !== "number" || status >= 500));
52
+ if (!uncertain) throw error;
53
+ throw new CliError(
54
+ `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.`,
55
+ {code: "MUTATION_UNCERTAIN", exitCode: 7},
56
+ );
57
+ }
58
+ }
59
+
60
+ /** @param {MutationResponse} result @param {number} expectedStatus */
61
+ export function validateHttpStatus(result, expectedStatus) {
62
+ if (result.status !== undefined && result.status !== expectedStatus) {
63
+ throw invalidResponse("The API returned an unexpected success status");
64
+ }
65
+ }
66
+
67
+ /** @param {MutationResponse} result @param {number} expectedStatus @param {string} [requestedAppKey] */
68
+ export function parseIssuance(result, expectedStatus, requestedAppKey) {
69
+ validateHttpStatus(result, expectedStatus);
70
+ const {appKey, status, apiSecret} = responseObject(result.body);
71
+ if (typeof appKey !== "string" || !/^[A-Za-z0-9._~-]{1,128}$/.test(appKey)
72
+ || (requestedAppKey !== undefined && appKey !== requestedAppKey)
73
+ || typeof status !== "string" || !APPLICATION_STATUSES.has(status)
74
+ || (apiSecret !== null && !isApiSecret(apiSecret))
75
+ || (result.replayed === true && apiSecret !== null)
76
+ || (result.replayed === false && apiSecret === null)) {
77
+ throw invalidResponse("The issuance response did not match the expected application, status and secret contract");
78
+ }
79
+ return {appKey, status, apiSecret};
80
+ }
81
+
82
+ /** @param {unknown} value @returns {value is string} */
83
+ export function isApiSecret(value) {
84
+ return typeof value === "string" && /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/.test(value);
85
+ }
86
+
87
+ /** @param {{appKey: string, status: string, apiSecret: string | null}} issuance @param {CommandOutput} deps @param {{json?: boolean}} [options] */
88
+ export function printIssuance(issuance, deps, options = {}) {
89
+ const invocation = getCommandInvocation(deps.profile);
90
+ if (options.json) {
91
+ printData(issuance, deps);
92
+ const warn = deps.warn ?? console.error;
93
+ if (issuance.apiSecret === null) warn(`This is an idempotency replay; the API did not return a secret. Run \`${invocation} secret reveal ${issuance.appKey}\` to retrieve the current active secret.`);
94
+ else warn("Store this secret in your backend secret manager. The CLI did not save it.");
95
+ (deps.warn ?? console.error)("Changes may take a moment. Check the application status before relying on the change.");
96
+ return;
97
+ }
98
+ const renderer = rendererFor(deps);
99
+ const diagnostics = rendererFor(deps, deps.warn ? "stderr" : "stdout");
100
+ const warn = deps.warn ?? deps.log;
101
+ deps.log(renderer.heading("Application credentials", "🔐"));
102
+ deps.log(renderer.fields([["App Key", issuance.appKey], ["Status", issuance.status]]));
103
+ if (issuance.apiSecret === null) {
104
+ warn(diagnostics.message("warning", `This is an idempotency replay; the API did not return a secret. Run \`${invocation} secret reveal ${issuance.appKey}\` to retrieve the current active secret.`));
105
+ } else {
106
+ deps.log(renderer.fields([["Secret Key", issuance.apiSecret]]));
107
+ warn(diagnostics.message("warning", "Store this secret in your backend secret manager. The CLI did not save it."));
108
+ }
109
+ warn(diagnostics.message("warning", "Changes may take a moment. Check the application status before relying on the change."));
110
+ }
111
+
112
+ /** @param {MutationResponse} result */
113
+ export function parseAccepted(result) {
114
+ validateHttpStatus(result, 202);
115
+ if (responseObject(result.body).status !== "accepted") {
116
+ throw invalidResponse("The mutation response did not acknowledge acceptance");
117
+ }
118
+ }
119
+
120
+ /** @param {string} action @param {string} appKey @param {CommandOutput} deps @param {{json?: boolean}} [options] */
121
+ export function printAccepted(action, appKey, deps, options = {}) {
122
+ const data = {appKey, status: "accepted"};
123
+ if (options.json) printData(data, deps);
124
+ else {
125
+ const renderer = rendererFor(deps);
126
+ deps.log(renderer.heading(`${action} accepted`));
127
+ deps.log(renderer.fields([["App Key", appKey], ["Status", "accepted"]]));
128
+ }
129
+ const diagnostics = rendererFor(deps, deps.warn ? "stderr" : "stdout");
130
+ const warning = "Changes may take a moment.";
131
+ const guidance = `Run \`${getCommandInvocation(deps.profile)} app get ${appKey}\` to check the current application status.`;
132
+ (deps.warn ?? deps.log)(options.json ? warning : diagnostics.message("warning", warning));
133
+ (deps.warn ?? deps.log)(options.json ? guidance : diagnostics.paragraph(guidance));
134
+ return data;
135
+ }
@@ -0,0 +1,26 @@
1
+ // @ts-check
2
+ import { rendererFor } from "../terminal.mjs";
3
+
4
+ /** @param {unknown} data @param {{log(message: string): void}} deps */
5
+ export function printData(data, deps) {
6
+ deps.log(JSON.stringify({data}, null, 2));
7
+ }
8
+
9
+ /** Keep diagnostics separate from machine-readable results, including direct callers.
10
+ * @template {{log: (message: string) => void, warn?: (message: string) => void}} T
11
+ * @param {{json?: boolean}} options @param {T} deps @returns {T}
12
+ */
13
+ export function outputContext(options, deps) {
14
+ return {...deps, warn: deps.warn ?? (options.json ? console.error : deps.log)};
15
+ }
16
+
17
+ /** @param {import('./application-response.mjs').Application} app
18
+ * @param {{json?: boolean}} options @param {{log(message: string): void, terminal?: import('../terminal.mjs').TerminalContext}} deps */
19
+ export function printApplication(app, options, deps) {
20
+ if (options.json) return printData(app, deps);
21
+ const renderer = rendererFor(deps);
22
+ deps.log([renderer.heading("Application", "📦"), "", renderer.fields([
23
+ ["App Key", app.appKey], ["Name", app.name], ["Status", app.status],
24
+ ["Created", app.createdAt], ["Updated", app.updatedAt],
25
+ ])].join("\n"));
26
+ }
@@ -0,0 +1,66 @@
1
+ // @ts-check
2
+ import { invalidResponse, responseObject } from "./application-response.mjs";
3
+ import { outputContext, printData } from "./output.mjs";
4
+ import { rendererFor } from "../terminal.mjs";
5
+ import { confirmAction, isApiSecret, mutationKey, parseAccepted, parseIssuance, printAccepted, printIssuance, validateHttpStatus, withMutationRecovery } from "./mutation-support.mjs";
6
+ import { saveSecretOutput, withSecretOutput } from "./secret-output.mjs";
7
+
8
+ /** @typedef {import('./mutation-support.mjs').MutationResponse} MutationResponse */
9
+ /** @typedef {import('./mutation-support.mjs').CommandOutput & {sessions: {loadToken(): Promise<string>}}} Dependencies */
10
+ /** @typedef {{appKey: string, yes?: boolean, json?: boolean, idempotencyKey?: string}} MutationOptions */
11
+
12
+ /** @param {MutationOptions & {output?: string}} options
13
+ * @param {Dependencies & {api: {rotateSecret(token: string, appKey: string, options: {idempotencyKey: string}): Promise<MutationResponse>}}} deps
14
+ */
15
+ export async function rotateSecret(options, deps) {
16
+ deps = outputContext(options, deps);
17
+ await confirmAction(options, deps, `Replace the current secret for ${options.appKey}?${options.output === undefined ? "" : " Save the new credentials to the selected output file?"} Existing backend configuration will need the new secret.`, "ROTATE");
18
+ const idempotencyKey = mutationKey(options.idempotencyKey);
19
+ return withSecretOutput(options.output, deps, async (file) => {
20
+ const token = await deps.sessions.loadToken();
21
+ const issuance = await withMutationRecovery(idempotencyKey, deps, async () =>
22
+ parseIssuance(await deps.api.rotateSecret(token, options.appKey, {idempotencyKey}), 200, options.appKey));
23
+ if (file) return saveSecretOutput(file, issuance, options, deps);
24
+ printIssuance(issuance, deps, options);
25
+ return issuance;
26
+ });
27
+ }
28
+
29
+ /** @param {{appKey: string, yes?: boolean, json?: boolean, output?: string}} options
30
+ * @param {Dependencies & {api: {revealSecret(token: string, appKey: string): Promise<MutationResponse>}}} deps
31
+ */
32
+ export async function revealSecret(options, deps) {
33
+ deps = outputContext(options, deps);
34
+ await confirmAction(options, deps, options.output === undefined
35
+ ? `Display the current API secret for ${options.appKey} in this terminal?`
36
+ : `Retrieve the current API secret for ${options.appKey} and save the credentials to the selected output file?`, "REVEAL");
37
+ return withSecretOutput(options.output, deps, async (file) => {
38
+ const token = await deps.sessions.loadToken();
39
+ const result = await deps.api.revealSecret(token, options.appKey);
40
+ validateHttpStatus(result, 200);
41
+ const {apiSecret} = responseObject(result.body);
42
+ if (!isApiSecret(apiSecret)) throw invalidResponse("The reveal response did not contain a valid API secret");
43
+ if (file) return saveSecretOutput(file, {appKey: options.appKey, apiSecret}, options, deps);
44
+ if (options.json) printData({apiSecret}, deps);
45
+ else {
46
+ const renderer = rendererFor(deps);
47
+ deps.log(`${renderer.heading("Application secret", "🔐")}\n\n${renderer.fields([["Secret Key", apiSecret]])}`);
48
+ }
49
+ const notice = "Store this secret in your backend secret manager. The CLI did not save it.";
50
+ (deps.warn ?? deps.log)(options.json ? notice : rendererFor(deps, deps.warn ? "stderr" : "stdout").message("warning", notice));
51
+ return {apiSecret};
52
+ });
53
+ }
54
+
55
+ /** @param {MutationOptions} options
56
+ * @param {Dependencies & {api: {revokeSecret(token: string, appKey: string, options: {idempotencyKey: string}): Promise<MutationResponse>}}} deps
57
+ */
58
+ export async function revokeSecret(options, deps) {
59
+ deps = outputContext(options, deps);
60
+ await confirmAction(options, deps, `Revoke the current API secret for ${options.appKey}?`, "REVOKE");
61
+ const idempotencyKey = mutationKey(options.idempotencyKey);
62
+ const token = await deps.sessions.loadToken();
63
+ await withMutationRecovery(idempotencyKey, deps, async () =>
64
+ parseAccepted(await deps.api.revokeSecret(token, options.appKey, {idempotencyKey})));
65
+ return printAccepted("Secret revocation", options.appKey, deps, options);
66
+ }
@@ -0,0 +1,52 @@
1
+ // @ts-check
2
+ import { SecretOutputFile } from "../secret-output.mjs";
3
+ import { CliError } from "../errors.mjs";
4
+ import { getCommandInvocation } from "../config.mjs";
5
+ import { rendererFor } from "../terminal.mjs";
6
+ import { printData } from "./output.mjs";
7
+
8
+ /** Reserve storage before session loading/API calls and clean up on every failure.
9
+ * @template T @param {string | undefined} output
10
+ * @param {import('./mutation-support.mjs').CommandOutput} deps
11
+ * @param {(file?: SecretOutputFile) => Promise<T>} operation
12
+ */
13
+ export async function withSecretOutput(output, deps, operation) {
14
+ if (output === undefined) return operation();
15
+ const file = new SecretOutputFile(output, deps.secretOutput);
16
+ try {
17
+ await file.prepare();
18
+ return await operation(file);
19
+ } finally {
20
+ if (!await file.cleanup()) (deps.warn ?? deps.log)("Could not remove the unfinished output file. Inspect the selected path privately before using it again.");
21
+ }
22
+ }
23
+
24
+ /** @param {SecretOutputFile} file
25
+ * @param {{appKey: string, apiSecret: string | null, status?: string}} credentials
26
+ * @param {{json?: boolean}} options
27
+ * @param {import('./mutation-support.mjs').CommandOutput} deps
28
+ */
29
+ export async function saveSecretOutput(file, credentials, options, deps) {
30
+ const {appKey, apiSecret, status} = credentials;
31
+ const recover = `Run \`${getCommandInvocation(deps.profile)} secret reveal ${appKey} --output <new-file>\` to save the current active secret. Do not create or rotate again.`;
32
+ if (apiSecret === null) {
33
+ throw new CliError(`Application ${appKey}: this idempotency replay returned no secret, so no credentials file was saved. ${recover}`, {code: "OUTPUT_SECRET_UNAVAILABLE", exitCode: 7});
34
+ }
35
+ try { await file.commit(appKey, apiSecret); }
36
+ catch {
37
+ throw new CliError(`The application or secret request for ${appKey} succeeded, but the credentials file could not be saved. ${recover}`, {code: "OUTPUT_WRITE_FAILED", exitCode: 7});
38
+ }
39
+ const data = {appKey, ...(status === undefined ? {} : {status}), outputPath: file.file, secretStored: true};
40
+ if (options.json) printData(data, deps);
41
+ else {
42
+ const renderer = rendererFor(deps);
43
+ /** @type {Array<[string, string]>} */
44
+ const fields = [["App Key", appKey]];
45
+ if (status !== undefined) fields.push(["Status", status]);
46
+ fields.push(["Output file", file.file], ["Secret stored", "yes"]);
47
+ deps.log([renderer.heading("Credentials saved", "🔐"), "", renderer.fields(fields)].join("\n"));
48
+ }
49
+ (deps.warn ?? deps.log)("Configure your backend launcher or container to load this ENV file. Saving it does not configure the backend.");
50
+ if (status !== undefined) (deps.warn ?? deps.log)("Changes may take a moment. Check the application status before relying on the change.");
51
+ return data;
52
+ }
@@ -0,0 +1,34 @@
1
+ // @ts-check
2
+ import { getServiceConfig } from '../config.mjs';
3
+ import { checkServices } from '../service-status-client.mjs';
4
+ import { CommandCompletion } from '../command-completion.mjs';
5
+ import { rendererFor } from '../terminal.mjs';
6
+
7
+ /** @param {{json?: boolean}} options
8
+ * @param {{profile?: import('../config.mjs').Profile, fetchImpl?: typeof fetch, log(message: string): void, terminal?: import('../terminal.mjs').TerminalContext}} deps
9
+ */
10
+ export async function showServiceStatus(options, deps) {
11
+ const profile = deps.profile ?? 'prod';
12
+ const config = getServiceConfig(profile);
13
+ const checkedAt = new Date().toISOString();
14
+ const checks = await checkServices({...config, fetchImpl: deps.fetchImpl});
15
+ const result = {data: {checkedAt, checks}};
16
+ if (options.json) deps.log(JSON.stringify(result, null, 2));
17
+ else {
18
+ const renderer = rendererFor(deps);
19
+ deps.log([
20
+ renderer.heading('Service status'),
21
+ renderer.fields([['Checked at', checkedAt]]),
22
+ ...checks.map(check => [
23
+ renderer.section(check.service === 'management-api' ? 'Management API' : 'Challenge Service'),
24
+ renderer.fields([
25
+ ['URL', check.url], ['Status', check.status], ['Response time', `${check.latencyMs} ms`],
26
+ ['HTTP status', check.httpStatus === null ? 'Unavailable' : String(check.httpStatus)],
27
+ ['Reason', check.reason ?? 'None'],
28
+ ]),
29
+ ].join('\n')),
30
+ renderer.paragraph('Results reflect checks from this computer at the time shown. Test a protected request to verify your application integration.'),
31
+ ].join('\n\n'));
32
+ }
33
+ return new CommandCompletion(checks.every(check => check.status === 'ok') ? 0 : 1);
34
+ }
@@ -0,0 +1,95 @@
1
+ // @ts-check
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { CliError } from "../errors.mjs";
4
+ import { REQUESTED_OAUTH_SCOPES, validateAuthorizationIssuer } from "../config.mjs";
5
+ import { createLoopbackReceiver } from "../oauth-loopback.mjs";
6
+ import { openBrowser } from "../browser-opener.mjs";
7
+ import { printData, outputContext } from "./output.mjs";
8
+ import { rendererFor } from "../terminal.mjs";
9
+
10
+ /** @typedef {{
11
+ * oauth: Pick<import('../oauth-client.mjs').OAuthClient, 'discover' | 'exchangeCode' | 'config'>,
12
+ * authorizationIssuer?: string,
13
+ * sessions: {save(credentials: import('../oauth-client.mjs').OAuthCredentials): Promise<void>},
14
+ * log: (message: string) => void,
15
+ * warn?: (message: string) => void,
16
+ * profile?: import('../config.mjs').Profile,
17
+ * openBrowser?: typeof openBrowser,
18
+ * signal?: AbortSignal,
19
+ * timeoutMs?: number,
20
+ * interruptSource?: Pick<NodeJS.Process, 'on' | 'removeListener'>,
21
+ * terminal?: import('../terminal.mjs').TerminalContext,
22
+ * }} SigninDependencies
23
+ */
24
+
25
+ /** @param {{noOpen?: boolean, json?: boolean}} options @param {SigninDependencies} deps */
26
+ export async function signin(options, deps) {
27
+ const {noOpen = false} = options;
28
+ deps = outputContext(options, deps);
29
+ const progress = deps.warn ?? deps.log;
30
+ const diagnostics = rendererFor(deps, "stderr");
31
+ /** @param {string} text */
32
+ const progressMessage = text => options.json ? text : diagnostics.message("info", text);
33
+ /** Preserve the full authorization URL on its own line, including in JSON diagnostics.
34
+ * @param {string} url */
35
+ const showAuthorizationUrl = url => progress(`${progressMessage("Open this authorization URL in a browser on this computer:")}\n${url}`);
36
+ const authorizationIssuer = validateAuthorizationIssuer(deps.authorizationIssuer ?? deps.oauth.config.issuer);
37
+ const controller = new AbortController();
38
+ let expired = false;
39
+ const interruptSource = deps.interruptSource ?? process;
40
+ const cancel = () => controller.abort();
41
+ const timer = setTimeout(() => { expired = true; cancel(); }, deps.timeoutMs ?? 600_000);
42
+ interruptSource.on("SIGINT", cancel);
43
+ deps.signal?.addEventListener("abort", cancel, {once: true});
44
+ if (deps.signal?.aborted) cancel();
45
+ /** @type {Awaited<ReturnType<typeof createLoopbackReceiver>> | undefined} */
46
+ let receiver;
47
+ try {
48
+ const {signal} = controller;
49
+ const {authorizationEndpoint} = await deps.oauth.discover({signal});
50
+ signal.throwIfAborted();
51
+ const state = randomBytes(32).toString("base64url");
52
+ const verifier = randomBytes(32).toString("base64url");
53
+ receiver = await createLoopbackReceiver({state, issuer: authorizationIssuer, signal});
54
+ const authorizeUrl = new URL(authorizationEndpoint);
55
+ authorizeUrl.search = new URLSearchParams({
56
+ client_id: deps.oauth.config.clientId,
57
+ response_type: "code", redirect_uri: receiver.redirectUri,
58
+ code_challenge: createHash("sha256").update(verifier).digest("base64url"),
59
+ code_challenge_method: "S256", state, scope: REQUESTED_OAUTH_SCOPES.join(" "),
60
+ }).toString();
61
+ if (noOpen) {
62
+ showAuthorizationUrl(authorizeUrl.href);
63
+ } else {
64
+ progress(progressMessage("Opening your browser to sign in. Complete authorization on this computer."));
65
+ try { await (deps.openBrowser ?? openBrowser)(authorizeUrl.href, {signal}); }
66
+ catch {
67
+ signal.throwIfAborted();
68
+ showAuthorizationUrl(authorizeUrl.href);
69
+ }
70
+ }
71
+ const {code} = await receiver.result;
72
+ signal.throwIfAborted();
73
+ const credentials = await deps.oauth.exchangeCode({code, verifier, redirectUri: receiver.redirectUri, signal});
74
+ signal.throwIfAborted();
75
+ await deps.sessions.save(credentials);
76
+ signal.throwIfAborted();
77
+ receiver.complete(true);
78
+ const result = {signedIn: true};
79
+ if (options.json) printData(result, deps);
80
+ else deps.log(rendererFor(deps).message("success", "Signed in successfully. The session is stored for the current user."));
81
+ return result;
82
+ } catch (error) {
83
+ receiver?.complete(false);
84
+ if (controller.signal.aborted) throw new CliError(expired
85
+ ? "Sign-in expired; run `requestshield login` again"
86
+ : "Sign-in was cancelled", {code: expired ? "SIGNIN_EXPIRED" : "SIGNIN_CANCELLED", exitCode: 3});
87
+ if (error instanceof CliError) throw error;
88
+ throw new CliError("Sign-in failed; run `requestshield login` again", {code: "SIGNIN_FAILED", exitCode: 3});
89
+ } finally {
90
+ clearTimeout(timer);
91
+ interruptSource.removeListener("SIGINT", cancel);
92
+ deps.signal?.removeEventListener("abort", cancel);
93
+ await receiver?.close();
94
+ }
95
+ }
@@ -0,0 +1,14 @@
1
+ // @ts-check
2
+
3
+ import { printData } from "./output.mjs";
4
+ import { rendererFor } from "../terminal.mjs";
5
+
6
+ /** @param {{json?: boolean}} options
7
+ * @param {{sessions: {signout(): Promise<{profile: import('../config.mjs').Profile, removed: boolean}>}, log: (message: string) => void, terminal?: import('../terminal.mjs').TerminalContext}} deps
8
+ */
9
+ export async function signout(options, deps) {
10
+ const { removed } = await deps.sessions.signout();
11
+ if (options.json) printData({removed}, deps);
12
+ else deps.log(rendererFor(deps).message(removed ? "success" : "info", removed ? "Signed out." : "No saved session."));
13
+ return {removed};
14
+ }