@limecloud/cli 0.0.0-stage → 0.3.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/src/input.mjs ADDED
@@ -0,0 +1,91 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { requestSchema } from "./contract.mjs";
3
+ import { CLIError } from "./protocol.mjs";
4
+
5
+ const sensitive = new Set(["access_token", "refresh_token", "id_token", "device_code", "client_secret", "code", "code_verifier", "token"]);
6
+ const fail = (message) => { throw new CLIError("invalid_arguments", message); };
7
+
8
+ export async function readSource(path, limit = 1_048_576) {
9
+ if (path !== 0) {
10
+ const bytes = await readFile(path);
11
+ if (bytes.length > limit) fail("Input exceeds its byte limit");
12
+ return bytes;
13
+ }
14
+ const chunks = [];
15
+ let size = 0;
16
+ for await (const chunk of process.stdin) {
17
+ const bytes = Buffer.from(chunk); size += bytes.length;
18
+ if (size > limit) fail("Input exceeds its byte limit");
19
+ chunks.push(bytes);
20
+ }
21
+ return Buffer.concat(chunks);
22
+ }
23
+
24
+ export async function jsonInput(input, readInput = async (path) => (await readSource(path)).toString("utf8")) {
25
+ const indirect = input === "-" || input.startsWith("@");
26
+ const source = indirect ? await readInput(input === "-" ? 0 : input.slice(1)) : input;
27
+ if (Buffer.byteLength(source) > 1_048_576) fail("JSON input exceeds 1 MiB");
28
+ let value;
29
+ try { value = JSON.parse(source); } catch { fail("Invalid JSON input"); }
30
+ if (!value || typeof value !== "object" || Array.isArray(value)) fail("JSON input must be an object");
31
+ return { value, indirect };
32
+ }
33
+
34
+ export async function parametersFor(parsed, env, method, readInput) {
35
+ const form = new URLSearchParams();
36
+ function append(name, value, privateInput) {
37
+ if (!/^[a-z][a-z0-9_]*$/.test(name)) fail("Invalid parameter name");
38
+ if (["access_token", "client_secret", "id_token"].includes(name)) fail("Use credential environment variables for authentication");
39
+ if (!privateInput && sensitive.has(name)) fail(`Use --param-env or --data @file for the sensitive field ${name}`);
40
+ for (const item of Array.isArray(value) ? value : [value]) {
41
+ if (!["string", "number", "boolean"].includes(typeof item)) fail(`Parameter ${name} must be a scalar or array of scalars`);
42
+ form.append(name, String(item));
43
+ }
44
+ }
45
+ for (const flag of ["params", "data"]) {
46
+ const input = parsed.get(flag);
47
+ if (input === undefined) continue;
48
+ if (flag === "data" && method === "GET") fail("GET accepts --params, not --data");
49
+ const { value, indirect } = await jsonInput(input, readInput);
50
+ if (flag === "params" && method !== "GET" && Object.keys(value).length) fail("OAuth POST fields belong in --data, not query --params");
51
+ for (const [name, item] of Object.entries(value)) append(name, item, indirect);
52
+ }
53
+ for (const flag of ["param", "param-env"]) for (const value of parsed.flags.get(flag) ?? []) {
54
+ const equal = value.indexOf("=");
55
+ if (equal < 1) fail(`--${flag} requires name=value`);
56
+ const name = value.slice(0, equal), input = value.slice(equal + 1);
57
+ if (flag === "param-env") {
58
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(input) || !env[input]) fail(`--param-env ${name} requires a populated environment variable`);
59
+ append(name, env[input], true);
60
+ } else append(name, input, false);
61
+ }
62
+ return form;
63
+ }
64
+
65
+ export function validateParameters(contract, operation, form, basic, dryRun = false) {
66
+ const schema = requestSchema(contract, operation, form);
67
+ const properties = schema?.properties ?? Object.fromEntries((operation.parameters ?? []).map((p) => [p.name, p.schema]));
68
+ const required = schema?.required ?? (operation.parameters ?? []).filter((p) => p.required).map((p) => p.name);
69
+ for (const name of form.keys()) {
70
+ const property = properties[name];
71
+ if (!property) fail(`Unknown parameter ${name}; use schema ${operation.command ?? operation.operationId}`);
72
+ const values = form.getAll(name);
73
+ if (values.length > 1 && property.type !== "array") fail(`Parameter ${name} must occur once`);
74
+ for (const value of values) {
75
+ if (property.const !== undefined && value !== property.const || property.enum && !property.enum.includes(value) ||
76
+ property.minLength !== undefined && value.length < property.minLength ||
77
+ property.maxLength !== undefined && value.length > property.maxLength) fail(`Invalid value for ${name}`);
78
+ }
79
+ if (operation.method === "GET" && sensitive.has(name)) fail("Credential parameters cannot be sent in URLs");
80
+ }
81
+ for (const name of required) if (!form.get(name)) fail(`Required parameter: ${name}`);
82
+ if (!dryRun && !basic && ["/oauth/token", "/oauth/device/authorization", "/oauth/revoke"].includes(operation.path) && !form.get("client_id")) {
83
+ fail("Set --client-id or LIMECLOUD_CLIENT_ID for this public-client request");
84
+ }
85
+ }
86
+
87
+ export function scopeList(value) {
88
+ const scopes = value.trim().split(/[\s,]+/).filter(Boolean);
89
+ if (!scopes.length || scopes.some((scope) => !/^[A-Za-z0-9_:.\/-]{1,100}$/.test(scope)) || new Set(scopes).size !== scopes.length) fail("Provide a non-empty list of unique scopes");
90
+ return scopes;
91
+ }
@@ -0,0 +1,160 @@
1
+ import { createHash } from "node:crypto";
2
+ import { resolveSchema } from "./contract.mjs";
3
+ import { jsonInput, readSource } from "./input.mjs";
4
+ import { CLIError } from "./protocol.mjs";
5
+
6
+ export const flagName = (field) => field.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/([A-Z])([A-Z][a-z])/g, "$1-$2").replaceAll("_", "-").toLowerCase();
7
+ const fail = (message) => { throw new CLIError("invalid_arguments", message); };
8
+ const sensitive = new Set(["password", "secret", "client_secret", "signing_key", "access_token", "refresh_token", "id_token", "device_code", "code_verifier", "token", "totp_secret", "recovery_codes"]);
9
+
10
+ function noInlineSecrets(value) {
11
+ if (!value || typeof value !== "object") return;
12
+ for (const [key, item] of Object.entries(value)) {
13
+ if (sensitive.has(key) && item != null && item !== "") fail("Sensitive JSON fields require --data @file, stdin, or --param-env");
14
+ noInlineSecrets(item);
15
+ }
16
+ }
17
+
18
+ export function validateJSON(contract, input, value, label = "data", depth = 0) {
19
+ if (!input || depth > 32) return;
20
+ const schema = resolveSchema(contract, input);
21
+ for (const kind of ["oneOf", "anyOf"]) if (schema[kind]) {
22
+ const accepted = schema[kind].filter((variant) => { try { validateJSON(contract, variant, value, label, depth + 1); return true; } catch { return false; } });
23
+ if (!accepted.length || kind === "oneOf" && accepted.length !== 1) fail(`${label} does not match its public schema`);
24
+ return;
25
+ }
26
+ for (const variant of schema.allOf ?? []) validateJSON(contract, variant, value, label, depth + 1);
27
+ if (schema.const !== undefined && value !== schema.const || schema.enum && !schema.enum.includes(value)) fail(`${label} has an unsupported value`);
28
+ const types = Array.isArray(schema.type) ? schema.type : schema.type ? [schema.type] : [];
29
+ if (types.length && !types.some((type) => type === "null" ? value === null : type === "array" ? Array.isArray(value) : type === "object" ? value && typeof value === "object" && !Array.isArray(value) :
30
+ type === "integer" ? Number.isInteger(value) : type === "number" ? typeof value === "number" && Number.isFinite(value) : typeof value === type)) fail(`${label} has the wrong type`);
31
+ if (typeof value === "string" && (schema.minLength !== undefined && value.length < schema.minLength || schema.maxLength !== undefined && value.length > schema.maxLength || schema.pattern && !new RegExp(schema.pattern).test(value))) fail(`${label} has an invalid string value`);
32
+ if (typeof value === "number" && (schema.minimum !== undefined && value < schema.minimum || schema.maximum !== undefined && value > schema.maximum)) fail(`${label} is outside the allowed range`);
33
+ if (Array.isArray(value)) {
34
+ if (schema.minItems !== undefined && value.length < schema.minItems || schema.maxItems !== undefined && value.length > schema.maxItems) fail(`${label} has an invalid number of items`);
35
+ value.forEach((item, index) => validateJSON(contract, schema.items, item, `${label}[${index}]`, depth + 1));
36
+ } else if (value && typeof value === "object") {
37
+ for (const required of schema.required ?? []) if (!Object.hasOwn(value, required)) fail(`${label}.${required} is required`);
38
+ for (const [name, item] of Object.entries(value)) {
39
+ if (schema.additionalProperties === false && !Object.hasOwn(schema.properties ?? {}, name)) fail(`${label}.${name} is not a declared field`);
40
+ validateJSON(contract, schema.properties?.[name], item, `${label}.${name}`, depth + 1);
41
+ }
42
+ }
43
+ }
44
+
45
+ export function matchHTTP(catalogue, method, path) {
46
+ if (!path?.startsWith("/") || path.startsWith("//") || /[?#\\\x00-\x1f]/.test(path)) return null;
47
+ for (const operation of catalogue.filter((item) => item.method === method)) {
48
+ const pattern = operation.path.split("/"), actual = path.split("/");
49
+ if (pattern.length !== actual.length) continue;
50
+ const bindings = {};
51
+ let matches = true;
52
+ for (let index = 0; index < pattern.length; index++) {
53
+ const name = pattern[index].match(/^\{([^}]+)\}$/)?.[1];
54
+ if (!name && pattern[index] !== actual[index]) { matches = false; break; }
55
+ if (name) {
56
+ try { bindings[name] = decodeURIComponent(actual[index]); } catch { matches = false; break; }
57
+ if (!bindings[name] || /[/\\?#\x00-\x1f]/.test(bindings[name])) { matches = false; break; }
58
+ }
59
+ }
60
+ if (matches) return { operation, bindings };
61
+ }
62
+ return null;
63
+ }
64
+
65
+ function cast(raw, schema) {
66
+ if (schema?.type === "integer" || schema?.type === "number") {
67
+ if (String(raw).trim() === "" || !Number.isFinite(Number(raw))) fail("Expected a numeric parameter");
68
+ return Number(raw);
69
+ }
70
+ if (schema?.type === "boolean") {
71
+ if (raw !== "true" && raw !== "false" && typeof raw !== "boolean") fail("Expected true or false");
72
+ return raw === true || raw === "true";
73
+ }
74
+ if (["object", "array"].includes(schema?.type) && typeof raw === "string") {
75
+ try { return JSON.parse(raw); } catch { fail("Structured typed fields require a JSON value"); }
76
+ }
77
+ return raw;
78
+ }
79
+
80
+ export async function prepareManagement(contract, operation, parsed, env, readInput, rawBindings = {}, readBinary = readSource) {
81
+ const declarations = operation.parameters ?? [];
82
+ const inputSchema = resolveSchema(contract, operation.requestBody?.content?.["application/json"]?.schema);
83
+ const values = {}, body = {};
84
+ let bodyProvided = false;
85
+ const binaryType = Object.keys(operation.requestBody?.content ?? {}).find((type) => type === "image/png");
86
+ let binaryBody;
87
+ if (parsed.get("file")) {
88
+ if (!binaryType) fail("This public method does not accept --file");
89
+ if (parsed.get("data")) fail("Use --file alone for a binary request body");
90
+ binaryBody = Buffer.from(await readBinary(parsed.get("file") === "-" ? 0 : parsed.get("file"), 320 << 10));
91
+ if (binaryBody.length > 320 << 10 || binaryBody.length < 33 || !binaryBody.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) ||
92
+ binaryBody.toString("ascii", 12, 16) !== "IHDR" || binaryBody.readUInt32BE(16) !== 256 || binaryBody.readUInt32BE(20) !== 256) fail("Choose a cropped 256 by 256 PNG no larger than 320 KiB");
93
+ bodyProvided = true;
94
+ }
95
+ if (parsed.get("params")) {
96
+ const { value, indirect } = await jsonInput(parsed.get("params"), readInput);
97
+ if (!indirect) noInlineSecrets(value);
98
+ Object.assign(values, value);
99
+ }
100
+ if (parsed.get("data")) {
101
+ if (!inputSchema) fail("This public method does not accept --data");
102
+ const input = await jsonInput(parsed.get("data"), readInput);
103
+ if (!input.indirect) noInlineSecrets(input.value);
104
+ Object.assign(body, input.value); bodyProvided = true;
105
+ }
106
+ const declaration = (name) => declarations.find((item) => item.name === name || item.in === "header" && item.name.toLowerCase() === name.toLowerCase());
107
+ function apply(name, raw, privateInput) {
108
+ if (!privateInput && sensitive.has(name)) fail("Sensitive fields require --param-env or private JSON input");
109
+ const parameter = declaration(name);
110
+ const property = inputSchema?.properties?.[name];
111
+ if (!parameter && !property) fail(`Unknown parameter ${name}`);
112
+ if (parameter) values[parameter.name] = cast(raw, parameter.schema);
113
+ else { body[name] = cast(raw, resolveSchema(contract, property)); bodyProvided = true; }
114
+ }
115
+ for (const kind of ["param", "param-env", "header"]) for (const pair of parsed.flags.get(kind) ?? []) {
116
+ const index = pair.indexOf("=");
117
+ if (index < 1) fail(`--${kind} requires name=value`);
118
+ const name = pair.slice(0, index), raw = pair.slice(index + 1);
119
+ if (kind === "header" && !declarations.some((item) => item.in === "header" && item.name.toLowerCase() === name.toLowerCase())) fail("Only contract-declared HTTP headers can be set");
120
+ if (kind === "param-env") {
121
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(raw) || !env[raw]) fail(`--param-env ${name} needs a populated environment variable`);
122
+ apply(name, env[raw], true);
123
+ } else apply(name, raw, false);
124
+ }
125
+ for (const name of [...declarations.map((item) => item.name), ...Object.keys(inputSchema?.properties ?? {})]) {
126
+ if (parsed.get(flagName(name)) !== undefined) {
127
+ const field = declaration(name)?.schema ?? resolveSchema(contract, inputSchema.properties[name]);
128
+ const items = parsed.flags.get(flagName(name));
129
+ apply(name, field?.type === "array" && items.length > 1 ? items : items[0], false);
130
+ }
131
+ }
132
+ for (const key of Object.keys(values)) if (!declaration(key)) fail(`Unknown path/query/header parameter ${key}`);
133
+ const query = new URLSearchParams(), headers = {}, bindings = { ...rawBindings };
134
+ for (const parameter of declarations) {
135
+ let value = values[parameter.name];
136
+ if (parameter.in === "path" && rawBindings[parameter.name] !== undefined) {
137
+ if (value !== undefined && String(value) !== rawBindings[parameter.name]) fail(`Path parameter ${parameter.name} conflicts with the raw HTTP path`);
138
+ value = rawBindings[parameter.name];
139
+ }
140
+ if (value === undefined) { if (parameter.required) fail(`Required ${parameter.in} parameter: ${parameter.name}`); continue; }
141
+ value = cast(value, parameter.schema);
142
+ validateJSON(contract, parameter.schema, value, parameter.name);
143
+ if (parameter.in === "path") {
144
+ if (typeof value !== "string" || !value || value === "." || value === ".." || /[/\\?#\x00-\x1f]/.test(value)) fail(`Invalid path parameter ${parameter.name}`);
145
+ bindings[parameter.name] = value;
146
+ } else if (parameter.in === "header") {
147
+ if (["authorization", "cookie", "host", "content-type"].includes(parameter.name.toLowerCase()) || /[\r\n\0]/.test(String(value))) fail("Unsafe or reserved request header");
148
+ headers[parameter.name] = String(value);
149
+ } else if (parameter.in === "query") for (const item of Array.isArray(value) ? value : [value]) query.append(parameter.name, String(item));
150
+ }
151
+ const path = operation.path.replace(/\{([^}]+)\}/g, (_match, key) => {
152
+ if (bindings[key] === undefined) fail(`Required path parameter: ${key}`);
153
+ return encodeURIComponent(bindings[key]);
154
+ });
155
+ if (operation.requestBody?.required && !bodyProvided) fail(binaryType ? "This public method requires --file PNG_PATH (or --file - for stdin)" : "This public method requires --data JSON");
156
+ if (bodyProvided && !binaryBody) validateJSON(contract, inputSchema, body);
157
+ return { path, query, extraHeaders: headers, ...(binaryBody ? { binaryBody, binaryType,
158
+ binarySummary: { media_type: binaryType, bytes: binaryBody.length, width: 256, height: 256, sha256: createHash("sha256").update(binaryBody).digest("hex") } } :
159
+ bodyProvided ? { jsonBody: body } : {}) };
160
+ }
package/src/output.mjs ADDED
@@ -0,0 +1,55 @@
1
+ import { CLIError, redact } from "./protocol.mjs";
2
+
3
+ export const formats = ["json", "ndjson", "table", "csv"];
4
+
5
+ export function outputOptions(parsed) {
6
+ const format = parsed.get("json") ? "json" : parsed.get("format") ?? "json";
7
+ if (!formats.includes(format)) throw new CLIError("invalid_arguments", "--format must be json, ndjson, table or csv");
8
+ if (parsed.get("json") && parsed.get("format") && parsed.get("format") !== "json") throw new CLIError("invalid_arguments", "--json conflicts with --format");
9
+ const fields = parsed.get("fields")?.split(",").map((value) => value.trim());
10
+ if (fields?.some((value) => !/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/.test(value) || value.split(".").some((part) => ["__proto__", "prototype", "constructor"].includes(part)))) {
11
+ throw new CLIError("invalid_arguments", "--fields must be comma-separated field paths");
12
+ }
13
+ return { format, fields };
14
+ }
15
+
16
+ function select(value, fields) {
17
+ if (!fields) return value;
18
+ if (Array.isArray(value)) return value.map((row) => select(row, fields));
19
+ return Object.fromEntries(fields.map((field) => [field, field.split(".").reduce((current, key) =>
20
+ current && typeof current === "object" && Object.hasOwn(current, key) ? current[key] : null, value)]));
21
+ }
22
+
23
+ function text(value) {
24
+ const result = value == null ? "" : typeof value === "object" ? JSON.stringify(value) : String(value);
25
+ return result.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").replace(/[\x00-\x1f\x7f-\x9f]/g, " ");
26
+ }
27
+
28
+ export function success(io, value, { format = "json", fields, identity = "public", raw = false, meta = {} } = {}) {
29
+ const data = select(raw ? value : redact(value), fields);
30
+ const rows = (Array.isArray(data) ? data : [data]).map((row) => row && typeof row === "object" && !Array.isArray(row) ? row : { value: row });
31
+ if (format === "json") {
32
+ io.stdout.write(`${JSON.stringify({ ok: true, identity, data, meta: { ...meta, ...(Array.isArray(data) ? { count: data.length } : {}) } }, null, 2)}\n`);
33
+ } else if (format === "ndjson") {
34
+ for (const row of rows) io.stdout.write(`${JSON.stringify({ ok: true, identity, data: row })}\n`);
35
+ } else {
36
+ const keys = [...new Set(rows.flatMap((row) => Object.keys(row)))];
37
+ if (format === "csv") {
38
+ const quote = (item) => `"${String(item ?? "").replaceAll('"', '""')}"`;
39
+ io.stdout.write(`${[keys, ...rows.map((row) => keys.map((key) => typeof row[key] === "object" && row[key] !== null ? JSON.stringify(row[key]) : row[key]))].map((row) => row.map(quote).join(",")).join("\n")}\n`);
40
+ } else {
41
+ const cells = [keys, ...rows.map((row) => keys.map((key) => text(row[key])))];
42
+ const widths = keys.map((_key, index) => Math.min(80, Math.max(...cells.map((row) => row[index].length))));
43
+ io.stdout.write(`${cells.map((row) => row.map((cell, index) => text(cell).slice(0, widths[index]).padEnd(widths[index])).join(" ").trimEnd()).join("\n")}\n`);
44
+ }
45
+ }
46
+ }
47
+
48
+ export function errorEnvelope(error, cancelled) {
49
+ const known = error instanceof CLIError;
50
+ const code = cancelled ? "cancelled" : known ? error.code : "cli_error";
51
+ const type = error?.status ? "api" : ["login_required", "token_expired", "missing_scopes"].includes(code) ? "auth" :
52
+ code === "request_failed" ? "transport" : code === "invalid_arguments" || code === "invalid_issuer" ? "validation" : "cli";
53
+ return { ok: false, error: { type, ...(type === "validation" ? { subtype: "invalid_argument" } : {}), code, message: cancelled ? "Command cancelled" : known ? error.message : "Command failed; check local configuration and retry",
54
+ ...(known && error.status ? { status: error.status } : {}), ...(known && error.retryAfter ? { retry_after: error.retryAfter } : {}) } };
55
+ }
@@ -0,0 +1,147 @@
1
+ export class CLIError extends Error {
2
+ constructor(code, message, status, retryAfter) {
3
+ super(message);
4
+ this.code = code;
5
+ this.status = status;
6
+ this.retryAfter = retryAfter;
7
+ }
8
+ }
9
+
10
+ export function issuerOrigin(value) {
11
+ let url;
12
+ try { url = new URL(value); } catch { throw new CLIError("invalid_issuer", "Issuer must be an absolute HTTPS origin"); }
13
+ const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
14
+ if ((url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) || url.username || url.password ||
15
+ url.pathname !== "/" || url.search || url.hash) {
16
+ throw new CLIError("invalid_issuer", "Issuer must be an HTTPS origin; HTTP is allowed only on loopback");
17
+ }
18
+ return url.origin;
19
+ }
20
+
21
+ export function verificationURI(value, issuer) {
22
+ let url;
23
+ try { url = new URL(value); } catch { throw new CLIError("invalid_response", "Invalid device verification URI"); }
24
+ if (url.origin !== issuer || url.username || url.password || url.pathname !== "/auth/device") {
25
+ throw new CLIError("invalid_response", "Device verification URI does not match this issuer");
26
+ }
27
+ return url.href;
28
+ }
29
+
30
+ const secretKeys = new Set(["access_token", "refresh_token", "id_token", "device_code", "client_secret", "secret", "password", "signing_key", "totp_secret", "recovery_codes", "code", "code_verifier", "token"]);
31
+ export function redact(value) {
32
+ if (Array.isArray(value)) return value.map(redact);
33
+ if (!value || typeof value !== "object") return value;
34
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, secretKeys.has(key) ? "[REDACTED]" : redact(item)]));
35
+ }
36
+
37
+ export async function protocolRequest({ issuer, origin = issuer, path, method = "GET", parameters, query, jsonBody, binaryBody, binaryType, extraHeaders = {}, accessToken, clientID, clientSecret, fetcher = fetch, signal }) {
38
+ const targetOrigin = issuerOrigin(origin);
39
+ const url = new URL(path, targetOrigin);
40
+ if (url.origin !== targetOrigin || !path.startsWith("/") || path.startsWith("//") || url.pathname !== path || url.hash || url.search) {
41
+ throw new CLIError("invalid_endpoint", "Endpoint must be a contract path on this issuer");
42
+ }
43
+ const headers = { ...extraHeaders, Accept: "application/json" };
44
+ const form = new URLSearchParams(parameters);
45
+ if (clientSecret) {
46
+ if (!clientID) throw new CLIError("client_id_required", "Set LIMECLOUD_CLIENT_ID with LIMECLOUD_CLIENT_SECRET");
47
+ // OAuth Basic credentials use form encoding before Base64 encoding.
48
+ const encode = (value) => new URLSearchParams({ v: value }).toString().slice(2);
49
+ headers.Authorization = `Basic ${Buffer.from(`${encode(clientID)}:${encode(clientSecret)}`).toString("base64")}`;
50
+ form.delete("client_id");
51
+ } else if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
52
+ const management = path.startsWith("/api/v1/");
53
+ if (management) {
54
+ url.search = new URLSearchParams(query).toString();
55
+ if (binaryBody !== undefined) headers["Content-Type"] = binaryType;
56
+ else if (jsonBody !== undefined) headers["Content-Type"] = "application/json";
57
+ } else if (method === "GET") url.search = form.toString();
58
+ else headers["Content-Type"] = "application/x-www-form-urlencoded";
59
+ let response;
60
+ try {
61
+ response = await fetcher(url, {
62
+ method, headers, body: management ? binaryBody ?? (jsonBody === undefined ? undefined : JSON.stringify(jsonBody)) : method === "GET" ? undefined : form,
63
+ redirect: "manual", signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000),
64
+ });
65
+ } catch {
66
+ throw new CLIError("request_failed", "Request failed or timed out; a token exchange may have completed. Do not replay an uncertain token exchange");
67
+ }
68
+ if (response.status >= 300 && response.status < 400) {
69
+ throw new CLIError("redirect_refused", "Endpoint returned a redirect; no follow-up request was sent", response.status);
70
+ }
71
+ let data;
72
+ const body = await response.text();
73
+ if (!body) data = null;
74
+ else {
75
+ try { data = JSON.parse(body); } catch {
76
+ throw new CLIError("invalid_response", "Endpoint returned a non-JSON response", response.status);
77
+ }
78
+ }
79
+ if (!response.ok) {
80
+ const errorCode = typeof data?.error === "string" ? data.error : data?.error?.code;
81
+ const code = typeof errorCode === "string" && /^[A-Za-z0-9_.:-]{1,80}$/.test(errorCode) ? errorCode : "http_error";
82
+ // Do not echo error descriptions: a server may include submitted secrets in them.
83
+ const retry = Number(response.headers.get("Retry-After"));
84
+ throw new CLIError(code, `API request failed: ${code} (HTTP ${response.status})`, response.status,
85
+ Number.isFinite(retry) && retry > 0 ? retry : undefined);
86
+ }
87
+ return data;
88
+ }
89
+
90
+ export function tokenRecord(tokens, context, now = Date.now()) {
91
+ if (!tokens || typeof tokens.access_token !== "string" || !tokens.access_token ||
92
+ !/^bearer$/i.test(tokens.token_type) || !Number.isFinite(tokens.expires_in) || tokens.expires_in <= 0 || tokens.expires_in > 31_536_000 ||
93
+ (tokens.scope !== undefined && typeof tokens.scope !== "string") ||
94
+ (tokens.refresh_token !== undefined && (typeof tokens.refresh_token !== "string" || !tokens.refresh_token))) {
95
+ throw new CLIError("invalid_response", "Invalid OAuth token response; start a new login");
96
+ }
97
+ return { issuer: context.issuer, ...(context.apiOrigin ? { api_origin: context.apiOrigin } : {}), client_id: context.clientID, access_token: tokens.access_token,
98
+ refresh_token: tokens.refresh_token, scope: tokens.scope ?? "", expires_at: new Date(now + tokens.expires_in * 1000).toISOString() };
99
+ }
100
+
101
+ export async function startDevice({ context, request = protocolRequest, now = Date.now, parameters = new URLSearchParams() }) {
102
+ const input = new URLSearchParams(parameters);
103
+ input.set("client_id", context.clientID);
104
+ const device = await request({ ...context, path: "/oauth/device/authorization", method: "POST", parameters: input });
105
+ if (!device || typeof device.device_code !== "string" || !device.device_code || typeof device.user_code !== "string" ||
106
+ !/^[A-Z2-7]{4}-[A-Z2-7]{4}$/.test(device.user_code) || !Number.isInteger(device.interval) || device.interval <= 0 ||
107
+ !Number.isInteger(device.expires_in) || device.expires_in <= 0 || device.expires_in > 3600) {
108
+ throw new CLIError("invalid_response", "Invalid device authorization response");
109
+ }
110
+ return { issuer: context.issuer, ...(context.apiOrigin ? { api_origin: context.apiOrigin } : {}), client_id: context.clientID, device_code: device.device_code, user_code: device.user_code,
111
+ verification_uri: verificationURI(device.verification_uri_complete ?? device.verification_uri, context.issuer),
112
+ expires_at: new Date(now() + device.expires_in * 1000).toISOString(), interval: device.interval, next_poll_at: now() + device.interval * 1000 };
113
+ }
114
+
115
+ export async function pollDevice({ pending, request = protocolRequest, wait, now = Date.now, persist = async () => {} }) {
116
+ if (pending.exchange_pending) throw new CLIError("login_required", "The previous device token exchange has an unknown result; start a new auth login");
117
+ const deadline = Date.parse(pending.expires_at);
118
+ const context = { issuer: pending.issuer, apiOrigin: pending.api_origin, clientID: pending.client_id };
119
+ if (!Number.isFinite(deadline) || !Number.isFinite(pending.next_poll_at) || !Number.isInteger(pending.interval) || pending.interval <= 0 ||
120
+ typeof pending.device_code !== "string" || !pending.device_code || !context.clientID) throw new CLIError("invalid_credentials", "Invalid pending device authorization");
121
+ const state = { ...pending };
122
+ const form = new URLSearchParams({ client_id: context.clientID, grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: state.device_code });
123
+ while (now() < deadline) {
124
+ if (state.next_poll_at >= deadline) break;
125
+ await wait(Math.max(0, state.next_poll_at - now()));
126
+ if (now() >= deadline) break;
127
+ state.exchange_pending = true;
128
+ await persist(state);
129
+ try {
130
+ const tokens = await request({ ...context, path: "/oauth/token", method: "POST", parameters: form });
131
+ return tokenRecord(tokens, context, now());
132
+ } catch (error) {
133
+ if (error.code === "slow_down") state.interval += 5;
134
+ else if (error.code !== "authorization_pending") throw error;
135
+ state.exchange_pending = false;
136
+ state.next_poll_at = now() + state.interval * 1000;
137
+ await persist(state);
138
+ }
139
+ }
140
+ throw new CLIError("expired_token", "Device authorization expired; start auth login again");
141
+ }
142
+
143
+ export async function deviceLogin({ context, request = protocolRequest, notify, wait, now = Date.now, parameters = new URLSearchParams() }) {
144
+ const pending = await startDevice({ context, request, now, parameters });
145
+ notify({ verification_uri: pending.verification_uri, user_code: pending.user_code, expires_in: Math.ceil((Date.parse(pending.expires_at) - now()) / 1000) });
146
+ return pollDevice({ pending, request, wait, now });
147
+ }
@@ -0,0 +1,95 @@
1
+ import { constants } from "node:fs";
2
+ import { mkdir, open, rename, unlink } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { randomUUID } from "node:crypto";
6
+ import { CLIError, issuerOrigin } from "./protocol.mjs";
7
+ import { scopeList } from "./input.mjs";
8
+
9
+ export function credentialsPath(env = process.env) {
10
+ return env.LIMECLOUD_CONFIG || join(homedir(), ".config", "limecloud", "credentials.json");
11
+ }
12
+ export function settingsPath(env = process.env) {
13
+ return env.LIMECLOUD_SETTINGS || join(dirname(credentialsPath(env)), "config.json");
14
+ }
15
+
16
+ function checkPrivate(stat) {
17
+ if (!stat.isFile() || (process.platform !== "win32" && ((stat.mode & 0o077) || stat.uid !== process.getuid()))) {
18
+ throw new CLIError("unsafe_credentials", "Local configuration must be a regular file owned by this user with mode 0600");
19
+ }
20
+ }
21
+
22
+ export class PrivateJSONStore {
23
+ constructor(path) { this.path = path; }
24
+ async read() {
25
+ let file;
26
+ try {
27
+ file = await open(this.path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
28
+ checkPrivate(await file.stat());
29
+ const data = JSON.parse(await file.readFile("utf8"));
30
+ if (!data || typeof data !== "object" || Array.isArray(data)) throw new Error("Invalid record");
31
+ return data;
32
+ } catch (error) {
33
+ if (error.code === "ENOENT") return null;
34
+ if (error instanceof CLIError) throw error;
35
+ throw new CLIError("invalid_credentials", "Unable to read private local configuration; check the file or select another path");
36
+ } finally { await file?.close(); }
37
+ }
38
+ async write(record) {
39
+ await this.read();
40
+ const temp = `${this.path}.${randomUUID()}.new`;
41
+ const file = await open(temp, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
42
+ try { await file.writeFile(`${JSON.stringify(record)}\n`); await file.sync(); }
43
+ finally { await file.close(); }
44
+ await rename(temp, this.path);
45
+ }
46
+ async withLock(callback) {
47
+ await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
48
+ const lockPath = `${this.path}.lock`;
49
+ let lock;
50
+ try { lock = await open(lockPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600); }
51
+ catch (error) {
52
+ if (error.code === "EEXIST") throw new CLIError("credentials_busy", "Another command holds this configuration lock; finish it before retrying");
53
+ throw error;
54
+ }
55
+ try { await lock.writeFile(`${process.pid}\n`); return await callback(); }
56
+ finally { await lock.close(); await unlink(lockPath); }
57
+ }
58
+ }
59
+
60
+ export class CredentialStore extends PrivateJSONStore {
61
+ async read() {
62
+ const data = await super.read();
63
+ if (!data?.access_token) return null;
64
+ issuerOrigin(data.issuer);
65
+ if (data.api_origin !== undefined) issuerOrigin(data.api_origin);
66
+ if (typeof data.client_id !== "string" || !data.client_id || typeof data.access_token !== "string" ||
67
+ !Number.isFinite(Date.parse(data.expires_at)) ||
68
+ (data.refresh_token !== undefined && typeof data.refresh_token !== "string")) {
69
+ throw new CLIError("invalid_credentials", "Invalid saved credentials; use a separate LIMECLOUD_CONFIG and sign in again");
70
+ }
71
+ return data;
72
+ }
73
+ }
74
+
75
+ export function validateSettings(value) {
76
+ if (Object.keys(value).some((key) => !["issuer", "api_origin", "client_id", "scope"].includes(key))) throw new CLIError("invalid_arguments", "Supported config keys: issuer, api_origin, client_id, scope");
77
+ const result = {};
78
+ if (value.issuer !== undefined) result.issuer = issuerOrigin(value.issuer);
79
+ if (value.api_origin !== undefined) result.api_origin = issuerOrigin(value.api_origin);
80
+ if (value.client_id !== undefined) {
81
+ if (typeof value.client_id !== "string" || !value.client_id || value.client_id.length > 200 || /[\x00-\x20\x7f]/.test(value.client_id)) {
82
+ throw new CLIError("invalid_arguments", "client_id must be a registered public client ID");
83
+ }
84
+ result.client_id = value.client_id;
85
+ }
86
+ if (value.scope !== undefined) {
87
+ if (typeof value.scope !== "string") throw new CLIError("invalid_arguments", "scope must be a space-separated string");
88
+ result.scope = scopeList(value.scope).join(" ");
89
+ }
90
+ return result;
91
+ }
92
+
93
+ export class SettingsStore extends PrivateJSONStore {
94
+ async read() { const value = await super.read(); return value ? validateSettings(value) : {}; }
95
+ }