@f5-sales-demo/pi-utils 21.6.4 → 21.8.0

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/pi-utils",
4
- "version": "21.6.4",
4
+ "version": "21.8.0",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://github.com/f5-sales-demo/xcsh",
7
7
  "author": "Can Boluk",
@@ -39,7 +39,7 @@
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/bun": "^1.3",
42
- "@f5-sales-demo/pi-natives": "21.6.4"
42
+ "@f5-sales-demo/pi-natives": "21.8.0"
43
43
  },
44
44
  "engines": {
45
45
  "bun": ">=1.3.7"
@@ -54,7 +54,8 @@
54
54
  },
55
55
  "./*": {
56
56
  "types": "./src/*.ts",
57
- "import": "./src/*.ts"
57
+ "import": "./src/*.ts",
58
+ "require": "./src/*.ts"
58
59
  },
59
60
  "./*.js": "./src/*.ts"
60
61
  }
package/src/index.ts CHANGED
@@ -37,6 +37,7 @@ export * from "./tab-spacing";
37
37
  export * from "./temp";
38
38
  export * from "./type-guards";
39
39
  export * from "./which";
40
+ export * from "./xcsh-auth";
40
41
  export * from "./xcsh-context-paths";
41
42
  export * from "./xcsh-context-resolver";
42
43
  export * from "./xcsh-env-names";
