envis-node 0.0.1

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 ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "envis-node",
3
+ "version": "0.0.1",
4
+ "description": "Secure, simple secret management",
5
+ "keywords": [
6
+ "secret",
7
+ "manager",
8
+ "encryption",
9
+ "secrets"
10
+ ],
11
+ "license": "ISC",
12
+ "author": "Umair Arham",
13
+ "type": "commonjs",
14
+ "main": "src/index.js",
15
+ "scripts": {
16
+ "test": "echo \"Error: no test specified\" && exit 1"
17
+ },
18
+ "dependencies": {
19
+ "chalk": "^4.1.2",
20
+ "node-fetch": "^2.7.0",
21
+ "open": "^8.4.2"
22
+ }
23
+ }
package/src/auth.js ADDED
@@ -0,0 +1,122 @@
1
+ const fetch = require("node-fetch");
2
+
3
+ const {
4
+ BASE_URL,
5
+ WAIT_TIME,
6
+ POLL_DELAY_SECONDS,
7
+ writeSession,
8
+ } = require("./session");
9
+
10
+ const SECOND = 1000;
11
+
12
+ function sleep(seconds) {
13
+ return new Promise((resolve) => setTimeout(resolve, seconds * SECOND));
14
+ }
15
+
16
+ function parseRetryHeader(retryHeader) {
17
+ if (!retryHeader) {
18
+ return POLL_DELAY_SECONDS;
19
+ }
20
+ const parsed = Number(retryHeader);
21
+ if (!Number.isFinite(parsed) || parsed <= 0) {
22
+ return POLL_DELAY_SECONDS;
23
+ }
24
+ return Math.max(1, Math.floor(parsed));
25
+ }
26
+
27
+ async function waitForAuth(deviceId) {
28
+
29
+ const url = `${BASE_URL}/v1/auth/${deviceId}`;
30
+ const deadline = Date.now() + WAIT_TIME * SECOND;
31
+
32
+ while (Date.now() < deadline) {
33
+ let response;
34
+ try {
35
+ response = await fetch(url, { timeout: 10000 });
36
+ } catch {
37
+ await sleep(POLL_DELAY_SECONDS);
38
+ continue;
39
+ }
40
+
41
+ let bodyText = "";
42
+ try {
43
+ bodyText = await response.text();
44
+ } catch {
45
+ bodyText = "";
46
+ }
47
+
48
+ let payload = null;
49
+ if (bodyText) {
50
+ try {
51
+ payload = JSON.parse(bodyText);
52
+ } catch (error) {
53
+ if (response.status === 202) {
54
+ await sleep(parseRetryHeader(response.headers.get("retry-after")));
55
+ continue;
56
+ }
57
+ const preview =
58
+ bodyText.trim().slice(0, 200) || "<empty body>";
59
+ throw new Error(
60
+ `Auth endpoint returned invalid JSON (status ${response.status}): ${preview}`
61
+ );
62
+ }
63
+ }
64
+
65
+ if (
66
+ response.status === 200 &&
67
+ payload &&
68
+ typeof payload === "object" &&
69
+ payload.is_auth
70
+ ) {
71
+ const sessionBlob = payload.content;
72
+ if (!sessionBlob) {
73
+ throw new Error(
74
+ "Auth endpoint returned an empty session payload."
75
+ );
76
+ }
77
+
78
+ let session;
79
+ if (
80
+ typeof sessionBlob === "string" ||
81
+ sessionBlob instanceof String
82
+ ) {
83
+ try {
84
+ session = JSON.parse(sessionBlob);
85
+ } catch (error) {
86
+ throw new Error(
87
+ "Auth endpoint returned malformed session payload."
88
+ );
89
+ }
90
+ } else if (typeof sessionBlob === "object") {
91
+ session = sessionBlob;
92
+ } else {
93
+ throw new Error(
94
+ "Auth endpoint returned an unsupported session payload."
95
+ );
96
+ }
97
+
98
+ await writeSession(session);
99
+ return session;
100
+ }
101
+
102
+ if (response.status === 202) {
103
+ await sleep(parseRetryHeader(response.headers.get("retry-after")));
104
+ continue;
105
+ }
106
+
107
+ const detail =
108
+ payload && typeof payload === "object"
109
+ ? payload.detail || bodyText || "<empty body>"
110
+ : bodyText || "<empty body>";
111
+
112
+ throw new Error(
113
+ `Auth endpoint failed (${response.status}): ${detail}`
114
+ );
115
+ }
116
+
117
+ throw new Error("Timed out waiting for device approval. Please try again.");
118
+ }
119
+
120
+ module.exports = {
121
+ waitForAuth,
122
+ };
package/src/index.js ADDED
@@ -0,0 +1,23 @@
1
+ const { get, logout } = require("./main");
2
+ const {
3
+ ensureSession,
4
+ loadSession,
5
+ refreshSession,
6
+ SESSION_PATH,
7
+ BASE_URL,
8
+ FRONTEND_URL,
9
+ } = require("./session");
10
+
11
+ const envis = {
12
+ get,
13
+ logout,
14
+ ensureSession,
15
+ loadSession,
16
+ refreshSession,
17
+ SESSION_PATH,
18
+ BASE_URL,
19
+ FRONTEND_URL,
20
+ };
21
+
22
+ module.exports = envis;
23
+ module.exports.default = envis;
package/src/main.js ADDED
@@ -0,0 +1,106 @@
1
+ const fs = require("fs");
2
+ const { URL } = require("url");
3
+
4
+ const fetch = require("node-fetch");
5
+
6
+ const {
7
+ ensureSession,
8
+ loadSession,
9
+ printc,
10
+ SESSION_PATH,
11
+ BASE_URL,
12
+ } = require("./session");
13
+
14
+ async function logout() {
15
+ try {
16
+ await fs.promises.access(SESSION_PATH, fs.constants.F_OK);
17
+ } catch {
18
+ throw new Error(
19
+ "Already logged out. Please authenticate again."
20
+ );
21
+ }
22
+
23
+ try {
24
+ await fs.promises.unlink(SESSION_PATH);
25
+ printc("Successfully logged out!", "green");
26
+ } catch (error) {
27
+ throw new Error(`Error logging out: ${error.message}`);
28
+ }
29
+ }
30
+
31
+ async function get(projectId, secretName) {
32
+ if (!projectId || !secretName) {
33
+ throw new Error("Both project id and secret name are required.");
34
+ }
35
+
36
+ await ensureSession();
37
+ const session = await loadSession();
38
+
39
+ const headers = {
40
+ Authorization: `Bearer ${session.access_token}`,
41
+ "Content-Type": "application/json",
42
+ };
43
+
44
+ const url = `${BASE_URL}/v1/projects/${encodeURIComponent(
45
+ projectId
46
+ )}/secrets/${encodeURIComponent(secretName)}`;
47
+
48
+ let response;
49
+ try {
50
+ response = await fetch(url, { headers, timeout: 10000 });
51
+ } catch (error) {
52
+ throw new Error(`Failed to reach Envault API: ${error.message}`);
53
+ }
54
+
55
+ if (!response.ok) {
56
+ let detail;
57
+ try {
58
+ detail = (await response.text()).trim();
59
+ } catch (error) {
60
+ detail = error.message;
61
+ }
62
+ throw new Error(
63
+ `Failed to fetch secret (${response.status || "HTTP error"}): ${
64
+ detail || "No response body provided."
65
+ }`
66
+ );
67
+ }
68
+
69
+ let bodyText = "";
70
+ try {
71
+ bodyText = await response.text();
72
+ } catch {
73
+ bodyText = "";
74
+ }
75
+
76
+ if (!bodyText) {
77
+ throw new Error(
78
+ "API returned an empty, non-JSON response when fetching secret."
79
+ );
80
+ }
81
+
82
+ try {
83
+ const payload = JSON.parse(bodyText);
84
+ if (
85
+ payload &&
86
+ typeof payload === "object" &&
87
+ Object.prototype.hasOwnProperty.call(payload, "value")
88
+ ) {
89
+ return payload.value;
90
+ }
91
+ return payload;
92
+ } catch {
93
+ const raw = bodyText.trim();
94
+ if (raw) {
95
+ return { raw };
96
+ }
97
+ throw new Error(
98
+ "API returned an empty, non-JSON response when fetching secret."
99
+ );
100
+ }
101
+ }
102
+
103
+ module.exports = {
104
+ get,
105
+ logout,
106
+ };
package/src/session.js ADDED
@@ -0,0 +1,258 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const os = require("os");
4
+ const crypto = require("crypto");
5
+
6
+ const chalk = require("chalk");
7
+ const fetch = require("node-fetch");
8
+
9
+ const SESSION_PATH = path.join(os.homedir(), ".envis", "session.json");
10
+ const REQUIRED_SESSION_KEYS = ["access_token", "refresh_token"];
11
+ const WAIT_TIME = 120; // seconds
12
+ const POLL_DELAY_SECONDS = 5;
13
+
14
+ function printc(text, color = "white") {
15
+ const colorFn = chalk[color];
16
+ if (typeof colorFn === "function") {
17
+ console.log(colorFn(text));
18
+ return;
19
+ }
20
+ console.log(text);
21
+ }
22
+
23
+ function getEnvUrl(varName, fallback) {
24
+ const raw = process.env[varName];
25
+ if (!raw) {
26
+ return fallback;
27
+ }
28
+ return raw.replace(/\/+$/, "");
29
+ }
30
+
31
+ const BASE_URL = getEnvUrl(
32
+ "ENVIS_API_URL",
33
+ "https://envis.onrender.com"
34
+ );
35
+
36
+ const FRONTEND_URL = getEnvUrl(
37
+ "ENVIS_DASH_URL",
38
+ "https://envisible.netlify.app"
39
+ );
40
+
41
+ async function pathExists(targetPath) {
42
+ try {
43
+ await fs.promises.access(targetPath, fs.constants.F_OK);
44
+ return true;
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ function shouldRefresh(session) {
51
+ const expiresAt = session?.expires_at;
52
+ if (!expiresAt) {
53
+ return false;
54
+ }
55
+ const expiryTs = Number(expiresAt);
56
+ if (!Number.isFinite(expiryTs)) {
57
+ return false;
58
+ }
59
+ const nowSeconds = Math.floor(Date.now() / 1000);
60
+ return nowSeconds >= expiryTs - 60;
61
+ }
62
+
63
+ async function invalidateSessionCache() {
64
+ try {
65
+ await fs.promises.unlink(SESSION_PATH);
66
+ } catch (error) {
67
+ if (error && error.code === "ENOENT") {
68
+ return;
69
+ }
70
+ throw new Error(
71
+ `Failed to delete invalid session cache: ${error?.message || error}`
72
+ );
73
+ }
74
+ }
75
+
76
+ function validateSessionPayload(session) {
77
+ if (typeof session !== "object" || session === null) {
78
+ throw new Error("Session payload must be an object.");
79
+ }
80
+ const missing = REQUIRED_SESSION_KEYS.filter(
81
+ (key) => !session[key]
82
+ );
83
+ if (missing.length) {
84
+ throw new Error(
85
+ `Session payload missing required field(s): ${missing.join(", ")}.`
86
+ );
87
+ }
88
+ }
89
+
90
+ async function writeSession(sessionInfo) {
91
+ validateSessionPayload(sessionInfo);
92
+
93
+ let payload;
94
+ try {
95
+ payload = JSON.stringify(sessionInfo, null, 2);
96
+ } catch (error) {
97
+ throw new Error("Session payload is not JSON serializable.");
98
+ }
99
+
100
+ try {
101
+ await fs.promises.mkdir(path.dirname(SESSION_PATH), {
102
+ recursive: true,
103
+ });
104
+ await fs.promises.writeFile(SESSION_PATH, payload, {
105
+ encoding: "utf-8",
106
+ });
107
+ await fs.promises.chmod(SESSION_PATH, 0o600);
108
+ } catch (error) {
109
+ throw new Error(`Failed to write session cache: ${error.message}`);
110
+ }
111
+ }
112
+
113
+ async function refreshSession(session) {
114
+ const refreshToken = session?.refresh_token;
115
+ if (!refreshToken) {
116
+ throw new Error(
117
+ "Session expired and no refresh token is available. Re-run `envis login`."
118
+ );
119
+ }
120
+
121
+ const url = `${BASE_URL}/v1/auth/refresh`;
122
+ let response;
123
+ try {
124
+ response = await fetch(url, {
125
+ method: "POST",
126
+ headers: { "Content-Type": "application/json" },
127
+ body: JSON.stringify({ refresh_token: refreshToken }),
128
+ timeout: 10000,
129
+ });
130
+ } catch (error) {
131
+ throw new Error(
132
+ `Failed to reach Envisible API for session refresh: ${error.message}`
133
+ );
134
+ }
135
+
136
+ if (!response.ok) {
137
+ let detail = "";
138
+ try {
139
+ detail = await response.text();
140
+ } catch (error) {
141
+ detail = error.message;
142
+ }
143
+ throw new Error(
144
+ `Failed to refresh session (${response.status || "HTTP error"}): ${detail}`
145
+ );
146
+ }
147
+
148
+ let newSession;
149
+ try {
150
+ newSession = await response.json();
151
+ } catch (error) {
152
+ throw new Error("Refresh endpoint returned invalid JSON.");
153
+ }
154
+
155
+ await writeSession(newSession);
156
+ return newSession;
157
+ }
158
+
159
+ async function loadSession() {
160
+ if (!(await pathExists(SESSION_PATH))) {
161
+ const deviceCode = crypto.randomUUID();
162
+ throw new Error(
163
+ `Not authenticated. Visit ${FRONTEND_URL}/auth?device_code=${deviceCode} to link this device.`
164
+ );
165
+ }
166
+
167
+ let raw;
168
+ try {
169
+ raw = await fs.promises.readFile(SESSION_PATH, {
170
+ encoding: "utf-8",
171
+ });
172
+ } catch (error) {
173
+ throw new Error("Unable to read session file. Re-run `envis login`.");
174
+ }
175
+
176
+ let session;
177
+ try {
178
+ session = JSON.parse(raw);
179
+ } catch (error) {
180
+ throw new Error("Session file is corrupt. Re-run `envis login`.");
181
+ }
182
+
183
+ let hydrated = session;
184
+ const needsRefresh =
185
+ shouldRefresh(session) ||
186
+ (!session.access_token && session.refresh_token);
187
+
188
+ if (needsRefresh) {
189
+ hydrated = await refreshSession(session);
190
+ }
191
+
192
+ try {
193
+ validateSessionPayload(hydrated);
194
+ } catch (error) {
195
+ await invalidateSessionCache();
196
+ throw new Error(
197
+ `Session cache invalid even after attempting refresh: ${error.message}`
198
+ );
199
+ }
200
+
201
+ return hydrated;
202
+ }
203
+
204
+ async function ensureSession() {
205
+ if (await pathExists(SESSION_PATH)) {
206
+ return;
207
+ }
208
+
209
+ const { waitForAuth } = require("./auth");
210
+ let openBrowser;
211
+ try {
212
+ openBrowser = require("open");
213
+ } catch {
214
+ openBrowser = null;
215
+ }
216
+
217
+ const deviceCode = crypto.randomUUID();
218
+ const authUrl = `${FRONTEND_URL}/auth?device_code=${deviceCode}`;
219
+
220
+ printc("No cached session detected.", "red");
221
+
222
+ if (openBrowser) {
223
+ try {
224
+ await openBrowser(authUrl, { wait: false });
225
+ console.log("\nTrying to open:");
226
+ printc(` ${authUrl}`, "blue");
227
+ } catch {
228
+ printc(
229
+ `Could not open browser automatically – open ${authUrl}`,
230
+ "red"
231
+ );
232
+ }
233
+ } else {
234
+ printc(
235
+ `Open the following URL in your browser to link this device:\n${authUrl}`,
236
+ "blue"
237
+ );
238
+ }
239
+
240
+ console.log("\nWaiting for the session to be approved...");
241
+ await waitForAuth(deviceCode);
242
+ printc("\nDevice approved and session saved locally.", "green");
243
+ }
244
+
245
+ module.exports = {
246
+ BASE_URL,
247
+ FRONTEND_URL,
248
+ WAIT_TIME,
249
+ POLL_DELAY_SECONDS,
250
+ SESSION_PATH,
251
+ REQUIRED_SESSION_KEYS,
252
+ printc,
253
+ loadSession,
254
+ writeSession,
255
+ ensureSession,
256
+ refreshSession,
257
+ invalidateSessionCache,
258
+ };