requestshield 0.1.4 → 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.
- package/README.md +421 -85
- package/config/.env.prod +7 -0
- package/package.json +21 -12
- package/skills/requestshield/SKILL.md +299 -307
- package/skills/requestshield/assets/AGENTS.codex.md +62 -62
- package/skills/requestshield/references/backend-java-core.md +128 -128
- package/skills/requestshield/references/backend-spring-boot.md +145 -145
- package/skills/requestshield/references/browser-manual.md +210 -210
- package/skills/requestshield/references/browser-seamless.md +156 -164
- package/skills/requestshield/references/cli.md +107 -182
- package/skills/requestshield/references/integration-planning.md +362 -389
- package/skills/requestshield/references/troubleshooting.md +114 -118
- package/src/agent-detector.mjs +102 -74
- package/src/api-client.mjs +115 -79
- package/src/args.mjs +140 -80
- package/src/browser-opener.mjs +32 -0
- package/src/cli.mjs +277 -51
- package/src/commands/agent-setup.mjs +182 -185
- package/src/commands/application-mutations.mjs +33 -0
- package/src/commands/application-response.mjs +55 -0
- package/src/commands/apps-get.mjs +20 -0
- package/src/commands/apps-list.mjs +94 -0
- package/src/commands/auth-status.mjs +37 -0
- package/src/commands/keys-create.mjs +7 -38
- package/src/commands/mutation-support.mjs +110 -0
- package/src/commands/secret-commands.mjs +45 -0
- package/src/commands/signin.mjs +70 -57
- package/src/commands/signout.mjs +9 -0
- package/src/commands/update-check.mjs +12 -4
- package/src/config.mjs +150 -0
- package/src/entrypoint.mjs +24 -0
- package/src/errors.mjs +3 -1
- package/src/main.mjs +5 -24
- package/src/oauth-client.mjs +153 -0
- package/src/oauth-loopback.mjs +120 -0
- package/src/session-files.mjs +213 -0
- package/src/session-store.mjs +177 -64
package/src/session-store.mjs
CHANGED
|
@@ -1,91 +1,204 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
4
3
|
import os from "node:os";
|
|
5
4
|
import path from "node:path";
|
|
6
|
-
import { randomUUID } from "node:crypto";
|
|
7
|
-
import { spawnSync } from "node:child_process";
|
|
8
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;
|
|
9
17
|
|
|
10
18
|
export class SessionStore {
|
|
11
|
-
/**
|
|
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
|
+
*/
|
|
12
25
|
constructor(options = {}) {
|
|
13
|
-
this.env = options.env ?? process.env;
|
|
14
26
|
this.platform = options.platform ?? process.platform;
|
|
15
27
|
this.homeDir = options.homeDir ?? os.homedir();
|
|
16
|
-
this.
|
|
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
|
+
});
|
|
17
39
|
}
|
|
18
40
|
|
|
19
|
-
/**
|
|
20
|
-
async save(
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
}
|
|
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); });
|
|
34
45
|
}
|
|
35
46
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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 };
|
|
40
54
|
try {
|
|
41
|
-
|
|
55
|
+
config = this.#config();
|
|
56
|
+
result.apiUrl = config.apiUrl;
|
|
57
|
+
result.issuer = config.issuer;
|
|
58
|
+
result.clientId = config.clientId;
|
|
42
59
|
} catch (error) {
|
|
43
|
-
if (
|
|
44
|
-
|
|
45
|
-
code: "NOT_SIGNED_IN",
|
|
46
|
-
exitCode: 3,
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
throw 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. */ }
|
|
50
62
|
}
|
|
51
63
|
let session;
|
|
52
|
-
try {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
});
|
|
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;
|
|
66
70
|
}
|
|
67
|
-
return
|
|
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
|
+
};
|
|
68
79
|
}
|
|
69
|
-
}
|
|
70
80
|
|
|
71
|
-
/**
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return path.join(env.LOCALAPPDATA || path.join(homeDir, "AppData", "Local"), "IntelliFend", "RequestShield", "session.json");
|
|
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() };
|
|
75
84
|
}
|
|
76
|
-
|
|
77
|
-
|
|
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 };
|
|
78
163
|
}
|
|
79
164
|
|
|
80
|
-
/** @param {
|
|
81
|
-
function
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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 invalid or belongs to different OAuth configuration. Run `requestshield signin` again.", {
|
|
184
|
+
code: "INVALID_SESSION", exitCode: 3,
|
|
85
185
|
});
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
186
|
+
}
|
|
187
|
+
function signinRequired() {
|
|
188
|
+
return new CliError("The RequestShield session could not be safely refreshed. Run `requestshield signin` 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("The RequestShield environment profile is invalid.", { code: "INVALID_PROFILE", exitCode: 2 });
|
|
90
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);
|
|
91
204
|
}
|