requestshield 0.0.0-stage → 1.0.0-stg.276
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +292 -2
- package/config/.env.stg +8 -0
- package/package.json +25 -4
- package/skills/requestshield/SKILL.md +240 -0
- package/skills/requestshield/assets/AGENTS.codex.md +107 -0
- package/skills/requestshield/references/backend-java-core.md +152 -0
- package/skills/requestshield/references/backend-spring-boot.md +158 -0
- package/skills/requestshield/references/browser-manual.md +244 -0
- package/skills/requestshield/references/browser-seamless.md +213 -0
- package/skills/requestshield/references/cli.md +314 -0
- package/skills/requestshield/references/integration-planning.md +357 -0
- package/skills/requestshield/references/troubleshooting.md +131 -0
- package/src/agent-detector.mjs +102 -0
- package/src/api-client.mjs +155 -0
- package/src/args.mjs +147 -0
- package/src/browser-opener.mjs +32 -0
- package/src/cli.mjs +115 -0
- package/src/command-completion.mjs +10 -0
- package/src/command-registry.mjs +151 -0
- package/src/commands/agent-setup.mjs +195 -0
- package/src/commands/agent-status.mjs +65 -0
- package/src/commands/application-mutations.mjs +37 -0
- package/src/commands/application-response.mjs +55 -0
- package/src/commands/apps-get.mjs +28 -0
- package/src/commands/apps-list.mjs +93 -0
- package/src/commands/auth-status.mjs +42 -0
- package/src/commands/billing.mjs +106 -0
- package/src/commands/contract.mjs +59 -0
- package/src/commands/keys-create.mjs +22 -0
- package/src/commands/mutation-support.mjs +135 -0
- package/src/commands/output.mjs +26 -0
- package/src/commands/secret-commands.mjs +66 -0
- package/src/commands/secret-output.mjs +52 -0
- package/src/commands/service-status.mjs +34 -0
- package/src/commands/signin.mjs +95 -0
- package/src/commands/signout.mjs +14 -0
- package/src/commands/update-check.mjs +205 -0
- package/src/commands/usage.mjs +72 -0
- package/src/config.mjs +200 -0
- package/src/entrypoint.mjs +44 -0
- package/src/errors.mjs +16 -0
- package/src/integration-contract-client.mjs +85 -0
- package/src/integration-contract.mjs +114 -0
- package/src/main.mjs +6 -0
- package/src/oauth-client.mjs +153 -0
- package/src/oauth-loopback.mjs +120 -0
- package/src/package-profile.mjs +24 -0
- package/src/private-path.mjs +33 -0
- package/src/secret-output.mjs +121 -0
- package/src/service-status-client.mjs +87 -0
- package/src/session-files.mjs +192 -0
- package/src/session-store.mjs +204 -0
- package/src/terminal.mjs +143 -0
- package/src/usage-time.mjs +26 -0
|
@@ -0,0 +1,192 @@
|
|
|
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 { setTimeout as delay } from "node:timers/promises";
|
|
7
|
+
import { CliError } from "./errors.mjs";
|
|
8
|
+
import { protectPrivatePath } from "./private-path.mjs";
|
|
9
|
+
|
|
10
|
+
const MAX_SESSION_BYTES = 65_536;
|
|
11
|
+
|
|
12
|
+
/** Filesystem errors never expose exception text that might contain credentials. */
|
|
13
|
+
export class SessionFiles {
|
|
14
|
+
/** @param {string} file @param {{platform?: NodeJS.Platform, fs?: Partial<typeof nativeFs>, protectPath?: (file: string, directory: boolean) => Promise<void>, syncDirectory?: () => Promise<void>, lockWaitMs?: number}} [options] */
|
|
15
|
+
constructor(file, options = {}) {
|
|
16
|
+
this.file = file;
|
|
17
|
+
this.directory = path.dirname(file);
|
|
18
|
+
this.lock = `${file}.lock`;
|
|
19
|
+
this.fs = { ...nativeFs, ...options.fs };
|
|
20
|
+
this.platform = options.platform ?? process.platform;
|
|
21
|
+
this.protectPath = options.protectPath ?? ((file, directory) => this.#protect(file, directory));
|
|
22
|
+
this.syncDirectory = options.syncDirectory ?? (() => this.#syncDirectory());
|
|
23
|
+
this.lockWaitMs = options.lockWaitMs ?? 10_000;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** @template T @param {() => Promise<T>} action @returns {Promise<T>} */
|
|
27
|
+
async withLock(action) {
|
|
28
|
+
await this.#prepareDirectory();
|
|
29
|
+
const deadline = performance.now() + this.lockWaitMs;
|
|
30
|
+
let handle;
|
|
31
|
+
while (!handle) {
|
|
32
|
+
try { handle = await this.fs.open(this.lock, "wx", 0o600); }
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (errorCode(error) !== "EEXIST") throw storageFailure();
|
|
35
|
+
if (performance.now() >= deadline) {
|
|
36
|
+
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.", {
|
|
37
|
+
code: "SESSION_LOCKED", exitCode: 7,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
await delay(40);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
// Never steal an old-looking lock: a suspended process may own it while
|
|
45
|
+
// its provider request rotates a token. No credentials enter this file.
|
|
46
|
+
await handle.writeFile(JSON.stringify({ pid: process.pid, id: randomUUID() }));
|
|
47
|
+
return await action();
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (error instanceof CliError) throw error;
|
|
50
|
+
throw storageFailure();
|
|
51
|
+
} finally {
|
|
52
|
+
await handle.close().catch(() => undefined);
|
|
53
|
+
await this.fs.rm(this.lock, { force: true }).catch(() => undefined);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async read() {
|
|
58
|
+
let handle;
|
|
59
|
+
try {
|
|
60
|
+
const directory = await this.fs.lstat(this.directory);
|
|
61
|
+
if (!directory.isDirectory() || directory.isSymbolicLink()) throw storageFailure();
|
|
62
|
+
const stat = await this.fs.lstat(this.file);
|
|
63
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw storageFailure();
|
|
64
|
+
if (this.platform !== "win32" && process.platform !== "win32"
|
|
65
|
+
&& ((stat.mode & 0o077) !== 0 || (directory.mode & 0o077) !== 0
|
|
66
|
+
|| stat.uid !== process.getuid?.() || directory.uid !== process.getuid?.())) throw permissionFailure();
|
|
67
|
+
handle = await this.fs.open(this.file, "r");
|
|
68
|
+
const buffer = Buffer.alloc(MAX_SESSION_BYTES + 1);
|
|
69
|
+
let total = 0;
|
|
70
|
+
while (total < buffer.length) {
|
|
71
|
+
const { bytesRead } = await handle.read(buffer, total, buffer.length - total, null);
|
|
72
|
+
if (bytesRead === 0) break;
|
|
73
|
+
total += bytesRead;
|
|
74
|
+
}
|
|
75
|
+
if (total > MAX_SESSION_BYTES) throw invalidFile();
|
|
76
|
+
try { return JSON.parse(buffer.subarray(0, total).toString("utf8")); }
|
|
77
|
+
catch { throw invalidFile(); }
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error instanceof CliError) throw error;
|
|
80
|
+
if (errorCode(error) === "ENOENT") {
|
|
81
|
+
throw new CliError("Not signed in. Run `requestshield login` first.", { code: "NOT_SIGNED_IN", exitCode: 3 });
|
|
82
|
+
}
|
|
83
|
+
throw storageFailure();
|
|
84
|
+
} finally { await handle?.close().catch(() => undefined); }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Remove only the selected regular credential file under the refresh lock.
|
|
88
|
+
* No JSON parsing is needed, so malformed or mismatched sessions can be cleared.
|
|
89
|
+
*/
|
|
90
|
+
async remove() {
|
|
91
|
+
try {
|
|
92
|
+
const directory = await this.fs.lstat(this.directory);
|
|
93
|
+
if (!directory.isDirectory() || directory.isSymbolicLink()) throw storageFailure();
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (errorCode(error) === "ENOENT") return false;
|
|
96
|
+
if (error instanceof CliError) throw error;
|
|
97
|
+
throw storageFailure();
|
|
98
|
+
}
|
|
99
|
+
return this.withLock(async () => {
|
|
100
|
+
let removed = false;
|
|
101
|
+
try {
|
|
102
|
+
const existing = await this.fs.lstat(this.file);
|
|
103
|
+
if (!existing.isFile() || existing.isSymbolicLink()) throw storageFailure();
|
|
104
|
+
await this.fs.unlink(this.file);
|
|
105
|
+
removed = true;
|
|
106
|
+
await this.syncDirectory();
|
|
107
|
+
return true;
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (removed) {
|
|
110
|
+
throw new CliError("The local session was removed, but its durability could not be confirmed. Run `requestshield logout` again to confirm removal.", {
|
|
111
|
+
code: "SESSION_COMMIT_UNCERTAIN", exitCode: 7,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (errorCode(error) === "ENOENT") return false;
|
|
115
|
+
if (error instanceof CliError) throw error;
|
|
116
|
+
throw storageFailure();
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Caller holds the session lock. @param {unknown} value */
|
|
122
|
+
async write(value) {
|
|
123
|
+
const temp = `${this.file}.${randomUUID()}.tmp`;
|
|
124
|
+
let handle;
|
|
125
|
+
let replaced = false;
|
|
126
|
+
try {
|
|
127
|
+
try {
|
|
128
|
+
const existing = await this.fs.lstat(this.file);
|
|
129
|
+
if (!existing.isFile() || existing.isSymbolicLink()) throw storageFailure();
|
|
130
|
+
} catch (error) { if (errorCode(error) !== "ENOENT") throw error; }
|
|
131
|
+
handle = await this.fs.open(temp, "wx", 0o600);
|
|
132
|
+
await this.protectPath(temp, false);
|
|
133
|
+
const serialized = `${JSON.stringify(value)}\n`;
|
|
134
|
+
if (Buffer.byteLength(serialized) > MAX_SESSION_BYTES) throw invalidFile();
|
|
135
|
+
await handle.writeFile(serialized, "utf8");
|
|
136
|
+
await handle.sync();
|
|
137
|
+
await handle.close();
|
|
138
|
+
handle = undefined;
|
|
139
|
+
await this.fs.rename(temp, this.file);
|
|
140
|
+
replaced = true;
|
|
141
|
+
await this.syncDirectory();
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (replaced) {
|
|
144
|
+
throw new CliError("The session was replaced, but its durability could not be confirmed. Run `requestshield login` again if the next command cannot load it.", {
|
|
145
|
+
code: "SESSION_COMMIT_UNCERTAIN", exitCode: 7,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
if (error instanceof CliError) throw error;
|
|
149
|
+
throw storageFailure();
|
|
150
|
+
} finally {
|
|
151
|
+
await handle?.close().catch(() => undefined);
|
|
152
|
+
await this.fs.rm(temp, { force: true }).catch(() => undefined);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async #prepareDirectory() {
|
|
157
|
+
try {
|
|
158
|
+
await this.fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
159
|
+
const stat = await this.fs.lstat(this.directory);
|
|
160
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw permissionFailure();
|
|
161
|
+
await this.protectPath(this.directory, true);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (error instanceof CliError) throw error;
|
|
164
|
+
throw permissionFailure();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async #syncDirectory() {
|
|
169
|
+
if (this.platform === "win32" || process.platform === "win32") return;
|
|
170
|
+
const directory = await this.fs.open(this.directory, "r");
|
|
171
|
+
try { await directory.sync(); } finally { await directory.close(); }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** @param {string} file @param {boolean} directory */
|
|
175
|
+
async #protect(file, directory) {
|
|
176
|
+
try {
|
|
177
|
+
await protectPrivatePath(file, directory, {platform: this.platform, fs: this.fs});
|
|
178
|
+
} catch { throw permissionFailure(); }
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** @param {unknown} error */
|
|
183
|
+
function errorCode(error) { return error && typeof error === "object" ? Reflect.get(error, "code") : undefined; }
|
|
184
|
+
function storageFailure() {
|
|
185
|
+
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 });
|
|
186
|
+
}
|
|
187
|
+
function permissionFailure() {
|
|
188
|
+
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 });
|
|
189
|
+
}
|
|
190
|
+
function invalidFile() {
|
|
191
|
+
return new CliError("The saved RequestShield session is invalid. Run `requestshield login` again.", { code: "INVALID_SESSION", exitCode: 3 });
|
|
192
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { CliError } from "./errors.mjs";
|
|
6
|
+
import { getApiUrl, getOAuthConfig, validateBearerToken } from "./config.mjs";
|
|
7
|
+
import { OAuthClient } from "./oauth-client.mjs";
|
|
8
|
+
import { SessionFiles } from "./session-files.mjs";
|
|
9
|
+
|
|
10
|
+
/** @typedef {{accessToken: string, refreshToken: string, expiresAt: number, scopes: string[]}} Credentials */
|
|
11
|
+
/** @typedef {{issuer: string, clientId: string, apiUrl: string}} OAuthConfig */
|
|
12
|
+
/** @typedef {Credentials & {version: 1, issuer: string, clientId: string, apiUrl: string, refreshState: "ready" | "in_progress"}} SavedSession */
|
|
13
|
+
/** @typedef {'signed_out' | 'valid' | 'expired' | 'refresh_uncertain' | 'config_mismatch' | 'invalid' | 'configuration_error'} SessionState */
|
|
14
|
+
/** @typedef {{profile: import('./config.mjs').Profile, apiUrl: string | null, issuer: string | null, clientId: string | null, state: SessionState, localOnly: true, expiresAt?: number, scopes?: string[]}} SessionStatus */
|
|
15
|
+
|
|
16
|
+
const REFRESH_WINDOW_MS = 60_000;
|
|
17
|
+
|
|
18
|
+
export class SessionStore {
|
|
19
|
+
/**
|
|
20
|
+
* @param {{env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, homeDir?: string, profile?: import("./config.mjs").Profile,
|
|
21
|
+
* config?: OAuthConfig, oauth?: {refresh(token: string): Promise<Credentials>},
|
|
22
|
+
* now?: () => number, fs?: Partial<typeof import("node:fs/promises")>,
|
|
23
|
+
* protectPath?: (file: string, directory: boolean) => Promise<void>, lockWaitMs?: number}} [options]
|
|
24
|
+
*/
|
|
25
|
+
constructor(options = {}) {
|
|
26
|
+
this.platform = options.platform ?? process.platform;
|
|
27
|
+
this.homeDir = options.homeDir ?? os.homedir();
|
|
28
|
+
this.profile = options.profile ?? "prod";
|
|
29
|
+
// Environment variables select OS storage locations only, never credentials
|
|
30
|
+
// or the API/provider configuration bound to those credentials.
|
|
31
|
+
this.file = sessionPath(options.env ?? process.env, this.platform, this.homeDir, this.profile);
|
|
32
|
+
this.config = options.config;
|
|
33
|
+
this.oauth = options.oauth;
|
|
34
|
+
this.now = options.now ?? Date.now;
|
|
35
|
+
this.files = new SessionFiles(this.file, {
|
|
36
|
+
platform: this.platform, fs: options.fs,
|
|
37
|
+
protectPath: options.protectPath, lockWaitMs: options.lockWaitMs,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A successful interactive sign-in replaces even an uncertain refresh record. @param {Credentials} credentials */
|
|
42
|
+
async save(credentials) {
|
|
43
|
+
const session = this.#record(credentials);
|
|
44
|
+
await this.files.withLock(async () => { await this.files.write(session); });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Inspect the local snapshot only: no refresh, network request, lock or write.
|
|
48
|
+
* @returns {Promise<SessionStatus>}
|
|
49
|
+
*/
|
|
50
|
+
async status() {
|
|
51
|
+
let config;
|
|
52
|
+
/** @type {SessionStatus} */
|
|
53
|
+
const result = { profile: this.profile, apiUrl: null, issuer: null, clientId: null, state: "signed_out", localOnly: true };
|
|
54
|
+
try {
|
|
55
|
+
config = this.#config();
|
|
56
|
+
result.apiUrl = config.apiUrl;
|
|
57
|
+
result.issuer = config.issuer;
|
|
58
|
+
result.clientId = config.clientId;
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (!(error instanceof CliError) || error.code !== "OAUTH_CONFIG_INVALID") throw error;
|
|
61
|
+
try { result.apiUrl = getApiUrl(this.profile); } catch { /* Partial configuration may lack an API URL too. */ }
|
|
62
|
+
}
|
|
63
|
+
let session;
|
|
64
|
+
try { session = checkedSession(await this.files.read()); }
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (!(error instanceof CliError)) throw error;
|
|
67
|
+
if (error.code === "NOT_SIGNED_IN") return result;
|
|
68
|
+
if (error.code === "INVALID_SESSION") return { ...result, state: "invalid" };
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
if (!config) return { ...result, state: "configuration_error" };
|
|
72
|
+
if (!matchesConfig(session, config)) return { ...result, state: "config_mismatch" };
|
|
73
|
+
return {
|
|
74
|
+
...result,
|
|
75
|
+
state: session.refreshState === "in_progress" ? "refresh_uncertain" : session.expiresAt <= this.now() ? "expired" : "valid",
|
|
76
|
+
expiresAt: session.expiresAt,
|
|
77
|
+
scopes: [...session.scopes],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Remove only this profile's local credential; never revoke provider tokens. */
|
|
82
|
+
async signout() {
|
|
83
|
+
return { profile: this.profile, removed: await this.files.remove() };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async loadToken() {
|
|
87
|
+
const initial = await this.#read();
|
|
88
|
+
if (initial.refreshState === "ready" && initial.expiresAt > this.now() + REFRESH_WINDOW_MS) return initial.accessToken;
|
|
89
|
+
|
|
90
|
+
return this.files.withLock(async () => {
|
|
91
|
+
// Another CLI process may have refreshed while this one waited.
|
|
92
|
+
const session = await this.#read();
|
|
93
|
+
this.#assertReady(session);
|
|
94
|
+
if (session.expiresAt > this.now() + REFRESH_WINDOW_MS) return session.accessToken;
|
|
95
|
+
|
|
96
|
+
// Persist before dispatch: a crash/lost response must not resend a
|
|
97
|
+
// potentially rotated refresh token. Preserve the original credential.
|
|
98
|
+
await this.files.write({ ...session, refreshState: "in_progress" });
|
|
99
|
+
let refreshed;
|
|
100
|
+
try {
|
|
101
|
+
const oauth = this.oauth ?? new OAuthClient({ config: this.#config() });
|
|
102
|
+
refreshed = await oauth.refresh(session.refreshToken);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
const code = error instanceof CliError ? error.code : undefined;
|
|
105
|
+
if (code === "OAUTH_REJECTED" || code === "NETWORK_ERROR"
|
|
106
|
+
|| code === "OAUTH_INVALID_RESPONSE" || code === "OAUTH_CONFIG_INVALID") {
|
|
107
|
+
// OAuthClient guarantees these are definitive rejections or failures
|
|
108
|
+
// during discovery, before refresh dispatch.
|
|
109
|
+
await this.files.write(session);
|
|
110
|
+
throw new CliError("Could not refresh the RequestShield session. Retry the command.", {
|
|
111
|
+
code: "SESSION_REFRESH_FAILED", exitCode: 7,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
throw signinRequired();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// A validation/write failure retains the marker and original credentials.
|
|
118
|
+
const replacement = this.#record(refreshed);
|
|
119
|
+
await this.files.write(replacement);
|
|
120
|
+
return replacement.accessToken;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
#config() { return this.config ?? getOAuthConfig(this.profile); }
|
|
125
|
+
|
|
126
|
+
/** @param {Credentials} value @returns {SavedSession} */
|
|
127
|
+
#record(value) {
|
|
128
|
+
const credentials = checkedCredentials(value);
|
|
129
|
+
if (credentials.expiresAt <= this.now()) throw invalidSession();
|
|
130
|
+
return { version: 1, ...this.#config(), ...credentials, refreshState: "ready" };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** @returns {Promise<SavedSession>} */
|
|
134
|
+
async #read() {
|
|
135
|
+
const record = checkedSession(await this.files.read());
|
|
136
|
+
const config = this.#config();
|
|
137
|
+
if (!matchesConfig(record, config)) throw invalidSession();
|
|
138
|
+
return record;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** @param {SavedSession} session */
|
|
142
|
+
#assertReady(session) {
|
|
143
|
+
if (session.refreshState !== "ready") throw signinRequired();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** @param {SavedSession} session @param {OAuthConfig} config */
|
|
148
|
+
function matchesConfig(session, config) {
|
|
149
|
+
return session.issuer === config.issuer && session.clientId === config.clientId && session.apiUrl === config.apiUrl;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** @param {unknown} value @returns {SavedSession} */
|
|
153
|
+
function checkedSession(value) {
|
|
154
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw invalidSession();
|
|
155
|
+
const record = /** @type {Record<string, unknown>} */ (value);
|
|
156
|
+
if (record.version !== 1 || typeof record.issuer !== "string" || typeof record.clientId !== "string" || typeof record.apiUrl !== "string"
|
|
157
|
+
|| (record.refreshState !== "ready" && record.refreshState !== "in_progress")) throw invalidSession();
|
|
158
|
+
const allowed = new Set(["version", "issuer", "clientId", "apiUrl", "refreshState",
|
|
159
|
+
"accessToken", "refreshToken", "expiresAt", "scopes"]);
|
|
160
|
+
if (Object.keys(record).some(key => !allowed.has(key))) throw invalidSession();
|
|
161
|
+
return { version: 1, issuer: record.issuer, clientId: record.clientId, apiUrl: record.apiUrl,
|
|
162
|
+
...checkedCredentials(record), refreshState: record.refreshState };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** @param {unknown} value @returns {Credentials} */
|
|
166
|
+
function checkedCredentials(value) {
|
|
167
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw invalidSession();
|
|
168
|
+
const record = /** @type {Record<string, unknown>} */ (value);
|
|
169
|
+
try { validateBearerToken(record.accessToken); } catch { throw invalidSession(); }
|
|
170
|
+
if (typeof record.refreshToken !== "string" || record.refreshToken.length > 16_384
|
|
171
|
+
|| !/^[\x21-\x7e]+$/.test(record.refreshToken)
|
|
172
|
+
|| typeof record.expiresAt !== "number" || !Number.isSafeInteger(record.expiresAt) || record.expiresAt <= 0
|
|
173
|
+
|| !Array.isArray(record.scopes) || record.scopes.length === 0 || record.scopes.length > 64
|
|
174
|
+
|| record.scopes.some(scope => typeof scope !== "string" || !/^[\x21\x23-\x5b\x5d-\x7e]{1,256}$/.test(scope))
|
|
175
|
+
|| new Set(record.scopes).size !== record.scopes.length) throw invalidSession();
|
|
176
|
+
return {
|
|
177
|
+
accessToken: /** @type {string} */ (record.accessToken), refreshToken: record.refreshToken,
|
|
178
|
+
expiresAt: record.expiresAt, scopes: [...record.scopes],
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function invalidSession() {
|
|
183
|
+
return new CliError("The saved RequestShield session is no longer usable. Run `requestshield login` again.", {
|
|
184
|
+
code: "INVALID_SESSION", exitCode: 3,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
function signinRequired() {
|
|
188
|
+
return new CliError("The RequestShield session could not be safely refreshed. Run `requestshield login` again.", {
|
|
189
|
+
code: "SESSION_SIGNIN_REQUIRED", exitCode: 3,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** @param {NodeJS.ProcessEnv} env @param {NodeJS.Platform} platform @param {string} homeDir @param {import("./config.mjs").Profile} [profile] */
|
|
194
|
+
export function sessionPath(env, platform, homeDir, profile = "prod") {
|
|
195
|
+
if (!["prod", "qat", "stg"].includes(profile)) {
|
|
196
|
+
throw new CliError("This RequestShield installation is invalid. Reinstall the CLI or contact support.", { code: "INVALID_PROFILE", exitCode: 2 });
|
|
197
|
+
}
|
|
198
|
+
const suffix = profile === "prod" ? ["session.json"] : [profile, "session.json"];
|
|
199
|
+
if (platform === "win32") {
|
|
200
|
+
return path.join(env.LOCALAPPDATA || path.join(homeDir, "AppData", "Local"), "IntelliFend", "RequestShield", ...suffix);
|
|
201
|
+
}
|
|
202
|
+
const base = env.XDG_STATE_HOME || path.join(homeDir, ".local", "state");
|
|
203
|
+
return path.join(base, "intellifend", "requestshield", ...suffix);
|
|
204
|
+
}
|
package/src/terminal.mjs
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { stripVTControlCharacters } from "node:util";
|
|
3
|
+
|
|
4
|
+
/** @typedef {{isTTY?: boolean, columns?: number, term?: string, noColor?: boolean}} TerminalCapabilities */
|
|
5
|
+
/** @typedef {{stdout?: TerminalCapabilities, stderr?: TerminalCapabilities}} TerminalContext */
|
|
6
|
+
/** @typedef {{terminal?: TerminalContext}} TerminalOutput */
|
|
7
|
+
|
|
8
|
+
const segmenter = new Intl.Segmenter(undefined, {granularity: "grapheme"});
|
|
9
|
+
const colors = {bold: 1, cyan: 36, muted: 90, success: 32, warning: 33, error: 31, info: 36};
|
|
10
|
+
|
|
11
|
+
/** Measure terminal cells, not UTF-16 units; styles and combining marks occupy no cells.
|
|
12
|
+
* @param {string} value */
|
|
13
|
+
export function displayWidth(value) {
|
|
14
|
+
let width = 0;
|
|
15
|
+
for (const {segment} of segmenter.segment(stripVTControlCharacters(value))) {
|
|
16
|
+
if (/^[\p{Mark}\p{Cf}]+$/u.test(segment)) continue;
|
|
17
|
+
const point = segment.codePointAt(0) ?? 0;
|
|
18
|
+
const emoji = /\p{Emoji_Presentation}|\uFE0F|\u20E3/u.test(segment);
|
|
19
|
+
const wide = point >= 0x1100 && (point <= 0x115f || point === 0x2329 || point === 0x232a
|
|
20
|
+
|| point >= 0x2e80 && point <= 0xa4cf && point !== 0x303f
|
|
21
|
+
|| point >= 0xac00 && point <= 0xd7a3 || point >= 0xf900 && point <= 0xfaff
|
|
22
|
+
|| point >= 0xfe10 && point <= 0xfe19 || point >= 0xfe30 && point <= 0xfe6f
|
|
23
|
+
|| point >= 0xff00 && point <= 0xff60 || point >= 0xffe0 && point <= 0xffe6
|
|
24
|
+
|| point >= 0x20000 && point <= 0x3fffd);
|
|
25
|
+
width += emoji || wide ? 2 : 1;
|
|
26
|
+
}
|
|
27
|
+
return width;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Wrap prose at word boundaries. Never split copyable keys, URLs or secret values.
|
|
31
|
+
* @param {string} value @param {number} columns @returns {string[]} */
|
|
32
|
+
function wrap(value, columns) {
|
|
33
|
+
return value.split("\n").flatMap(line => {
|
|
34
|
+
const words = line.trim().match(/`[^`\n]+`|\S+/gu) ?? [];
|
|
35
|
+
const lines = [];
|
|
36
|
+
let current = "";
|
|
37
|
+
for (const word of words) {
|
|
38
|
+
if (current && displayWidth(`${current} ${word}`) > columns) {
|
|
39
|
+
lines.push(current);
|
|
40
|
+
current = word;
|
|
41
|
+
} else current += `${current ? " " : ""}${word}`;
|
|
42
|
+
}
|
|
43
|
+
lines.push(current);
|
|
44
|
+
return lines;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Pure presentation: callers explicitly provide capabilities; no product config is read.
|
|
49
|
+
* @param {TerminalCapabilities} [capabilities] */
|
|
50
|
+
export function createRenderer(capabilities = {}) {
|
|
51
|
+
const columns = Number.isFinite(capabilities.columns) && Number(capabilities.columns) > 0
|
|
52
|
+
? Math.max(20, Math.floor(Number(capabilities.columns))) : 80;
|
|
53
|
+
const decorated = capabilities.isTTY === true && capabilities.term !== "dumb" && !capabilities.noColor;
|
|
54
|
+
/** @param {keyof typeof colors} color @param {string} value */
|
|
55
|
+
const paint = (color, value) => decorated ? `\x1b[${colors[color]}m${value}\x1b[0m` : value;
|
|
56
|
+
/** @param {string} value @param {number} width */
|
|
57
|
+
const pad = (value, width) => value + " ".repeat(Math.max(0, width - displayWidth(value)));
|
|
58
|
+
/** @param {string} value */
|
|
59
|
+
const status = value => {
|
|
60
|
+
if (["enabled", "installed", "valid", "locally valid", "up_to_date"].includes(value)) return paint("success", value);
|
|
61
|
+
if (["pending", "accepted", "expired", "missing", "attention_required", "refresh_uncertain"].includes(value)) return paint("warning", value);
|
|
62
|
+
if (["revoked", "invalid", "config_mismatch", "configuration_error"].includes(value)) return paint("error", value);
|
|
63
|
+
return value;
|
|
64
|
+
};
|
|
65
|
+
/** @param {Array<[string, string]>} entries */
|
|
66
|
+
const fields = entries => {
|
|
67
|
+
const labelWidth = Math.max(0, ...entries.map(([label]) => displayWidth(label) + 1));
|
|
68
|
+
return entries.map(([label, value]) => {
|
|
69
|
+
const rendered = /^(status|session \(local\))$/i.test(label) ? status(value) : value;
|
|
70
|
+
// Values remain byte-for-byte intact, including spaces in names and paths.
|
|
71
|
+
if (labelWidth + displayWidth(value) + 4 > columns) return ` ${paint("muted", `${label}:`)}\n ${rendered}`;
|
|
72
|
+
return ` ${paint("muted", pad(`${label}:`, labelWidth))} ${rendered}`;
|
|
73
|
+
}).join("\n");
|
|
74
|
+
};
|
|
75
|
+
return {
|
|
76
|
+
columns,
|
|
77
|
+
/** @param {string} text @param {string} [icon] */
|
|
78
|
+
heading: (text, icon) => paint("bold", `${decorated && icon ? `${icon} ` : ""}${text}`),
|
|
79
|
+
/** @param {string} text */
|
|
80
|
+
section: text => paint("bold", text.toUpperCase()),
|
|
81
|
+
/** @param {string} text */
|
|
82
|
+
command: text => paint("cyan", text),
|
|
83
|
+
/** @param {string} text */
|
|
84
|
+
muted: text => paint("muted", text),
|
|
85
|
+
/** @param {string} text */
|
|
86
|
+
paragraph: text => wrap(text, columns).join("\n"),
|
|
87
|
+
/** @param {Array<{label: string, description: string, icon?: string}>} entries
|
|
88
|
+
* @param {{labelWidth?: number}} [layout] */
|
|
89
|
+
rows: (entries, layout = {}) => {
|
|
90
|
+
const labelWidth = Math.max(layout.labelWidth ?? 0, ...entries.map(entry => displayWidth(entry.label)));
|
|
91
|
+
const descriptionColumn = labelWidth + 4;
|
|
92
|
+
const stacked = columns - descriptionColumn < 24;
|
|
93
|
+
return entries.map(({label, description, icon}) => {
|
|
94
|
+
const prefix = decorated && icon ? `${icon} ` : "";
|
|
95
|
+
const indent = stacked ? 4 : descriptionColumn;
|
|
96
|
+
const lines = wrap(`${prefix}${description}`, columns - indent);
|
|
97
|
+
if (stacked) return ` ${paint("cyan", label)}\n${lines.map(line => ` ${line}`).join("\n")}`;
|
|
98
|
+
return ` ${paint("cyan", pad(label, labelWidth))} ${lines[0]}`
|
|
99
|
+
+ lines.slice(1).map(line => `\n${" ".repeat(indent)}${line}`).join("");
|
|
100
|
+
}).join("\n");
|
|
101
|
+
},
|
|
102
|
+
fields,
|
|
103
|
+
status,
|
|
104
|
+
/** @param {'success'|'warning'|'error'|'info'} kind @param {string} text */
|
|
105
|
+
message: (kind, text) => {
|
|
106
|
+
const labels = {success: "Success:", warning: "Warning:", error: "Error:", info: "Info:"};
|
|
107
|
+
const symbols = {success: "✓", warning: "⚠", error: "✖", info: "ℹ"};
|
|
108
|
+
return paint(kind, wrap(`${decorated ? symbols[kind] : labels[kind]} ${text}`, columns).join("\n"));
|
|
109
|
+
},
|
|
110
|
+
/** @param {string[]} headers @param {string[][]} rows */
|
|
111
|
+
table: (headers, rows) => {
|
|
112
|
+
const widths = headers.map((header, index) => Math.max(displayWidth(header), ...rows.map(row => displayWidth(row[index] ?? ""))));
|
|
113
|
+
if (widths.reduce((sum, width) => sum + width, 2 + 2 * (headers.length - 1)) > columns) {
|
|
114
|
+
return rows.map(row => fields(headers.map((header, index) => [header, row[index] ?? ""]))).join("\n\n");
|
|
115
|
+
}
|
|
116
|
+
const header = ` ${headers.map((value, index) => paint("bold", index === headers.length - 1 ? value : pad(value, widths[index]))).join(" ")}`;
|
|
117
|
+
const body = rows.map(row => ` ${headers.map((name, index) => {
|
|
118
|
+
const value = row[index] ?? "";
|
|
119
|
+
const formatted = name.toLowerCase() === "status" ? status(value) : value;
|
|
120
|
+
return index === headers.length - 1 ? formatted : pad(formatted, widths[index]);
|
|
121
|
+
}).join(" ")}`);
|
|
122
|
+
return [header, ...body].join("\n");
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** @param {TerminalOutput} deps @param {'stdout'|'stderr'} [stream] */
|
|
128
|
+
export function rendererFor(deps, stream = "stdout") {
|
|
129
|
+
return createRenderer(deps.terminal?.[stream]);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Only terminal presentation uses TERM/NO_COLOR; these never select product settings.
|
|
133
|
+
* Injected output callbacks default to plain text unless capabilities are supplied.
|
|
134
|
+
* @param {TerminalOutput & {log?: (message: string) => void, warn?: (message: string) => void, env?: NodeJS.ProcessEnv}} deps
|
|
135
|
+
* @returns {TerminalContext} */
|
|
136
|
+
export function resolveTerminal(deps) {
|
|
137
|
+
if (deps.terminal) return deps.terminal;
|
|
138
|
+
const env = deps.env ?? process.env;
|
|
139
|
+
/** @param {{isTTY?: boolean, columns?: number}} stream @param {boolean} injected */
|
|
140
|
+
const detect = (stream, injected) => ({isTTY: !injected && stream.isTTY === true, columns: stream.columns,
|
|
141
|
+
term: env.TERM, noColor: env.NO_COLOR !== undefined});
|
|
142
|
+
return {stdout: detect(process.stdout, !!deps.log), stderr: detect(process.stderr, !!deps.warn)};
|
|
143
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { CliError } from "./errors.mjs";
|
|
3
|
+
|
|
4
|
+
/** Strict UTC timestamps, with seconds and optional millisecond precision.
|
|
5
|
+
* Date.parse alone accepts calendar rollovers such as February 30.
|
|
6
|
+
* @param {unknown} value @returns {number | undefined}
|
|
7
|
+
*/
|
|
8
|
+
export function utcTimestamp(value) {
|
|
9
|
+
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(value)) return undefined;
|
|
10
|
+
const time = Date.parse(value);
|
|
11
|
+
if (!Number.isFinite(time)) return undefined;
|
|
12
|
+
const normalized = value.replace(/(?:\.(\d{1,3}))?Z$/, (_match, fraction = "") => `.${fraction.padEnd(3, "0")}Z`);
|
|
13
|
+
return new Date(time).toISOString() === normalized ? time : undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Validate syntax and ordering only; the server owns the current retention window.
|
|
17
|
+
* @param {{from?: string, to?: string}} options
|
|
18
|
+
*/
|
|
19
|
+
export function validateUsageRange({from, to}) {
|
|
20
|
+
if (from === undefined && to === undefined) return {};
|
|
21
|
+
const start = utcTimestamp(from), end = utcTimestamp(to);
|
|
22
|
+
if (start === undefined || end === undefined || start >= end) {
|
|
23
|
+
throw new CliError("Provide both --from and --to as valid UTC timestamps ending in Z, with --from before --to (at most three fractional digits).", {code: "INVALID_ARGUMENT", exitCode: 2});
|
|
24
|
+
}
|
|
25
|
+
return {from: /** @type {string} */ (from), to: /** @type {string} */ (to)};
|
|
26
|
+
}
|