@@ -0,0 +1,165 @@
1
+ import { type XCSH_API_TOKEN, XCSH_API_URL, type XCSH_CONSOLE_PASSWORD, type XCSH_USERNAME } from "./xcsh-env-names";
2
+
3
+ export type XcshCredentialKey =
4
+ | typeof XCSH_API_URL
5
+ | typeof XCSH_API_TOKEN
6
+ | typeof XCSH_USERNAME
7
+ | typeof XCSH_CONSOLE_PASSWORD;
8
+
9
+ export type XcshAuthValidationFailure =
10
+ | "unauthorized"
11
+ | "forbidden"
12
+ | "redirect"
13
+ | "non_json"
14
+ | "rate_limited"
15
+ | "server"
16
+ | "timeout"
17
+ | "network";
18
+
19
+ export type XcshAuthValidationStatus = "connected" | "auth_error" | "offline";
20
+
21
+ export interface XcshAuthValidationResult {
22
+ status: XcshAuthValidationStatus;
23
+ latencyMs: number;
24
+ httpStatus?: number;
25
+ failureReason?: XcshAuthValidationFailure;
26
+ namespaces?: string[];
27
+ }
28
+
29
+ export interface XcshAuthValidationOptions {
30
+ apiUrl: string;
31
+ apiToken: string;
32
+ fetch?: typeof globalThis.fetch;
33
+ timeoutMs?: number;
34
+ signal?: AbortSignal;
35
+ now?: () => number;
36
+ }
37
+
38
+ function unwrapWholeValueQuotes(value: string): string | null {
39
+ if (value.startsWith('"') || value.endsWith('"') || value.startsWith("'") || value.endsWith("'")) {
40
+ if (!(value.length >= 2 && value[0] === value.at(-1))) return null;
41
+ return value.slice(1, -1);
42
+ }
43
+ return value;
44
+ }
45
+
46
+ /** Normalize a raw credential or a supported shell/dotenv assignment without evaluating it. */
47
+ export function normalizeXcshCredentialInput(value: string, expectedKey: XcshCredentialKey): string | null {
48
+ if (typeof value !== "string" || /\r|\n/.test(value)) return null;
49
+ const trimmed = value.trim();
50
+ if (!trimmed) return "";
51
+
52
+ const assignment = trimmed.match(/^(?:#\s*)?(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
53
+ const isShellAssignment =
54
+ assignment !== null &&
55
+ (assignment[1] === expectedKey || assignment[1].startsWith("XCSH_") || /^(?:#\s*)?(?:export\s+)/.test(trimmed));
56
+ if (assignment && isShellAssignment) {
57
+ if (assignment[1] !== expectedKey) return null;
58
+ return unwrapWholeValueQuotes(assignment[2].trim());
59
+ }
60
+
61
+ if (trimmed === expectedKey || trimmed === `export ${expectedKey}` || trimmed === `#${expectedKey}`) return null;
62
+ if (/^(?:#\s*)?(?:export\s+)?XCSH_[A-Za-z0-9_]*\s*=/.test(trimmed)) return null;
63
+ return unwrapWholeValueQuotes(trimmed);
64
+ }
65
+
66
+ /** Normalize a parseable absolute API URL to its origin. */
67
+ export function normalizeXcshApiUrlInput(value: string): string | null {
68
+ const normalized = normalizeXcshCredentialInput(value, XCSH_API_URL);
69
+ if (normalized === null || normalized === "") return null;
70
+ try {
71
+ return new URL(normalized).origin;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ export function buildXcshAuthHeaders(apiToken: string): Record<string, string> {
78
+ return { Authorization: `APIToken ${apiToken}`, Accept: "application/json" };
79
+ }
80
+
81
+ function extractNamespaceNames(payload: unknown): string[] {
82
+ let values: unknown[] = [];
83
+ if (Array.isArray(payload)) values = payload;
84
+ else if (payload && typeof payload === "object") {
85
+ const record = payload as Record<string, unknown>;
86
+ if (Array.isArray(record.items)) values = record.items;
87
+ else if (Array.isArray(record.namespaces)) values = record.namespaces;
88
+ }
89
+ return [
90
+ ...new Set(
91
+ values
92
+ .map(value =>
93
+ typeof value === "string"
94
+ ? value
95
+ : value && typeof value === "object" && typeof (value as Record<string, unknown>).name === "string"
96
+ ? ((value as Record<string, unknown>).name as string)
97
+ : null,
98
+ )
99
+ .filter((value): value is string => value !== null && value.length > 0),
100
+ ),
101
+ ].sort((a, b) => a.localeCompare(b));
102
+ }
103
+
104
+ /** Validate API credentials without leaking credentials, response bodies, or tenant URLs in the result. */
105
+ export async function validateXcshApiCredentials(
106
+ options: XcshAuthValidationOptions,
107
+ ): Promise<XcshAuthValidationResult> {
108
+ const fetchImpl = options.fetch ?? globalThis.fetch;
109
+ const timeoutMs = options.timeoutMs ?? 3000;
110
+ const now = options.now ?? (() => performance.now());
111
+ const startedAt = now();
112
+ const controller = new AbortController();
113
+ const abort = (): void => controller.abort();
114
+ const timer = setTimeout(abort, timeoutMs);
115
+ const externalAbort = (): void => controller.abort();
116
+ options.signal?.addEventListener("abort", externalAbort, { once: true });
117
+ if (options.signal?.aborted) controller.abort();
118
+ const latency = (): number => Math.max(0, Math.round(now() - startedAt));
119
+
120
+ try {
121
+ const apiUrl = normalizeXcshApiUrlInput(options.apiUrl) ?? options.apiUrl.trim().replace(/\/+$/, "");
122
+ const response = await fetchImpl(`${apiUrl}/api/web/namespaces`, {
123
+ method: "GET",
124
+ headers: buildXcshAuthHeaders(options.apiToken),
125
+ signal: controller.signal,
126
+ redirect: "manual",
127
+ });
128
+ const base = { latencyMs: latency(), ...(response.status ? { httpStatus: response.status } : {}) };
129
+ if (response.type === "opaqueredirect" || (response.status >= 300 && response.status < 400)) {
130
+ return { status: "offline", ...base, failureReason: "redirect" };
131
+ }
132
+ if (response.status === 401 || response.status === 403) {
133
+ return {
134
+ status: "auth_error",
135
+ ...base,
136
+ failureReason: response.status === 401 ? "unauthorized" : "forbidden",
137
+ };
138
+ }
139
+ if (response.status === 429) return { status: "offline", ...base, failureReason: "rate_limited" };
140
+ if (response.status >= 500) return { status: "offline", ...base, failureReason: "server" };
141
+ if (!response.ok) return { status: "offline", ...base, failureReason: "network" };
142
+
143
+ const contentType = response.headers.get("content-type") ?? "";
144
+ if (!contentType.toLowerCase().includes("application/json")) {
145
+ return { status: "offline", ...base, failureReason: "non_json" };
146
+ }
147
+ try {
148
+ const payload = await response.json();
149
+ return { status: "connected", ...base, namespaces: extractNamespaceNames(payload) };
150
+ } catch {
151
+ return { status: "offline", ...base, failureReason: "non_json" };
152
+ }
153
+ } catch (error) {
154
+ const isTimeout =
155
+ controller.signal.aborted && !options.signal?.aborted
156
+ ? true
157
+ : error instanceof Error &&
158
+ (error.name === "TimeoutError" || error.name === "AbortError") &&
159
+ !options.signal?.aborted;
160
+ return { status: "offline", latencyMs: latency(), failureReason: isTimeout ? "timeout" : "network" };
161
+ } finally {
162
+ clearTimeout(timer);
163
+ options.signal?.removeEventListener("abort", externalAbort);
164
+ }
165
+ }