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,153 @@
1
+ // @ts-check
2
+ import { CliError } from "./errors.mjs";
3
+ import { validateOAuthConfig, REQUESTED_OAUTH_SCOPES, validateBearerToken, validatedUrl } from "./config.mjs";
4
+
5
+ /** @typedef {{accessToken: string, refreshToken: string, expiresAt: number, scopes: string[]}} OAuthCredentials */
6
+ /** @typedef {{authorizationEndpoint: string, tokenEndpoint: string}} OAuthDiscovery */
7
+
8
+ const MAX_BODY_BYTES = 64 * 1024;
9
+
10
+ export class OAuthClient {
11
+ /** @param {{config: import('./config.mjs').OAuthConfig, fetchImpl?: typeof fetch, now?: () => number, timeoutMs?: number}} options */
12
+ constructor({config, fetchImpl = fetch, now = Date.now, timeoutMs = 15_000}) {
13
+ this.config = validateOAuthConfig(config);
14
+ this.fetchImpl = fetchImpl;
15
+ this.now = now;
16
+ this.timeoutMs = timeoutMs;
17
+ /** @type {OAuthDiscovery | undefined} */
18
+ this.discovery = undefined;
19
+ }
20
+
21
+ /** @param {{signal?: AbortSignal}} [options] @returns {Promise<OAuthDiscovery>} */
22
+ async discover({signal} = {}) {
23
+ signal?.throwIfAborted();
24
+ if (this.discovery) return this.discovery;
25
+ const {body} = await this.#request(`${this.config.issuer}/.well-known/openid-configuration`, {method: "GET"}, signal);
26
+ if (body.issuer !== this.config.issuer) throw invalidResponse();
27
+ let authorizationEndpoint, tokenEndpoint;
28
+ try {
29
+ authorizationEndpoint = validatedUrl(body.authorization_endpoint, "authorization endpoint");
30
+ tokenEndpoint = validatedUrl(body.token_endpoint, "token endpoint");
31
+ } catch { throw invalidResponse(); }
32
+ const tokenUrl = new URL(tokenEndpoint);
33
+ if (tokenUrl.protocol !== "https:" || tokenUrl.origin !== new URL(this.config.issuer).origin) throw invalidResponse();
34
+ if (!Array.isArray(body.response_types_supported) || !body.response_types_supported.includes("code")
35
+ || !Array.isArray(body.code_challenge_methods_supported) || !body.code_challenge_methods_supported.includes("S256")) throw invalidResponse();
36
+ this.discovery = {authorizationEndpoint, tokenEndpoint};
37
+ return this.discovery;
38
+ }
39
+
40
+ /** @param {{code: string, verifier: string, redirectUri: string, signal?: AbortSignal}} args @returns {Promise<OAuthCredentials>} */
41
+ async exchangeCode({code, verifier, redirectUri, signal}) {
42
+ const {tokenEndpoint} = await this.discover({signal});
43
+ const {body} = await this.#request(tokenEndpoint, {
44
+ method: "POST",
45
+ body: new URLSearchParams({grant_type: "authorization_code", client_id: this.config.clientId, code, code_verifier: verifier, redirect_uri: redirectUri}),
46
+ }, signal);
47
+ return this.#credentials(body);
48
+ }
49
+
50
+ /** Public refresh tokens rotate. Never retry a dispatched exchange. @param {string} refreshToken @returns {Promise<OAuthCredentials>} */
51
+ async refresh(refreshToken) {
52
+ const {tokenEndpoint} = await this.discover();
53
+ try {
54
+ const {body} = await this.#request(tokenEndpoint, {
55
+ method: "POST",
56
+ body: new URLSearchParams({grant_type: "refresh_token", client_id: this.config.clientId, refresh_token: refreshToken}),
57
+ });
58
+ return this.#credentials(body);
59
+ } catch (error) {
60
+ if (error instanceof CliError && ["OAUTH_INVALID_GRANT", "OAUTH_REJECTED"].includes(error.code)) throw error;
61
+ throw new CliError("The refresh result is uncertain; run `requestshield signin` again", {code: "OAUTH_REFRESH_UNCERTAIN", exitCode: 3});
62
+ }
63
+ }
64
+
65
+ /** @param {Record<string, unknown>} body @returns {OAuthCredentials} */
66
+ #credentials(body) {
67
+ let accessToken;
68
+ try { accessToken = validateBearerToken(body.access_token); } catch { throw invalidResponse(); }
69
+ const refreshToken = body.refresh_token;
70
+ if (typeof refreshToken !== "string" || !/^[\x21-\x7e]{1,16384}$/.test(refreshToken)
71
+ || typeof body.token_type !== "string" || body.token_type.toLowerCase() !== "bearer"
72
+ || typeof body.expires_in !== "number" || !Number.isSafeInteger(body.expires_in) || body.expires_in <= 0 || body.expires_in > 900
73
+ || typeof body.scope !== "string" || body.scope.length > 2048) throw invalidResponse();
74
+ const scopes = body.scope.split(" ");
75
+ if (new Set(scopes).size !== scopes.length || scopes.length !== REQUESTED_OAUTH_SCOPES.length
76
+ || !REQUESTED_OAUTH_SCOPES.every(scope => scopes.includes(scope))) throw invalidResponse();
77
+ return {accessToken, refreshToken, expiresAt: this.now() + body.expires_in * 1000, scopes};
78
+ }
79
+
80
+ /** @param {string} url @param {RequestInit} init @param {AbortSignal} [outerSignal] */
81
+ async #request(url, init, outerSignal) {
82
+ const controller = new AbortController();
83
+ const abort = () => controller.abort();
84
+ const timeout = setTimeout(abort, this.timeoutMs);
85
+ outerSignal?.addEventListener("abort", abort, {once: true});
86
+ if (outerSignal?.aborted) abort();
87
+ const signal = controller.signal;
88
+ let onAbort = () => {};
89
+ const cancelled = new Promise((_, reject) => {
90
+ onAbort = () => reject(new CliError("The identity provider could not be reached", {code: "NETWORK_ERROR", exitCode: 7}));
91
+ signal.addEventListener("abort", onAbort, {once: true});
92
+ if (signal.aborted) onAbort();
93
+ });
94
+ try {
95
+ const operation = (async () => {
96
+ signal.throwIfAborted();
97
+ const response = await this.fetchImpl(url, {
98
+ ...init, signal, redirect: "error", credentials: "omit", cache: "no-store",
99
+ headers: {Accept: "application/json", ...(init.body ? {"Content-Type": "application/x-www-form-urlencoded"} : {})},
100
+ });
101
+ if (response.status >= 300 && response.status < 400) throw invalidResponse();
102
+ if (!response.body || Number(response.headers.get("content-length")) > MAX_BODY_BYTES) {
103
+ void response.body?.cancel().catch(() => {});
104
+ throw invalidResponse();
105
+ }
106
+ const reader = response.body.getReader();
107
+ const cancelBody = () => { void reader.cancel().catch(() => {}); };
108
+ signal.addEventListener("abort", cancelBody, {once: true});
109
+ if (signal.aborted) cancelBody();
110
+ const chunks = [];
111
+ let size = 0;
112
+ try {
113
+ while (true) {
114
+ const {done, value} = await reader.read();
115
+ if (done) break;
116
+ size += value.length;
117
+ if (size > MAX_BODY_BYTES) { void reader.cancel().catch(() => {}); throw invalidResponse(); }
118
+ chunks.push(value);
119
+ }
120
+ } finally { signal.removeEventListener("abort", cancelBody); reader.releaseLock(); }
121
+ /** @type {Record<string, unknown>} */
122
+ let body;
123
+ try {
124
+ body = JSON.parse(new TextDecoder("utf-8", {fatal: true}).decode(Buffer.concat(chunks)));
125
+ if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error();
126
+ } catch { throw invalidResponse(); }
127
+ if (!response.ok) {
128
+ if (body.error === "invalid_grant" || ["idp_refresh_token_invalid", "idp_authorization_code_invalid", "invalid_grant"].includes(String(body.error_type))) {
129
+ throw new CliError("Authorization was rejected; run `requestshield signin` again", {code: "OAUTH_INVALID_GRANT", exitCode: 3});
130
+ }
131
+ if (response.status >= 400 && response.status < 500) {
132
+ throw new CliError("The identity provider rejected the request; check the OAuth configuration or sign in again", {code: "OAUTH_REJECTED", exitCode: 3});
133
+ }
134
+ throw new CliError("The identity provider is unavailable", {code: "NETWORK_ERROR", exitCode: 7});
135
+ }
136
+ return {body};
137
+ })();
138
+ return await Promise.race([operation, /** @type {Promise<never>} */ (cancelled)]);
139
+ } catch (error) {
140
+ if (error instanceof CliError) throw error;
141
+ throw new CliError("The identity provider could not be reached", {code: "NETWORK_ERROR", exitCode: 7});
142
+ } finally {
143
+ clearTimeout(timeout);
144
+ signal.removeEventListener("abort", onAbort);
145
+ outerSignal?.removeEventListener("abort", abort);
146
+ controller.abort();
147
+ }
148
+ }
149
+ }
150
+
151
+ function invalidResponse() {
152
+ return new CliError("The identity provider returned an invalid OAuth response; check the provider configuration", {code: "OAUTH_INVALID_RESPONSE", exitCode: 3});
153
+ }
@@ -0,0 +1,120 @@
1
+ // @ts-check
2
+ import { createServer } from "node:http";
3
+ import { timingSafeEqual } from "node:crypto";
4
+ import { CliError } from "./errors.mjs";
5
+
6
+ /** @typedef {{code: string}} AuthorizationResult */
7
+
8
+ /** Bind before returning, so opening the browser cannot race port allocation.
9
+ * @param {{state: string, issuer: string, signal: AbortSignal}} options
10
+ */
11
+ export async function createLoopbackReceiver({state, issuer, signal}) {
12
+ /** @type {(value: AuthorizationResult) => void} */
13
+ let resolveResult;
14
+ /** @type {(error: Error) => void} */
15
+ let rejectResult;
16
+ const result = new Promise((resolve, reject) => { resolveResult = resolve; rejectResult = reject; });
17
+ // A callback can arrive while the OS browser launcher is still returning.
18
+ void result.catch(() => {});
19
+ /** @type {import('node:http').ServerResponse | undefined} */
20
+ let pendingResponse;
21
+ let accepted = false;
22
+ let expectedHost = "";
23
+ const server = createServer({maxHeaderSize: 8192, requestTimeout: 5_000, headersTimeout: 5_000}, (request, response) => {
24
+ response.setHeader("Cache-Control", "no-store");
25
+ response.setHeader("Referrer-Policy", "no-referrer");
26
+ response.setHeader("Content-Type", "text/plain; charset=utf-8");
27
+ response.setHeader("X-Content-Type-Options", "nosniff");
28
+ response.setHeader("Connection", "close");
29
+ const hostCount = request.rawHeaders.filter((_, i) => i % 2 === 0 && request.rawHeaders[i].toLowerCase() === "host").length;
30
+ if (request.method !== "GET" || request.headers.host !== expectedHost || hostCount !== 1
31
+ || request.headers["transfer-encoding"] || (request.headers["content-length"] && request.headers["content-length"] !== "0")) {
32
+ response.writeHead(400).end("Invalid authorization callback."); return;
33
+ }
34
+ const raw = request.url ?? "";
35
+ if (raw.length > 8192 || raw.split("?")[0] !== "/callback") {
36
+ response.writeHead(404).end("Not found."); return;
37
+ }
38
+ let url;
39
+ try { url = new URL(raw, `http://${expectedHost}`); } catch {
40
+ response.writeHead(400).end("Invalid authorization callback."); return;
41
+ }
42
+ const params = url.searchParams;
43
+ const suppliedState = params.get("state") ?? "";
44
+ const code = params.get("code");
45
+ const error = params.get("error");
46
+ if (url.hash || [...params.keys()].some(key => params.getAll(key).length !== 1)
47
+ || !equalState(suppliedState, state)
48
+ || (params.has("iss") && params.get("iss") !== issuer)
49
+ || (code === null) === (error === null)
50
+ || (code !== null && !/^[\x21-\x7e]{1,4096}$/.test(code))
51
+ || (error !== null && !/^[A-Za-z_]{1,128}$/.test(error))) {
52
+ response.writeHead(400).end("Invalid authorization callback."); return;
53
+ }
54
+ if (accepted) { response.writeHead(409).end("Authorization callback already received."); return; }
55
+ accepted = true;
56
+ if (error !== null) {
57
+ response.writeHead(400).end("Authorization was not completed. Return to your terminal.");
58
+ rejectResult(new CliError("Sign-in was denied or cancelled", {code: "SIGNIN_DENIED", exitCode: 3}));
59
+ return;
60
+ }
61
+ // Keep this response pending until token exchange AND protected storage succeed.
62
+ pendingResponse = response;
63
+ resolveResult({code: /** @type {string} */ (code)});
64
+ });
65
+ server.maxConnections = 8;
66
+ server.on("clientError", (_error, socket) => {
67
+ if (socket.writable) socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
68
+ else socket.destroy();
69
+ });
70
+ const aborted = () => {
71
+ rejectResult(new CliError("Sign-in was cancelled or expired; run `requestshield signin` again", {code: "SIGNIN_CANCELLED", exitCode: 3}));
72
+ if (pendingResponse && !pendingResponse.writableEnded) pendingResponse.writeHead(400).end("Sign-in was not completed. Return to your terminal.");
73
+ server.close();
74
+ server.closeAllConnections();
75
+ };
76
+ signal.addEventListener("abort", aborted, {once: true});
77
+ try {
78
+ if (signal.aborted) throw new Error();
79
+ await new Promise((resolve, reject) => {
80
+ server.once("error", reject);
81
+ server.listen(0, "127.0.0.1", () => { server.removeListener("error", reject); resolve(undefined); });
82
+ });
83
+ if (signal.aborted) throw new Error();
84
+ const address = server.address();
85
+ if (!address || typeof address === "string") throw new Error();
86
+ expectedHost = `127.0.0.1:${address.port}`;
87
+ } catch {
88
+ signal.removeEventListener("abort", aborted);
89
+ server.close();
90
+ server.closeAllConnections();
91
+ throw new CliError("Could not start the local sign-in callback listener", {code: "SIGNIN_LISTENER_ERROR", exitCode: 3});
92
+ }
93
+ server.on("error", () => rejectResult(new CliError("The local sign-in listener failed", {code: "SIGNIN_LISTENER_ERROR", exitCode: 3})));
94
+ return {
95
+ redirectUri: `http://${expectedHost}/callback`,
96
+ result: /** @type {Promise<AuthorizationResult>} */ (result),
97
+ /** @param {boolean} success */
98
+ complete(success) {
99
+ if (!pendingResponse || pendingResponse.writableEnded) return;
100
+ pendingResponse.writeHead(success ? 200 : 400).end(success
101
+ ? "Signed in successfully. You may close this window and return to your terminal."
102
+ : "Sign-in was not completed. Return to your terminal.");
103
+ },
104
+ async close() {
105
+ signal.removeEventListener("abort", aborted);
106
+ if (!server.listening) return;
107
+ await new Promise(resolve => {
108
+ const timer = setTimeout(() => server.closeAllConnections(), 1000);
109
+ server.close(() => { clearTimeout(timer); resolve(undefined); });
110
+ });
111
+ },
112
+ };
113
+ }
114
+
115
+ /** @param {string} actual @param {string} expected */
116
+ function equalState(actual, expected) {
117
+ const a = Buffer.from(actual);
118
+ const b = Buffer.from(expected);
119
+ return a.length === b.length && timingSafeEqual(a, b);
120
+ }
@@ -0,0 +1,213 @@
1
+ // @ts-check
2
+
3
+ import * as nativeFs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { randomUUID } from "node:crypto";
6
+ import { execFile } from "node:child_process";
7
+ import { promisify } from "node:util";
8
+ import { setTimeout as delay } from "node:timers/promises";
9
+ import { CliError } from "./errors.mjs";
10
+
11
+ const MAX_SESSION_BYTES = 65_536;
12
+
13
+ /** Filesystem errors never expose exception text that might contain credentials. */
14
+ export class SessionFiles {
15
+ /** @param {string} file @param {{platform?: NodeJS.Platform, fs?: Partial<typeof nativeFs>, protectPath?: (file: string, directory: boolean) => Promise<void>, syncDirectory?: () => Promise<void>, lockWaitMs?: number}} [options] */
16
+ constructor(file, options = {}) {
17
+ this.file = file;
18
+ this.directory = path.dirname(file);
19
+ this.lock = `${file}.lock`;
20
+ this.fs = { ...nativeFs, ...options.fs };
21
+ this.platform = options.platform ?? process.platform;
22
+ this.protectPath = options.protectPath ?? ((file, directory) => this.#protect(file, directory));
23
+ this.syncDirectory = options.syncDirectory ?? (() => this.#syncDirectory());
24
+ this.lockWaitMs = options.lockWaitMs ?? 10_000;
25
+ }
26
+
27
+ /** @template T @param {() => Promise<T>} action @returns {Promise<T>} */
28
+ async withLock(action) {
29
+ await this.#prepareDirectory();
30
+ const deadline = performance.now() + this.lockWaitMs;
31
+ let handle;
32
+ while (!handle) {
33
+ try { handle = await this.fs.open(this.lock, "wx", 0o600); }
34
+ catch (error) {
35
+ if (errorCode(error) !== "EEXIST") throw storageFailure();
36
+ if (performance.now() >= deadline) {
37
+ throw new CliError("Another RequestShield command holds the session lock. Retry after it exits. If a command crashed, remove the session.json.lock file only after confirming no RequestShield command is running.", {
38
+ code: "SESSION_LOCKED", exitCode: 7,
39
+ });
40
+ }
41
+ await delay(40);
42
+ }
43
+ }
44
+ try {
45
+ // Never steal an old-looking lock: a suspended process may own it while
46
+ // its provider request rotates a token. No credentials enter this file.
47
+ await handle.writeFile(JSON.stringify({ pid: process.pid, id: randomUUID() }));
48
+ return await action();
49
+ } catch (error) {
50
+ if (error instanceof CliError) throw error;
51
+ throw storageFailure();
52
+ } finally {
53
+ await handle.close().catch(() => undefined);
54
+ await this.fs.rm(this.lock, { force: true }).catch(() => undefined);
55
+ }
56
+ }
57
+
58
+ async read() {
59
+ let handle;
60
+ try {
61
+ const directory = await this.fs.lstat(this.directory);
62
+ if (!directory.isDirectory() || directory.isSymbolicLink()) throw storageFailure();
63
+ const stat = await this.fs.lstat(this.file);
64
+ if (!stat.isFile() || stat.isSymbolicLink()) throw storageFailure();
65
+ if (this.platform !== "win32" && process.platform !== "win32"
66
+ && ((stat.mode & 0o077) !== 0 || (directory.mode & 0o077) !== 0
67
+ || stat.uid !== process.getuid?.() || directory.uid !== process.getuid?.())) throw permissionFailure();
68
+ handle = await this.fs.open(this.file, "r");
69
+ const buffer = Buffer.alloc(MAX_SESSION_BYTES + 1);
70
+ let total = 0;
71
+ while (total < buffer.length) {
72
+ const { bytesRead } = await handle.read(buffer, total, buffer.length - total, null);
73
+ if (bytesRead === 0) break;
74
+ total += bytesRead;
75
+ }
76
+ if (total > MAX_SESSION_BYTES) throw invalidFile();
77
+ try { return JSON.parse(buffer.subarray(0, total).toString("utf8")); }
78
+ catch { throw invalidFile(); }
79
+ } catch (error) {
80
+ if (error instanceof CliError) throw error;
81
+ if (errorCode(error) === "ENOENT") {
82
+ throw new CliError("Not signed in. Run `requestshield signin` first.", { code: "NOT_SIGNED_IN", exitCode: 3 });
83
+ }
84
+ throw storageFailure();
85
+ } finally { await handle?.close().catch(() => undefined); }
86
+ }
87
+
88
+ /** Remove only the selected regular credential file under the refresh lock.
89
+ * No JSON parsing is needed, so malformed or mismatched sessions can be cleared.
90
+ */
91
+ async remove() {
92
+ try {
93
+ const directory = await this.fs.lstat(this.directory);
94
+ if (!directory.isDirectory() || directory.isSymbolicLink()) throw storageFailure();
95
+ } catch (error) {
96
+ if (errorCode(error) === "ENOENT") return false;
97
+ if (error instanceof CliError) throw error;
98
+ throw storageFailure();
99
+ }
100
+ return this.withLock(async () => {
101
+ let removed = false;
102
+ try {
103
+ const existing = await this.fs.lstat(this.file);
104
+ if (!existing.isFile() || existing.isSymbolicLink()) throw storageFailure();
105
+ await this.fs.unlink(this.file);
106
+ removed = true;
107
+ await this.syncDirectory();
108
+ return true;
109
+ } catch (error) {
110
+ if (removed) {
111
+ throw new CliError("The local session was removed, but its durability could not be confirmed. Run `requestshield signout` again to confirm removal.", {
112
+ code: "SESSION_COMMIT_UNCERTAIN", exitCode: 7,
113
+ });
114
+ }
115
+ if (errorCode(error) === "ENOENT") return false;
116
+ if (error instanceof CliError) throw error;
117
+ throw storageFailure();
118
+ }
119
+ });
120
+ }
121
+
122
+ /** Caller holds the session lock. @param {unknown} value */
123
+ async write(value) {
124
+ const temp = `${this.file}.${randomUUID()}.tmp`;
125
+ let handle;
126
+ let replaced = false;
127
+ try {
128
+ try {
129
+ const existing = await this.fs.lstat(this.file);
130
+ if (!existing.isFile() || existing.isSymbolicLink()) throw storageFailure();
131
+ } catch (error) { if (errorCode(error) !== "ENOENT") throw error; }
132
+ handle = await this.fs.open(temp, "wx", 0o600);
133
+ await this.protectPath(temp, false);
134
+ const serialized = `${JSON.stringify(value)}\n`;
135
+ if (Buffer.byteLength(serialized) > MAX_SESSION_BYTES) throw invalidFile();
136
+ await handle.writeFile(serialized, "utf8");
137
+ await handle.sync();
138
+ await handle.close();
139
+ handle = undefined;
140
+ await this.fs.rename(temp, this.file);
141
+ replaced = true;
142
+ await this.syncDirectory();
143
+ } catch (error) {
144
+ if (replaced) {
145
+ throw new CliError("The session was replaced, but its durability could not be confirmed. Run `requestshield signin` again if the next command cannot load it.", {
146
+ code: "SESSION_COMMIT_UNCERTAIN", exitCode: 7,
147
+ });
148
+ }
149
+ if (error instanceof CliError) throw error;
150
+ throw storageFailure();
151
+ } finally {
152
+ await handle?.close().catch(() => undefined);
153
+ await this.fs.rm(temp, { force: true }).catch(() => undefined);
154
+ }
155
+ }
156
+
157
+ async #prepareDirectory() {
158
+ try {
159
+ await this.fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
160
+ const stat = await this.fs.lstat(this.directory);
161
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw permissionFailure();
162
+ await this.protectPath(this.directory, true);
163
+ } catch (error) {
164
+ if (error instanceof CliError) throw error;
165
+ throw permissionFailure();
166
+ }
167
+ }
168
+
169
+ async #syncDirectory() {
170
+ if (this.platform === "win32" || process.platform === "win32") return;
171
+ const directory = await this.fs.open(this.directory, "r");
172
+ try { await directory.sync(); } finally { await directory.close(); }
173
+ }
174
+
175
+ /** @param {string} file @param {boolean} directory */
176
+ async #protect(file, directory) {
177
+ try {
178
+ if (this.platform !== "win32") {
179
+ await this.fs.chmod(file, directory ? 0o700 : 0o600);
180
+ return;
181
+ }
182
+ // Static script, path in environment, current SID avoids domain lookup.
183
+ const script = [
184
+ '$ErrorActionPreference = "Stop"',
185
+ '$sessionSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User',
186
+ `$sessionAcl = New-Object System.Security.AccessControl.${directory ? "DirectorySecurity" : "FileSecurity"}`,
187
+ '$sessionAcl.SetOwner($sessionSid)',
188
+ '$sessionAcl.SetAccessRuleProtection($true, $false)',
189
+ directory
190
+ ? '$sessionRule = New-Object System.Security.AccessControl.FileSystemAccessRule($sessionSid, "FullControl", "ContainerInherit,ObjectInherit", "None", "Allow")'
191
+ : '$sessionRule = New-Object System.Security.AccessControl.FileSystemAccessRule($sessionSid, "FullControl", "Allow")',
192
+ '$sessionAcl.AddAccessRule($sessionRule)',
193
+ `[System.IO.${directory ? "Directory" : "File"}]::SetAccessControl($env:REQUESTSHIELD_SESSION_ACL_PATH, $sessionAcl)`,
194
+ ].join("\n");
195
+ await promisify(execFile)("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
196
+ windowsHide: true, timeout: 10_000,
197
+ env: { SystemRoot: process.env.SystemRoot, PATH: process.env.PATH, REQUESTSHIELD_SESSION_ACL_PATH: file },
198
+ });
199
+ } catch { throw permissionFailure(); }
200
+ }
201
+ }
202
+
203
+ /** @param {unknown} error */
204
+ function errorCode(error) { return error && typeof error === "object" ? Reflect.get(error, "code") : undefined; }
205
+ function storageFailure() {
206
+ return new CliError("Could not read or safely save the RequestShield session. Check local storage permissions and sign in again if a refresh was interrupted.", { code: "SESSION_STORAGE_ERROR", exitCode: 7 });
207
+ }
208
+ function permissionFailure() {
209
+ return new CliError("Could not protect the RequestShield session for the current operating-system user. No new credentials were written.", { code: "SESSION_PERMISSION_ERROR", exitCode: 7 });
210
+ }
211
+ function invalidFile() {
212
+ return new CliError("The saved RequestShield session is invalid. Run `requestshield signin` again.", { code: "INVALID_SESSION", exitCode: 3 });
213
+ }