@suronai/sdk 0.1.36 → 0.1.38

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,28 +1,20 @@
1
- {
2
- "name": "@suronai/sdk",
3
- "version": "0.1.36",
4
- "description": "App SDK for Suron await vault() to load secrets",
5
- "type": "module",
6
- "main": "./src/index.js",
7
- "types": "./index.d.ts",
8
- "exports": {
9
- ".": {
10
- "types": "./index.d.ts",
11
- "default": "./src/index.js"
12
- }
13
- },
14
- "files": [
15
- "src",
16
- "index.d.ts"
17
- ],
18
- "scripts": {
19
- "build": "node --check src/index.js src/vault.js src/errors.js src/poll.js src/decrypt.js",
20
- "lint": "node --check src/index.js src/vault.js src/errors.js src/poll.js src/decrypt.js"
21
- },
22
- "dependencies": {
23
- "@dotenvx/dotenvx": "latest"
24
- },
25
- "engines": {
26
- "node": ">=18.0.0"
27
- }
28
- }
1
+ {
2
+ "name": "@suronai/sdk",
3
+ "version": "0.1.38",
4
+ "description": "Suron SDK — boot-gated secrets delivery",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "files": [
11
+ "src"
12
+ ],
13
+ "scripts": {
14
+ "test": "node src/index.js"
15
+ },
16
+ "engines": {
17
+ "node": ">=18.0.0"
18
+ },
19
+ "dependencies": {}
20
+ }
package/src/decrypt.js CHANGED
@@ -1,29 +1,14 @@
1
- import { parse as dotenvxParse } from "@dotenvx/dotenvx";
2
- import { readFileSync } from "fs";
3
- import { join } from "path";
4
-
5
- /**
6
- * Decrypts the encrypted .env (in envDir) into process.env using dotenvx.
7
- *
8
- * We use dotenvx.parse(src, { privateKey }) rather than config():
9
- * - parse() is the documented API that accepts privateKey directly
10
- * - It decrypts in-memory without touching process.env or requiring
11
- * DOTENV_PRIVATE_KEY to be set anywhere
12
- * - We then assign the decrypted values into process.env ourselves,
13
- * which is explicit and race-condition-free
14
- *
15
- * @param {string} privateKey - 64-char hex secp256k1 private key from Suron
16
- * @param {string} envDir - Directory containing the encrypted .env file
17
- */
18
- export function decryptEnv(privateKey, envDir = process.cwd()) {
19
- const envPath = join(envDir, ".env");
20
- const src = readFileSync(envPath, "utf-8");
21
- const parsed = dotenvxParse(src, { privateKey });
22
- for (const [key, value] of Object.entries(parsed)) {
23
- // Skip dotenvx's own metadata keys — these are infrastructure, not app secrets.
24
- // DOTENV_PUBLIC_KEY is stored in plaintext in the .env file by dotenvx and
25
- // would otherwise pollute the app's runtime environment.
26
- if (key.startsWith("DOTENV_PUBLIC_KEY") || key.startsWith("DOTENV_PRIVATE_KEY")) continue;
27
- process.env[key] = value;
1
+ export function parseEnv(plaintext) {
2
+ for (const line of plaintext.split("\n")) {
3
+ const trimmed = line.trim();
4
+ if (!trimmed || trimmed.startsWith("#")) continue;
5
+ const eq = trimmed.indexOf("=");
6
+ if (eq === -1) continue;
7
+ const key = trimmed.slice(0, eq).trim();
8
+ let val = trimmed.slice(eq + 1).trim();
9
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
10
+ val = val.slice(1, -1);
11
+ }
12
+ if (key) process.env[key] = val;
28
13
  }
29
14
  }
package/src/vault.js CHANGED
@@ -1,104 +1,74 @@
1
1
  import { existsSync, readFileSync } from "fs";
2
2
  import { join } from "path";
3
3
  import { SuronConfigError, SuronAppNotFoundError, SuronRateLimitError, SuronDeniedError, SuronTimeoutError } from "./errors.js";
4
- import { pollUntilApproved } from "./poll.js";
5
- import { decryptEnv } from "./decrypt.js";
4
+ import { parseEnv } from "./decrypt.js";
6
5
 
7
- /**
8
- * @typedef {Object} VaultOptions
9
- * @property {string} [configPath] - Dir containing .suron.json. Default: process.cwd()
10
- * @property {number} [timeout] - Ms to wait for Telegram approval. Default: 300000
11
- * @property {number} [pollInterval] - Ms between status polls. Default: 3000
12
- */
6
+ const BASE_URL = "https://suronai.com";
13
7
 
14
- /**
15
- * Reads and validates .suron.json from the given directory.
16
- * @param {string} dir
17
- * @returns {{ app: string, id: string, api_url?: string }}
18
- */
19
8
  function readSuronJson(dir) {
20
9
  const path = join(dir, ".suron.json");
21
- if (!existsSync(path)) {
22
- throw new SuronConfigError(`.suron.json not found in ${dir}. Run: suron init`);
23
- }
24
- let raw;
25
- try {
26
- raw = readFileSync(path, "utf-8");
27
- } catch (err) {
28
- throw new SuronConfigError(`.suron.json could not be read: ${err}`);
29
- }
10
+ if (!existsSync(path)) throw new SuronConfigError(`.suron.json not found in ${dir}. Run: suron init`);
30
11
  let parsed;
31
- try {
32
- parsed = JSON.parse(raw);
33
- } catch {
12
+ try { parsed = JSON.parse(readFileSync(path, "utf-8")); } catch {
34
13
  throw new SuronConfigError(".suron.json is malformed JSON");
35
14
  }
36
- const app = typeof parsed.app === "string" ? parsed.app.trim() : "";
37
- const id = typeof parsed.id === "string" ? parsed.id.trim() : "";
38
- if (!app || !id) {
39
- throw new SuronConfigError(".suron.json is missing 'app' or 'id' field");
40
- }
41
- parsed.app = app;
42
- parsed.id = id;
43
- return parsed;
15
+ const app_id = typeof parsed.app_id === "string" ? parsed.app_id.trim() : "";
16
+ const version = typeof parsed.version === "string" ? parsed.version.trim() : "";
17
+ if (!app_id || !version) throw new SuronConfigError(".suron.json is missing 'app_id' or 'version'");
18
+ return { app_id, version, app_name: parsed.app_name };
44
19
  }
45
20
 
46
- /**
47
- * Sends a boot request and returns the request_id.
48
- * @param {string} apiUrl
49
- * @param {string} appId
50
- * @returns {Promise<string>}
51
- */
52
- async function requestAccess(apiUrl, appId) {
21
+ async function requestAccess(app_id) {
53
22
  let res;
54
23
  try {
55
- res = await fetch(`${apiUrl}/request-access`, {
24
+ res = await fetch(`${BASE_URL}/request-access`, {
56
25
  method: "POST",
57
26
  headers: { "Content-Type": "application/json" },
58
- body: JSON.stringify({ app_id: appId }),
27
+ body: JSON.stringify({ app_id }),
59
28
  });
60
29
  } catch (err) {
61
- throw new Error(`Could not reach Suron API (${apiUrl}): ${err.message}`);
30
+ throw new Error(`Could not reach Suron API: ${err.message}`);
62
31
  }
63
-
64
- if (res.status === 404) throw new SuronAppNotFoundError("App not found in Suron. Run: suron init");
32
+ if (res.status === 404) throw new SuronAppNotFoundError("App not found. Run: suron init");
65
33
  if (res.status === 429) throw new SuronRateLimitError("Rate limit exceeded — max 20 boot requests per app per hour.");
66
34
  if (!res.ok) {
67
35
  const text = await res.text().catch(() => "");
68
- throw new Error(`Suron /request-access failed (${res.status}): ${text}`);
36
+ throw new Error(`/request-access failed (${res.status}): ${text}`);
69
37
  }
70
-
71
38
  const { request_id } = await res.json();
72
39
  return request_id;
73
40
  }
74
41
 
75
- /**
76
- * Fetches the private key after approval. Single-use.
77
- * @param {string} apiUrl
78
- * @param {string} requestId
79
- * @returns {Promise<string>}
80
- */
81
- async function fetchKey(apiUrl, requestId) {
82
- const res = await fetch(`${apiUrl}/fetch-key`, {
83
- method: "POST",
84
- headers: { "Content-Type": "application/json" },
85
- body: JSON.stringify({ request_id: requestId }),
42
+ async function pollUntilApproved(request_id, timeout, pollInterval) {
43
+ const deadline = Date.now() + timeout;
44
+ while (Date.now() < deadline) {
45
+ await new Promise(r => setTimeout(r, pollInterval));
46
+ let res;
47
+ try { res = await fetch(`${BASE_URL}/status?request_id=${encodeURIComponent(request_id)}`); } catch { continue; }
48
+ if (!res.ok) continue;
49
+ let status;
50
+ try { ({ status } = await res.json()); } catch { continue; }
51
+ if (status === "approved") return;
52
+ if (status === "denied") throw new SuronDeniedError("Boot denied.");
53
+ }
54
+ throw new SuronTimeoutError(`No approval received within ${timeout / 1000}s.`);
55
+ }
56
+
57
+ async function fetchVersion(app_id, version, request_id) {
58
+ const res = await fetch(`${BASE_URL}/apps/${app_id}/versions/${version}`, {
59
+ headers: {
60
+ "Content-Type": "application/json",
61
+ "X-Request-Id": request_id,
62
+ },
86
63
  });
87
64
  if (!res.ok) {
88
65
  const data = await res.json().catch(() => ({}));
89
- throw new Error(`Suron /fetch-key failed (${res.status}): ${data.error ?? "unknown"}`);
66
+ throw new Error(`/apps/${app_id}/versions/${version} failed (${res.status}): ${data.error ?? "unknown"}`);
90
67
  }
91
- const { private_key } = await res.json();
92
- return private_key;
68
+ const { env_plaintext } = await res.json();
69
+ return env_plaintext;
93
70
  }
94
71
 
95
- /**
96
- * Waits for Telegram approval, then decrypts .env into process.env.
97
- * Runs silently — no stdout output under any circumstances.
98
- * Must be awaited before any code that reads process.env.
99
- * @param {VaultOptions} [options]
100
- * @returns {Promise<void>}
101
- */
102
72
  export async function vault(options = {}) {
103
73
  const {
104
74
  configPath = process.cwd(),
@@ -106,44 +76,22 @@ export async function vault(options = {}) {
106
76
  pollInterval = 3_000,
107
77
  } = options;
108
78
 
109
- const suronJson = readSuronJson(configPath);
110
-
111
- const rawUrl = process.env.SURON_API_URL ?? suronJson.api_url;
112
- if (!rawUrl) throw new SuronConfigError("SURON_API_URL not set. Run: suron login");
113
- const apiUrl = rawUrl.replace(/\/$/, "");
114
-
115
- const requestId = await requestAccess(apiUrl, suronJson.id);
116
-
117
- await pollUntilApproved(apiUrl, requestId, timeout, pollInterval);
118
-
119
- let privateKey = await fetchKey(apiUrl, requestId);
120
- decryptEnv(privateKey, configPath);
121
- // JS strings are immutable — assigning "" does not overwrite heap memory.
122
- // The reference is dropped here so the GC can reclaim it normally.
123
- privateKey = ""; // drop reference
79
+ const { app_id, version } = readSuronJson(configPath);
80
+ const request_id = await requestAccess(app_id);
81
+ await pollUntilApproved(request_id, timeout, pollInterval);
82
+ const env_plaintext = await fetchVersion(app_id, version, request_id);
83
+ parseEnv(env_plaintext);
124
84
  }
125
85
 
126
- /**
127
- * Drop-in safe wrapper around vault().
128
- * Handles all known Suron errors (including SuronRateLimitError) with
129
- * a console.error + process.exit(1). Unknown errors are re-thrown.
130
- *
131
- * Usage:
132
- * import { config } from "@suronai/sdk";
133
- * await config();
134
- *
135
- * @param {VaultOptions} [options]
136
- * @returns {Promise<void>}
137
- */
138
86
  export async function config(options = {}) {
139
87
  try {
140
88
  await vault(options);
141
89
  } catch (err) {
142
- if (err instanceof SuronDeniedError) { console.error("[suron] Boot denied."); process.exit(1); }
143
- if (err instanceof SuronTimeoutError) { console.error("[suron] Approval timed out."); process.exit(1); }
144
- if (err instanceof SuronRateLimitError) { console.error("[suron]", err.message); process.exit(1); }
145
- if (err instanceof SuronConfigError) { console.error("[suron]", err.message); process.exit(1); }
146
- if (err instanceof SuronAppNotFoundError) { console.error("[suron]", err.message); process.exit(1); }
90
+ if (err instanceof SuronDeniedError) { process.stderr.write("[suron] Boot denied.\n"); process.exit(1); }
91
+ if (err instanceof SuronTimeoutError) { process.stderr.write("[suron] Approval timed out.\n"); process.exit(1); }
92
+ if (err instanceof SuronRateLimitError) { process.stderr.write(`[suron] ${err.message}\n`); process.exit(1); }
93
+ if (err instanceof SuronConfigError) { process.stderr.write(`[suron] ${err.message}\n`); process.exit(1); }
94
+ if (err instanceof SuronAppNotFoundError) { process.stderr.write(`[suron] ${err.message}\n`); process.exit(1); }
147
95
  throw err;
148
96
  }
149
97
  }
package/README.md DELETED
@@ -1,131 +0,0 @@
1
- # @suronai/sdk
2
-
3
- Runtime SDK for [Suron](https://suronai.com) — a drop-in replacement for `dotenv` that gates every app boot behind a Telegram approval before secrets are decrypted into `process.env`.
4
-
5
- ## Install
6
-
7
- ```bash
8
- npm install @suronai/sdk
9
- ```
10
-
11
- Requires Node.js 18+. Run [`suron init`](https://www.npmjs.com/package/@suronai/cli) in your project first.
12
-
13
- ---
14
-
15
- ## Usage
16
-
17
- Replace your `dotenv` bootstrap at the very top of your entry point, before any `process.env` access:
18
-
19
- ```js
20
- // before
21
- import { config } from 'dotenv'
22
- config()
23
-
24
- // after
25
- import { config } from '@suronai/sdk'
26
- await config()
27
- ```
28
-
29
- That's it. When your app starts:
30
-
31
- 1. Suron sends a message to your Telegram bot with an **Approve / Deny** button
32
- 2. `await config()` blocks until you tap Approve
33
- 3. Secrets are decrypted into `process.env` — your app continues normally
34
- 4. If you tap Deny, or the timeout expires, the process exits with a clear message
35
-
36
- `suron init` can patch this automatically — it detects `dotenv` in your entry point and offers to replace it interactively.
37
-
38
- ---
39
-
40
- ## Requirements
41
-
42
- - `.suron.json` in the working directory — created by `suron init`
43
- - Encrypted `.env` in the working directory — created by `suron init`
44
- - No extra environment variables needed — the API URL is stored in `.suron.json`
45
-
46
- ---
47
-
48
- ## API
49
-
50
- ### `config(options?)`
51
-
52
- Drop-in safe wrapper. Handles all known Suron errors with `console.error` + `process.exit(1)`. Only unexpected errors are re-thrown.
53
-
54
- ```js
55
- import { config } from '@suronai/sdk'
56
- await config()
57
- ```
58
-
59
- ### `vault(options?)`
60
-
61
- Same as `config()` but throws on all errors, giving you full control over error handling:
62
-
63
- ```js
64
- import { vault, SuronDeniedError, SuronTimeoutError, SuronConfigError, SuronAppNotFoundError } from '@suronai/sdk'
65
-
66
- try {
67
- await vault()
68
- } catch (err) {
69
- if (err instanceof SuronDeniedError) { console.error('[suron] Boot denied.'); process.exit(1) }
70
- if (err instanceof SuronTimeoutError) { console.error('[suron] Approval timed out.'); process.exit(1) }
71
- if (err instanceof SuronConfigError) { console.error('[suron]', err.message); process.exit(1) }
72
- if (err instanceof SuronAppNotFoundError) { console.error('[suron]', err.message); process.exit(1) }
73
- throw err
74
- }
75
- ```
76
-
77
- ### Options
78
-
79
- Both `config()` and `vault()` accept the same options object:
80
-
81
- ```js
82
- await config({
83
- configPath: '/custom/path', // dir containing .suron.json — default: process.cwd()
84
- timeout: 300_000, // ms to wait for Telegram approval — default: 5 minutes
85
- pollInterval: 3_000, // ms between /status polls — default: 3s
86
- })
87
- ```
88
-
89
- ---
90
-
91
- ## Errors
92
-
93
- | Class | Thrown when |
94
- |---|---|
95
- | `SuronConfigError` | `.suron.json` missing, malformed, or `SURON_API_URL` not set |
96
- | `SuronAppNotFoundError` | App not registered — run `suron init` |
97
- | `SuronRateLimitError` | More than 20 boot requests in 1 hour |
98
- | `SuronDeniedError` | You tapped Deny in Telegram |
99
- | `SuronTimeoutError` | No approval received within the timeout |
100
-
101
- `config()` exits the process for all errors except `SuronRateLimitError` and unknown errors, which are re-thrown. `vault()` always throws.
102
-
103
- ---
104
-
105
- ## How it works
106
-
107
- ```
108
- await config()
109
- └─ reads .suron.json # gets app_id and api_url
110
- └─ POST /request-access # triggers Telegram approval message
111
- └─ GET /status (polling) # waits for Approve / Deny tap
112
- └─ POST /fetch-key # retrieves private key (single-use token)
113
- └─ dotenvx.parse(.env, key) # decrypts secrets into process.env
114
- ```
115
-
116
- The private key never touches disk during runtime. It's received in memory, used to decrypt, and the reference is dropped immediately after.
117
-
118
- ---
119
-
120
- ## Security
121
-
122
- - The SDK reads only `.suron.json` for the `app_id` — no access to `~/.suron-config`
123
- - The `MASTER_KEY` lives exclusively inside the Convex deployment — the SDK never sees it
124
- - `/fetch-key` is single-use — the token is consumed on first retrieval
125
- - `DOTENV_PUBLIC_KEY` and `DOTENV_PRIVATE_KEY` are filtered out of `process.env` after decryption
126
-
127
- ---
128
-
129
- ## Related
130
-
131
- - [`@suronai/cli`](https://www.npmjs.com/package/@suronai/cli) — the CLI for setup and key rotation
package/index.d.ts DELETED
@@ -1,37 +0,0 @@
1
- export interface VaultOptions {
2
- /** Directory containing .suron.json. Default: process.cwd() */
3
- configPath?: string;
4
- /** Milliseconds to wait for Telegram approval. Default: 300000 (5 min) */
5
- timeout?: number;
6
- /** Milliseconds between status polls. Default: 3000 */
7
- pollInterval?: number;
8
- }
9
-
10
- /**
11
- * Drop-in replacement for `dotenv`'s config().
12
- *
13
- * Waits for Telegram approval, then decrypts .env into process.env.
14
- * Handles all known Suron errors with console.error + process.exit(1).
15
- * Unknown errors are re-thrown.
16
- *
17
- * Usage:
18
- * import { config } from '@suronai/sdk'
19
- * await config()
20
- *
21
- * Must be awaited before any code that reads process.env.
22
- */
23
- export function config(options?: VaultOptions): Promise<void>;
24
-
25
- /**
26
- * Low-level vault. Waits for Telegram approval, then decrypts .env into process.env.
27
- * Throws on all errors — use config() if you want automatic error handling.
28
- * Must be awaited before any code that reads process.env.
29
- */
30
- export function vault(options?: VaultOptions): Promise<void>;
31
-
32
- export class SuronError extends Error {}
33
- export class SuronConfigError extends SuronError {}
34
- export class SuronAppNotFoundError extends SuronError {}
35
- export class SuronRateLimitError extends SuronError {}
36
- export class SuronDeniedError extends SuronError {}
37
- export class SuronTimeoutError extends SuronError {}
package/src/poll.js DELETED
@@ -1,55 +0,0 @@
1
- import { SuronDeniedError, SuronTimeoutError } from "./errors.js";
2
-
3
- /**
4
- * Polls /status until the request is approved, denied, or times out.
5
- * @param {string} apiUrl
6
- * @param {string} requestId
7
- * @param {number} [timeout]
8
- * @param {number} [pollInterval]
9
- * @returns {Promise<void>}
10
- */
11
- export async function pollUntilApproved(
12
- apiUrl,
13
- requestId,
14
- timeout = 300_000,
15
- pollInterval = 3_000
16
- ) {
17
- const deadline = Date.now() + timeout;
18
-
19
- while (Date.now() < deadline) {
20
- await sleep(pollInterval);
21
-
22
- let res;
23
- try {
24
- res = await fetch(`${apiUrl}/status?request_id=${encodeURIComponent(requestId)}`);
25
- } catch {
26
- // Network hiccup — keep polling
27
- continue;
28
- }
29
-
30
- if (!res.ok) continue; // Transient error — keep polling
31
-
32
- let status;
33
- try {
34
- ({ status } = await res.json());
35
- } catch {
36
- // Malformed JSON — treat as transient and keep polling
37
- continue;
38
- }
39
-
40
- if (status === "approved") return;
41
- if (status === "denied") {
42
- throw new SuronDeniedError("Boot denied — you tapped Deny in Telegram.");
43
- }
44
- // "pending" → keep polling
45
- }
46
-
47
- throw new SuronTimeoutError(
48
- `No Telegram approval received within ${timeout / 1000}s.`
49
- );
50
- }
51
-
52
- /** @param {number} ms @returns {Promise<void>} */
53
- function sleep(ms) {
54
- return new Promise((resolve) => setTimeout(resolve, ms));
55
- }