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.
Files changed (37) hide show
  1. package/README.md +421 -85
  2. package/config/.env.prod +7 -0
  3. package/package.json +21 -12
  4. package/skills/requestshield/SKILL.md +299 -307
  5. package/skills/requestshield/assets/AGENTS.codex.md +62 -62
  6. package/skills/requestshield/references/backend-java-core.md +128 -128
  7. package/skills/requestshield/references/backend-spring-boot.md +145 -145
  8. package/skills/requestshield/references/browser-manual.md +210 -210
  9. package/skills/requestshield/references/browser-seamless.md +156 -164
  10. package/skills/requestshield/references/cli.md +107 -182
  11. package/skills/requestshield/references/integration-planning.md +362 -389
  12. package/skills/requestshield/references/troubleshooting.md +114 -118
  13. package/src/agent-detector.mjs +102 -74
  14. package/src/api-client.mjs +115 -79
  15. package/src/args.mjs +140 -80
  16. package/src/browser-opener.mjs +32 -0
  17. package/src/cli.mjs +277 -51
  18. package/src/commands/agent-setup.mjs +182 -185
  19. package/src/commands/application-mutations.mjs +33 -0
  20. package/src/commands/application-response.mjs +55 -0
  21. package/src/commands/apps-get.mjs +20 -0
  22. package/src/commands/apps-list.mjs +94 -0
  23. package/src/commands/auth-status.mjs +37 -0
  24. package/src/commands/keys-create.mjs +7 -38
  25. package/src/commands/mutation-support.mjs +110 -0
  26. package/src/commands/secret-commands.mjs +45 -0
  27. package/src/commands/signin.mjs +70 -57
  28. package/src/commands/signout.mjs +9 -0
  29. package/src/commands/update-check.mjs +12 -4
  30. package/src/config.mjs +150 -0
  31. package/src/entrypoint.mjs +24 -0
  32. package/src/errors.mjs +3 -1
  33. package/src/main.mjs +5 -24
  34. package/src/oauth-client.mjs +153 -0
  35. package/src/oauth-loopback.mjs +120 -0
  36. package/src/session-files.mjs +213 -0
  37. package/src/session-store.mjs +177 -64
@@ -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
- /** @param {{ env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, homeDir?: string }} [options] */
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.file = sessionPath(this.env, this.platform, this.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
+ });
17
39
  }
18
40
 
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
- }
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
- async loadToken() {
37
- const environmentToken = this.env.REQUESTSHIELD_ACCESS_TOKEN?.trim();
38
- if (environmentToken) return environmentToken;
39
- let contents;
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
- contents = await readFile(this.file, "utf8");
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 (/** @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;
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
- 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
- });
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 token;
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
- /** @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");
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
- const base = env.XDG_STATE_HOME || path.join(homeDir, ".local", "state");
77
- return path.join(base, "intellifend", "requestshield", "session.json");
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 {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,
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
- 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
- });
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
  }