requestshield 0.1.5 → 0.1.7

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 +407 -269
  2. package/config/.env.prod +5 -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 +19 -46
  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
@@ -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
  }
@@ -1,110 +0,0 @@
1
- // @ts-check
2
-
3
- import { CliError } from "../errors.mjs";
4
- import { objectString } from "../api-client.mjs";
5
-
6
- /**
7
- * @param {{ appKey: string }} options
8
- * @param {{ api: { getBilling(accessToken: string, appKey: string): Promise<{body: unknown}> }, sessions: { loadToken(): Promise<string> }, log: (message: string) => void }} deps
9
- */
10
- export async function showBilling(options, deps) {
11
- const accessToken =
12
- await deps.sessions.loadToken();
13
-
14
- const result =
15
- await deps.api.getBilling(accessToken, options.appKey);
16
-
17
- const billing = parseBilling(result.body);
18
-
19
- deps.log(JSON.stringify({ ok: true, data: billing }, null, 2));
20
- }
21
-
22
- /** @param {unknown} body */
23
- function parseBilling(body) {
24
- const ok = body && typeof body === "object" ?
25
- Reflect.get(body, "ok") : undefined;
26
-
27
- const data = objectValue(body, "data");
28
-
29
- const plan = objectValue(data, "plan");
30
-
31
- const usage = objectValue(data, "usage");
32
-
33
- if (ok !== true || !data || !plan || !usage) {
34
- throw invalidResponse("The billing response did not contain ok=true, data.plan, and data.usage");
35
- }
36
-
37
- const tier = objectString(plan, "tier");
38
- const currentCycle = Reflect.get(plan, "current_cycle");
39
- const billing = Reflect.get(plan, "billing");
40
- const nextCharge = objectString(plan, "next_charge");
41
- const monthlyQuota = Reflect.get(usage, "monthly_quota");
42
- const currentUsage = Reflect.get(usage, "current_usage");
43
- const overageCharge = Reflect.get(usage, "overage_charge");
44
-
45
- if (!tier) throw invalidResponse("The billing response did not contain plan.tier");
46
- if (
47
- !Array.isArray(currentCycle)
48
- || currentCycle.length !== 2
49
- || !currentCycle.every((value) =>
50
- typeof value === "string" && isIsoDateTime(value))
51
- || Date.parse(currentCycle[0]) > Date.parse(currentCycle[1])
52
- ) {
53
- throw invalidResponse("The billing response contained an invalid plan.current_cycle");
54
- }
55
- if (!nonNegativeNumber(billing)) {
56
- throw invalidResponse("The billing response contained an invalid plan.billing");
57
- }
58
- if (!nextCharge || !isIsoDateTime(nextCharge)) {
59
- throw invalidResponse("The billing response contained an invalid plan.next_charge");
60
- }
61
- if (!nonNegativeInteger(monthlyQuota) || !nonNegativeInteger(currentUsage)) {
62
- throw invalidResponse("The billing response contained invalid quota or usage values");
63
- }
64
- if (!nonNegativeNumber(overageCharge)) {
65
- throw invalidResponse("The billing response contained an invalid usage.overage_charge");
66
- }
67
-
68
- // Rebuild nested output so unexpected response fields cannot reach the terminal.
69
- return {
70
- plan: {
71
- tier,
72
- current_cycle: currentCycle,
73
- billing,
74
- next_charge: nextCharge,
75
- },
76
- usage: {
77
- monthly_quota: monthlyQuota,
78
- current_usage: currentUsage,
79
- overage_charge: overageCharge,
80
- },
81
- };
82
- }
83
-
84
- /** @param {unknown} value @param {string} property */
85
- function objectValue(value, property) {
86
- if (!value || typeof value !== "object") return undefined;
87
- const found = Reflect.get(value, property);
88
- return found && typeof found === "object" && !Array.isArray(found) ? found : undefined;
89
- }
90
-
91
- /** @param {unknown} value */
92
- function nonNegativeNumber(value) {
93
- return typeof value === "number" && Number.isFinite(value) && value >= 0;
94
- }
95
-
96
- /** @param {unknown} value */
97
- function nonNegativeInteger(value) {
98
- return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
99
- }
100
-
101
- /** @param {string} value */
102
- function isIsoDateTime(value) {
103
- return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})?$/.test(value)
104
- && Number.isFinite(Date.parse(value));
105
- }
106
-
107
- /** @param {string} message */
108
- function invalidResponse(message) {
109
- return new CliError(message, { code: "INVALID_RESPONSE" });
110
- }
@@ -1,81 +0,0 @@
1
- // @ts-check
2
-
3
- import { CliError } from "../errors.mjs";
4
- import { objectString } from "../api-client.mjs";
5
-
6
- /**
7
- * @param {{ appKey: string, from?: string, to?: string, granularity?: string }} options
8
- * @param {{ api: { challengeVolume(accessToken: string, appKey: string, options: {from?: string, to?: string, granularity?: string}): Promise<{body: unknown}> }, sessions: { loadToken(): Promise<string> }, log: (message: string) => void }} deps
9
- */
10
- export async function showChallengeVolume(options, deps) {
11
- const accessToken = await deps.sessions.loadToken();
12
- const filters = {
13
- ...(options.from ? { from: options.from } : {}),
14
- ...(options.to ? { to: options.to } : {}),
15
- ...(options.granularity ? { granularity: options.granularity } : {}),
16
- };
17
- const result = await deps.api.challengeVolume(accessToken, options.appKey, filters);
18
- const volume = parseChallengeVolume(result.body, options);
19
- deps.log(JSON.stringify({ ok: true, data: volume }, null, 2));
20
- }
21
-
22
- /**
23
- * @param {unknown} body
24
- * @param {{ appKey: string, from?: string, to?: string, granularity?: string }} requested
25
- */
26
- function parseChallengeVolume(body, requested) {
27
- const ok = body && typeof body === "object" ?
28
- Reflect.get(body, "ok") : undefined;
29
-
30
- const data = body && typeof body === "object" ?
31
- Reflect.get(body, "data") : undefined;
32
-
33
- if (ok !== true || !data || typeof data !== "object" || Array.isArray(data)) {
34
- throw invalidResponse("The challenge volume response did not contain ok=true and data");
35
- }
36
-
37
- const appKey = objectString(data, "app_key");
38
- const from = objectString(data, "from");
39
- const to = objectString(data, "to");
40
- const granularity = objectString(data, "granularity");
41
- const challengeCount = Reflect.get(data, "challenge_count");
42
-
43
- if (appKey !== requested.appKey) {
44
- throw invalidResponse("The challenge volume response did not match the requested App Key");
45
- }
46
- if (!from || !to || !isIsoTime(from) || !isIsoTime(to) || Date.parse(from) > Date.parse(to)) {
47
- throw invalidResponse("The challenge volume response contained an invalid time range");
48
- }
49
- if (requested.from && Date.parse(from) !== Date.parse(requested.from)) {
50
- throw invalidResponse("The challenge volume response did not match the requested start time");
51
- }
52
- if (requested.to && Date.parse(to) !== Date.parse(requested.to)) {
53
- throw invalidResponse("The challenge volume response did not match the requested end time");
54
- }
55
- if (!granularity || (requested.granularity && granularity !== requested.granularity)) {
56
- throw invalidResponse("The challenge volume response contained an unexpected granularity");
57
- }
58
- if (!Number.isSafeInteger(challengeCount) || /** @type {number} */ (challengeCount) < 0) {
59
- throw invalidResponse("The challenge volume response contained an invalid challenge_count");
60
- }
61
-
62
- // Rebuild the response so unexpected API fields cannot reach command output.
63
- return {
64
- app_key: appKey,
65
- from,
66
- to,
67
- granularity,
68
- challenge_count: challengeCount,
69
- };
70
- }
71
-
72
- /** @param {string} value */
73
- function isIsoTime(value) {
74
- return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:\d{2})$/.test(value)
75
- && Number.isFinite(Date.parse(value));
76
- }
77
-
78
- /** @param {string} message */
79
- function invalidResponse(message) {
80
- return new CliError(message, { code: "INVALID_RESPONSE" });
81
- }
@@ -1,106 +0,0 @@
1
- // @ts-check
2
-
3
- import { CliError } from "../errors.mjs";
4
- import { objectString } from "../api-client.mjs";
5
-
6
- const SENSITIVE_FIELDS = new Set([
7
- "access_token",
8
- "accesstoken",
9
- "api_secret",
10
- "apisecret",
11
- "secret_key",
12
- "secretkey",
13
- "session_token",
14
- "sessiontoken",
15
- ]);
16
-
17
- /**
18
- * @param {{ api: { integrationContract(accessToken: string): Promise<{body: unknown}> }, sessions: { loadToken(): Promise<string> }, log: (message: string) => void }} deps
19
- */
20
- export async function showIntegrationContract(deps) {
21
- const accessToken =
22
- await deps.sessions.loadToken();
23
-
24
- const result =
25
- await deps.api.integrationContract(accessToken);
26
-
27
- const data = parseIntegrationContract(result.body);
28
-
29
- deps.log(JSON.stringify({ ok: true, data }, null, 2));
30
- }
31
-
32
- /** @param {unknown} body @returns {Record<string, unknown>} */
33
- function parseIntegrationContract(body) {
34
- const ok = body && typeof body === "object" ?
35
- Reflect.get(body, "ok") : undefined;
36
-
37
- const data = objectValue(body, "data");
38
- if (ok !== true || !data) {
39
- throw invalidResponse("The integration contract response did not contain ok=true and data");
40
- }
41
-
42
- const browser = objectValue(data, "browser");
43
- const backend = objectValue(data, "backend");
44
- const minJdk = backend ?
45
- Reflect.get(backend, "min_jdk") : undefined;
46
-
47
- if (
48
- !objectString(data, "contract_version")
49
- || !browser
50
- || !objectString(browser, "script_url")
51
- || !objectString(browser, "token_header")
52
- || !stringArray(browser, "available_modes")
53
- || !backend
54
- || !stringArray(backend, "supported_languages")
55
- || !Number.isInteger(minJdk)
56
- || /** @type {number} */ (minJdk) < 1
57
- || !objectString(data, "release_state")
58
- ) {
59
- throw invalidResponse("The integration contract response is missing required fields");
60
- }
61
-
62
- // Preserve future contract fields, but never relay credential-shaped fields to output.
63
- assertNoSensitiveFields(data);
64
- return data;
65
- }
66
-
67
- /** @param {unknown} value @param {string} property */
68
- function objectValue(value, property) {
69
- if (!value || typeof value !== "object")
70
- return undefined;
71
-
72
- const found = Reflect.get(value, property);
73
-
74
- return found && typeof found === "object" && !Array.isArray(found) ? found : undefined;
75
- }
76
-
77
- /** @param {unknown} value @param {string} property */
78
- function stringArray(value, property) {
79
- if (!value || typeof value !== "object")
80
- return false;
81
-
82
- const found = Reflect.get(value, property);
83
-
84
- return Array.isArray(found) && found.every((item) => typeof item === "string" && item !== "");
85
- }
86
-
87
- /** @param {unknown} value */
88
- function assertNoSensitiveFields(value) {
89
- if (Array.isArray(value)) {
90
- for (const item of value) assertNoSensitiveFields(item);
91
- return;
92
- }
93
- if (!value || typeof value !== "object")
94
- return;
95
- for (const [key, nestedValue] of Object.entries(value)) {
96
- if (SENSITIVE_FIELDS.has(key.toLowerCase())) {
97
- throw invalidResponse("The integration contract response contained a sensitive field");
98
- }
99
- assertNoSensitiveFields(nestedValue);
100
- }
101
- }
102
-
103
- /** @param {string} message */
104
- function invalidResponse(message) {
105
- return new CliError(message, { code: "INVALID_RESPONSE" });
106
- }