requestshield 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # RequestShield CLI
2
+
3
+ Customer-facing CLI for signing in, rotating RequestShield application keys,
4
+ and installing the RequestShield skill for Codex.
5
+
6
+ ## Requirements
7
+
8
+ - Node.js 22.13 or newer.
9
+ - A customer management API implementing the contract below.
10
+
11
+ The API defaults to `https://api.intellifend.ai`. For development, set
12
+ `REQUESTSHIELD_API_URL` to the management API origin.
13
+
14
+ ## Commands
15
+
16
+ ```console
17
+ requestshield signin
18
+ requestshield keys create
19
+ requestshield agent setup --agent codex
20
+ ```
21
+
22
+ `signin` starts a device authorization flow and prints the verification URL and
23
+ code for the customer to open manually. The returned session is stored under the user's standard
24
+ application-data directory with permissions restricted to that user. CI may
25
+ instead provide `REQUESTSHIELD_ACCESS_TOKEN`; environment tokens are never
26
+ persisted.
27
+
28
+ `keys create` asks for confirmation, then atomically deactivates the previous
29
+ application credentials and creates a new `appKey` + `apiSecret`. The public
30
+ app key and the backend-only secret are printed after success. The secret is
31
+ not stored by the CLI and cannot be retrieved again.
32
+
33
+ `agent setup --agent codex` installs the bundled `requestshield` skill into
34
+ `$CODEX_HOME/skills`, or `~/.codex/skills` when `CODEX_HOME` is unset. Existing
35
+ content is preserved unless `--force` is supplied.
36
+
37
+ ## Management API contract
38
+
39
+ ### Start sign-in
40
+
41
+ `POST /v1/cli/signin`
42
+
43
+ ```json
44
+ {
45
+ "client": "requestshield-cli"
46
+ }
47
+ ```
48
+
49
+ The response contains `deviceCode`, `userCode`, `verificationUri`,
50
+ `expiresIn`, and `interval`.
51
+
52
+ ### Poll sign-in
53
+
54
+ `POST /v1/cli/signin/token` with `{ "deviceCode": "..." }`.
55
+
56
+ Pending responses use HTTP 400 with `code` set to `authorization_pending` or
57
+ `slow_down`. A successful response contains `accessToken` and optional account
58
+ metadata.
59
+
60
+ ### Rotate keys
61
+
62
+ `POST /v1/cli/keys/rotate` with Bearer authentication and an empty JSON body.
63
+ The response contains `appKey` and one-time `apiSecret`.
64
+
65
+ ## Development
66
+
67
+ ```console
68
+ pnpm --filter requestshield lint
69
+ pnpm --filter requestshield typecheck
70
+ pnpm --filter requestshield test
71
+ pnpm --filter requestshield build
72
+ ```
73
+ ## Quick start
74
+
75
+ RequestShield requires Node.js 22.13 or newer.
76
+
77
+ Run without installing globally:
78
+
79
+ ```console
80
+ npx requestshield --help
81
+ npx requestshield signin
82
+ npx requestshield keys create
83
+ npx requestshield agent setup --agent codex
84
+ ```
85
+
86
+ Pin a specific version for reproducible usage:
87
+
88
+ ```console
89
+ npx requestshield@0.1.0 --help
90
+ ```
91
+
92
+ To automatically accept npm's installation prompt:
93
+
94
+ ```console
95
+ npx --yes requestshield@0.1.0 --help
96
+ ```
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "requestshield",
3
+ "version": "0.1.0",
4
+ "description": "Customer CLI for IntelliFend RequestShield.",
5
+ "type": "module",
6
+ "bin": {
7
+ "requestshield": "src/main.mjs"
8
+ },
9
+ "files": [
10
+ "src/",
11
+ "skills/",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=22.13"
16
+ },
17
+ "scripts": {
18
+ "build": "node scripts/check-package.mjs",
19
+ "lint": "node scripts/check-package.mjs",
20
+ "test": "node --test",
21
+ "typecheck": "tsc -p jsconfig.json --noEmit",
22
+ "prepublishOnly": "npm run lint && npm run typecheck && npm test"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "26.1.1",
26
+ "esbuild": "^0.28.1",
27
+ "postject": "1.0.0-alpha.6",
28
+ "typescript": "^6.0.3"
29
+ },
30
+ "license": "UNLICENSED"
31
+ }
@@ -0,0 +1,44 @@
1
+ ---
2
+ name: requestshield
3
+ description: Set up or remove IntelliFend RequestShield protection in a customer application using the repository's Browser SDK and supported backend SDK. Use when the user asks an AI agent to integrate, configure, validate, or remove RequestShield protection.
4
+ ---
5
+
6
+ # RequestShield Integration
7
+
8
+ Work only inside the customer's application repository. Do not access or modify
9
+ IntelliFend operator infrastructure, Cloudflare resources, or customer records.
10
+
11
+ Before editing, identify the frontend and backend frameworks and inspect the
12
+ relevant dependency and configuration files. Explain the files that will change.
13
+ Do not place the backend Secret Key in source, browser configuration, URLs, logs,
14
+ or committed files. The public App Key may be included in browser configuration.
15
+
16
+ ## Set up protection
17
+
18
+ 1. Obtain the public App Key, backend-only Secret Key, and Challenge Server URL
19
+ from the customer. If the Secret Key is unavailable, stop and ask the user to
20
+ put it in their backend secret manager; never ask them to paste it into chat.
21
+ 2. Configure the Browser SDK with the App Key, Challenge Server URL, and exact
22
+ protected endpoint URLs. Query strings and fragments do not participate in
23
+ matching; path case and trailing slash do.
24
+ 3. Configure the supported backend SDK with the same App Key and a secret-store
25
+ reference for the Secret Key. For Spring Boot 3 MVC, protect the actual
26
+ mutation method with `@RequestShieldProtected`.
27
+ 4. For a new endpoint, start in `MONITOR`; move to `BLOCK` only after verifying
28
+ browser matching, backend outcomes, and CORS. Cross-origin customer APIs must
29
+ allow `X-IntelliFend-Token`.
30
+ 5. Run the project's normal lint, build, and tests. Report any manual secret or
31
+ deployment step without performing external deployment unless requested.
32
+
33
+ Do not combine a manual `RequestShieldClient.verify(token)` call with annotation
34
+ enforcement on the same request: the token is single-use and would be consumed
35
+ twice.
36
+
37
+ ## Remove protection
38
+
39
+ Remove only RequestShield-owned integration: browser initialization/protected
40
+ URL entries, backend annotation/configuration, and dependencies that have no
41
+ remaining callers. Preserve unrelated CORS, environment, build, and application
42
+ configuration. Never delete or rotate remote keys as part of source removal.
43
+ Run the project's normal validation after the change.
44
+
@@ -0,0 +1,106 @@
1
+ // @ts-check
2
+
3
+ import { CliError } from "./errors.mjs";
4
+
5
+ export class ManagementApiClient {
6
+ /** @param {{ baseUrl: string, fetchImpl?: typeof fetch }} options */
7
+ constructor({ baseUrl, fetchImpl = fetch }) {
8
+ let url;
9
+ try {
10
+ url = new URL(baseUrl);
11
+ } catch {
12
+ throw new CliError("REQUESTSHIELD_API_URL must be a valid URL", { exitCode: 2 });
13
+ }
14
+ if (url.protocol !== "https:" && !isLoopback(url)) {
15
+ throw new CliError("The management API must use HTTPS except on loopback", {
16
+ exitCode: 2,
17
+ });
18
+ }
19
+ this.baseUrl = url.href.replace(/\/$/, "");
20
+ this.fetchImpl = fetchImpl;
21
+ }
22
+
23
+ async startSignin() {
24
+ return this.#request("/v1/cli/signin", {
25
+ method: "POST",
26
+ body: JSON.stringify({ client: "requestshield-cli" }),
27
+ });
28
+ }
29
+
30
+ /** @param {string} deviceCode */
31
+ async pollSignin(deviceCode) {
32
+ return this.#request(
33
+ "/v1/cli/signin/token",
34
+ { method: "POST", body: JSON.stringify({ deviceCode }) },
35
+ true,
36
+ );
37
+ }
38
+
39
+ /** @param {string} accessToken */
40
+ async rotateKeys(accessToken) {
41
+ return this.#request("/v1/cli/keys/rotate", {
42
+ method: "POST",
43
+ headers: { Authorization: `Bearer ${accessToken}` },
44
+ body: "{}",
45
+ });
46
+ }
47
+
48
+ /**
49
+ * @param {string} path
50
+ * @param {RequestInit} init
51
+ * @param {boolean} [allowErrorBody]
52
+ */
53
+ async #request(path, init, allowErrorBody = false) {
54
+ let response;
55
+ try {
56
+ response = await this.fetchImpl(`${this.baseUrl}${path}`, {
57
+ ...init,
58
+ headers: { "Content-Type": "application/json", ...init.headers },
59
+ signal: AbortSignal.timeout(15_000),
60
+ });
61
+ } catch (error) {
62
+ throw new CliError(`Could not reach the RequestShield API: ${messageOf(error)}`, {
63
+ code: "NETWORK_ERROR",
64
+ exitCode: 7,
65
+ });
66
+ }
67
+
68
+ const text = await response.text();
69
+ let body = {};
70
+ if (text !== "") {
71
+ try {
72
+ body = JSON.parse(text);
73
+ } catch {
74
+ throw new CliError("The RequestShield API returned invalid JSON", {
75
+ code: "INVALID_RESPONSE",
76
+ });
77
+ }
78
+ }
79
+ if (!response.ok && !allowErrorBody) {
80
+ const detail = objectString(body, "message") ?? `HTTP ${response.status}`;
81
+ const exitCode = response.status === 401 || response.status === 403 ? 3 : 1;
82
+ throw new CliError(`RequestShield API error: ${detail}`, {
83
+ code: objectString(body, "code") ?? "API_ERROR",
84
+ exitCode,
85
+ });
86
+ }
87
+ return { ok: response.ok, status: response.status, body };
88
+ }
89
+ }
90
+
91
+ /** @param {URL} url */
92
+ function isLoopback(url) {
93
+ return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
94
+ }
95
+
96
+ /** @param {unknown} value @param {string} property */
97
+ export function objectString(value, property) {
98
+ if (!value || typeof value !== "object") return undefined;
99
+ const found = Reflect.get(value, property);
100
+ return typeof found === "string" ? found : undefined;
101
+ }
102
+
103
+ /** @param {unknown} error */
104
+ function messageOf(error) {
105
+ return error instanceof Error ? error.message : String(error);
106
+ }
package/src/args.mjs ADDED
@@ -0,0 +1,75 @@
1
+ // @ts-check
2
+
3
+ import { CliError } from "./errors.mjs";
4
+
5
+ const HELP = `RequestShield customer CLI
6
+
7
+ Usage:
8
+ requestshield signin
9
+ requestshield keys create [--yes]
10
+ requestshield agent setup --agent codex [--force]
11
+ requestshield --help
12
+ requestshield --version`;
13
+
14
+ /**
15
+ * @typedef {{ command: "help", help: string }
16
+ * | { command: "version" }
17
+ * | { command: "signin" }
18
+ * | { command: "keys-create", yes: boolean }
19
+ * | { command: "agent-setup", agent: "codex", force: boolean }} ParsedArgs
20
+ */
21
+
22
+ /** @param {string[]} argv @returns {ParsedArgs} */
23
+ export function parseArgs(argv) {
24
+ if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) {
25
+ return { command: "help", help: HELP };
26
+ }
27
+ if (argv.length === 1 && (argv[0] === "--version" || argv[0] === "-V")) {
28
+ return { command: "version" };
29
+ }
30
+ if (argv[0] === "signin") {
31
+ assertOnly(argv.slice(1), new Set());
32
+ return { command: "signin" };
33
+ }
34
+ if (argv[0] === "keys" && argv[1] === "create") {
35
+ assertOnly(argv.slice(2), new Set(["--yes"]));
36
+ return { command: "keys-create", yes: argv.includes("--yes") };
37
+ }
38
+ if (argv[0] === "agent" && argv[1] === "setup") {
39
+ const rest = argv.slice(2);
40
+ /** @type {string | undefined} */
41
+ let agent;
42
+ let force = false;
43
+ for (let index = 0; index < rest.length; index++) {
44
+ const arg = rest[index];
45
+ if (arg === "--force") {
46
+ force = true;
47
+ } else if (arg === "--agent") {
48
+ agent = rest[++index];
49
+ if (!agent || agent.startsWith("--")) {
50
+ throw new CliError("--agent needs a value", { exitCode: 2 });
51
+ }
52
+ } else {
53
+ throw new CliError(`Unknown option: ${arg}`, { exitCode: 2 });
54
+ }
55
+ }
56
+ if (agent !== "codex") {
57
+ throw new CliError("--agent codex is required; no other agent is supported", {
58
+ exitCode: 2,
59
+ });
60
+ }
61
+ return { command: "agent-setup", agent: "codex", force };
62
+ }
63
+ throw new CliError(`Unknown command.\n\n${HELP}`, { exitCode: 2 });
64
+ }
65
+
66
+ /** @param {string[]} args @param {Set<string>} allowed */
67
+ function assertOnly(args, allowed) {
68
+ for (const arg of args) {
69
+ if (!allowed.has(arg)) {
70
+ throw new CliError(`Unknown option: ${arg}`, { exitCode: 2 });
71
+ }
72
+ }
73
+ }
74
+
75
+ export { HELP };
package/src/cli.mjs ADDED
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+
4
+ import { parseArgs } from "./args.mjs";
5
+ import { ManagementApiClient } from "./api-client.mjs";
6
+ import { SessionStore } from "./session-store.mjs";
7
+ import { signin } from "./commands/signin.mjs";
8
+ import { createKeys } from "./commands/keys-create.mjs";
9
+ import { setupAgent } from "./commands/agent-setup.mjs";
10
+ import packageJson from "../package.json" with { type: "json" };
11
+
12
+ /**
13
+ * @param {string[]} argv
14
+ * @param {{ log?: (message: string) => void, api?: ManagementApiClient, sessions?: SessionStore, env?: NodeJS.ProcessEnv, homeDir?: string, sourceDir?: string, wait?: (milliseconds: number) => Promise<unknown>, confirm?: () => Promise<boolean> }} [deps]
15
+ */
16
+ export async function run(argv, deps = {}) {
17
+ const parsed = parseArgs(argv);
18
+ const log = deps.log ?? ((message) => console.log(message));
19
+ if (parsed.command === "help") return log(parsed.help);
20
+ if (parsed.command === "version") return log(packageJson.version);
21
+
22
+ if (parsed.command === "agent-setup") {
23
+ return setupAgent(parsed, { log, ...deps });
24
+ }
25
+
26
+ const env = deps.env ?? process.env;
27
+
28
+ const api = deps.api ?? new ManagementApiClient({
29
+ baseUrl:
30
+ env.REQUESTSHIELD_API_URL ??
31
+ "https://api.intellifend.ai",
32
+ });
33
+
34
+ const sessions = deps.sessions ?? new SessionStore({
35
+ env,
36
+ homeDir: deps.homeDir,
37
+ });
38
+ if (parsed.command === "signin") return signin({ api, sessions, log, ...deps });
39
+ if (parsed.command === "keys-create") return createKeys(parsed, { api, sessions, log, ...deps });
40
+ }
@@ -0,0 +1,91 @@
1
+ // @ts-check
2
+
3
+ import { cp, lstat, mkdir, rename, rm, writeFile } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { randomUUID } from "node:crypto";
7
+ import { CliError } from "../errors.mjs";
8
+ import { getAsset, isSea } from "node:sea";
9
+
10
+ const skillAsset = "requestshield-skill.md";
11
+
12
+ /**
13
+ * @param {{ agent: string, force: boolean }} options
14
+ * @param {{ env?: NodeJS.ProcessEnv, homeDir?: string, log: (message: string) => void, sourceDir?: string }} deps
15
+ */
16
+ export async function setupAgent(options, deps) {
17
+ if (options.agent !== "codex") {
18
+ throw new CliError(`Unsupported agent: ${options.agent}`, { exitCode: 2 });
19
+ }
20
+ const env = deps.env ?? process.env;
21
+ const codexHome = env.CODEX_HOME || path.join(deps.homeDir ?? os.homedir(), ".codex");
22
+ const skillsRoot = path.join(codexHome, "skills");
23
+ const destination = path.join(skillsRoot, "requestshield");
24
+ await mkdir(skillsRoot, { recursive: true });
25
+
26
+ const exists = await pathExists(destination);
27
+ if (exists && !options.force) {
28
+ throw new CliError(`RequestShield skill already exists at ${destination}; use --force to replace it`, {
29
+ code: "SKILL_EXISTS",
30
+ exitCode: 2,
31
+ });
32
+ }
33
+
34
+ const staging = path.join(skillsRoot, `.requestshield-${randomUUID()}.tmp`);
35
+ const backup = `${destination}.${randomUUID()}.backup`;
36
+ try {
37
+ if (deps.sourceDir) {
38
+ // Dùng trong test hoặc khi caller chỉ định source.
39
+ await cp(deps.sourceDir, staging, {
40
+ recursive: true,
41
+ errorOnExist: true,
42
+ });
43
+ } else if (isSea()) {
44
+ // SEA không có thư mục source trên disk, đọc skill đã nhúng.
45
+ await mkdir(staging);
46
+
47
+ await writeFile(
48
+ path.join(staging, "SKILL.md"),
49
+ Buffer.from(getAsset(skillAsset)),
50
+ );
51
+ } else {
52
+ // Chạy từ source/npm hoặc chạy bundle CJS trên disk.
53
+ //
54
+ // src/main.mjs -> ../skills/requestshield
55
+ // dist/requestshield.cjs -> ../skills/requestshield
56
+ const executableDir = path.dirname(path.resolve(process.argv[1]));
57
+ const source = path.resolve(
58
+ executableDir,
59
+ "../skills/requestshield",
60
+ );
61
+
62
+ await cp(source, staging, {
63
+ recursive: true,
64
+ errorOnExist: true,
65
+ });
66
+ }
67
+ if (exists) await rename(destination, backup);
68
+ await rename(staging, destination);
69
+ if (exists) await rm(backup, { recursive: true, force: true });
70
+ } catch (error) {
71
+ await rm(staging, { recursive: true, force: true });
72
+ if (await pathExists(backup)) {
73
+ await rm(destination, { recursive: true, force: true });
74
+ await rename(backup, destination);
75
+ }
76
+ throw error;
77
+ }
78
+ deps.log(`Installed RequestShield skill for Codex at ${destination}`);
79
+ deps.log("Restart Codex or start a new task before using the skill.");
80
+ }
81
+
82
+ /** @param {string} target */
83
+ async function pathExists(target) {
84
+ try {
85
+ await lstat(target);
86
+ return true;
87
+ } catch (error) {
88
+ if (/** @type {NodeJS.ErrnoException} */ (error).code === "ENOENT") return false;
89
+ throw error;
90
+ }
91
+ }
@@ -0,0 +1,46 @@
1
+ // @ts-check
2
+
3
+ import readline from "node:readline/promises";
4
+ import { stdin, stdout } from "node:process";
5
+ import { CliError } from "../errors.mjs";
6
+ import { objectString } from "../api-client.mjs";
7
+
8
+ /**
9
+ * @param {{ yes: boolean }} options
10
+ * @param {{ api: { rotateKeys(accessToken: string): Promise<{body: unknown}> }, sessions: { loadToken(): Promise<string> }, log: (message: string) => void, confirm?: () => Promise<boolean> }} deps
11
+ */
12
+ export async function createKeys(options, deps) {
13
+ if (!options.yes) {
14
+ const accepted = await (deps.confirm ?? confirmRotation)();
15
+ if (!accepted) throw new CliError("Key rotation cancelled", { code: "CANCELLED", exitCode: 2 });
16
+ }
17
+ const token = await deps.sessions.loadToken();
18
+ const result = await deps.api.rotateKeys(token);
19
+ const appKey = objectString(result.body, "appKey");
20
+ const apiSecret = objectString(result.body, "apiSecret");
21
+ if (!appKey || !apiSecret) {
22
+ throw new CliError("The key rotation response did not contain appKey and apiSecret", {
23
+ code: "INVALID_RESPONSE",
24
+ });
25
+ }
26
+ deps.log("New RequestShield keys are active. Previous keys are deactivated.");
27
+ deps.log(`App Key: ${appKey}`);
28
+ deps.log(`Secret Key (shown once): ${apiSecret}`);
29
+ deps.log("Store the Secret Key in your backend secret manager now. The CLI did not save it.");
30
+ }
31
+
32
+ async function confirmRotation() {
33
+ if (!stdin.isTTY || !stdout.isTTY) {
34
+ throw new CliError("Refusing non-interactive key rotation without --yes", {
35
+ code: "CONFIRMATION_REQUIRED",
36
+ exitCode: 2,
37
+ });
38
+ }
39
+ const prompt = readline.createInterface({ input: stdin, output: stdout });
40
+ try {
41
+ const answer = await prompt.question("This deactivates the current keys. Type ROTATE to continue: ");
42
+ return answer === "ROTATE";
43
+ } finally {
44
+ prompt.close();
45
+ }
46
+ }
@@ -0,0 +1,65 @@
1
+ // @ts-check
2
+
3
+ import { setTimeout as delay } from "node:timers/promises";
4
+ import { CliError } from "../errors.mjs";
5
+ import { objectString } from "../api-client.mjs";
6
+
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
9
+ */
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
+
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 });
40
+ }
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
+ });
46
+ }
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
+ }
package/src/errors.mjs ADDED
@@ -0,0 +1,14 @@
1
+ // @ts-check
2
+
3
+ export class CliError extends Error {
4
+ /**
5
+ * @param {string} message
6
+ * @param {{ code?: string, exitCode?: number }} [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
+ }
14
+ }
package/src/main.mjs ADDED
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+
4
+ import { run } from "./cli.mjs";
5
+ import { CliError } from "./errors.mjs";
6
+
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();
@@ -0,0 +1,91 @@
1
+ // @ts-check
2
+
3
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { randomUUID } from "node:crypto";
7
+ import { spawnSync } from "node:child_process";
8
+ import { CliError } from "./errors.mjs";
9
+
10
+ export class SessionStore {
11
+ /** @param {{ env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, homeDir?: string }} [options] */
12
+ constructor(options = {}) {
13
+ this.env = options.env ?? process.env;
14
+ this.platform = options.platform ?? process.platform;
15
+ this.homeDir = options.homeDir ?? os.homedir();
16
+ this.file = sessionPath(this.env, this.platform, this.homeDir);
17
+ }
18
+
19
+ /** @param {{ accessToken: string, account?: unknown }} session */
20
+ async save(session) {
21
+ if (!session.accessToken) throw new CliError("Cannot store an empty session token");
22
+ const directory = path.dirname(this.file);
23
+ await mkdir(directory, { recursive: true, mode: 0o700 });
24
+ const temp = `${this.file}.${randomUUID()}.tmp`;
25
+ try {
26
+ await writeFile(temp, `${JSON.stringify(session)}\n`, { encoding: "utf8", mode: 0o600 });
27
+ await chmod(temp, 0o600);
28
+ await rename(temp, this.file);
29
+ await chmod(this.file, 0o600);
30
+ if (this.platform === "win32") restrictWindowsAcl(this.file);
31
+ } finally {
32
+ await rm(temp, { force: true });
33
+ }
34
+ }
35
+
36
+ async loadToken() {
37
+ const environmentToken = this.env.REQUESTSHIELD_ACCESS_TOKEN?.trim();
38
+ if (environmentToken) return environmentToken;
39
+ let contents;
40
+ try {
41
+ contents = await readFile(this.file, "utf8");
42
+ } catch (error) {
43
+ if (/** @type {NodeJS.ErrnoException} */ (error).code === "ENOENT") {
44
+ throw new CliError("Not signed in. Run `requestshield signin` first.", {
45
+ code: "NOT_SIGNED_IN",
46
+ exitCode: 3,
47
+ });
48
+ }
49
+ throw error;
50
+ }
51
+ let session;
52
+ try {
53
+ session = JSON.parse(contents);
54
+ } catch {
55
+ throw new CliError("The saved RequestShield session is invalid; sign in again", {
56
+ code: "INVALID_SESSION",
57
+ exitCode: 3,
58
+ });
59
+ }
60
+ const token = session && typeof session === "object" ? session.accessToken : undefined;
61
+ if (typeof token !== "string" || token === "") {
62
+ throw new CliError("The saved RequestShield session is invalid; sign in again", {
63
+ code: "INVALID_SESSION",
64
+ exitCode: 3,
65
+ });
66
+ }
67
+ return token;
68
+ }
69
+ }
70
+
71
+ /** @param {NodeJS.ProcessEnv} env @param {NodeJS.Platform} platform @param {string} homeDir */
72
+ export function sessionPath(env, platform, homeDir) {
73
+ if (platform === "win32") {
74
+ return path.join(env.LOCALAPPDATA || path.join(homeDir, "AppData", "Local"), "IntelliFend", "RequestShield", "session.json");
75
+ }
76
+ const base = env.XDG_STATE_HOME || path.join(homeDir, ".local", "state");
77
+ return path.join(base, "intellifend", "requestshield", "session.json");
78
+ }
79
+
80
+ /** @param {string} file */
81
+ function restrictWindowsAcl(file) {
82
+ const result = spawnSync("icacls.exe", [file, "/inheritance:r", "/grant:r", `${process.env.USERNAME}:(R,W)`], {
83
+ stdio: "ignore",
84
+ windowsHide: true,
85
+ });
86
+ if ((result.status ?? 1) !== 0) {
87
+ throw new CliError("The session was written but its Windows ACL could not be restricted; remove it and sign in again", {
88
+ code: "SESSION_PERMISSION_ERROR",
89
+ });
90
+ }
91
+ }