@dotlabshq/orbseal-sdk 0.1.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/dist/index.cjs +267 -0
- package/dist/index.d.cts +137 -0
- package/dist/index.d.ts +137 -0
- package/dist/index.js +229 -0
- package/package.json +43 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
ConfigClient: () => ConfigClient,
|
|
34
|
+
ResolvedConfig: () => ResolvedConfig
|
|
35
|
+
});
|
|
36
|
+
module.exports = __toCommonJS(index_exports);
|
|
37
|
+
|
|
38
|
+
// src/decrypt.ts
|
|
39
|
+
var B58_ALPHA = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
40
|
+
function b58dec(s) {
|
|
41
|
+
let n = 0n;
|
|
42
|
+
for (const ch of s) {
|
|
43
|
+
const i = B58_ALPHA.indexOf(ch);
|
|
44
|
+
if (i < 0) throw new Error(`Invalid base58 character: ${ch}`);
|
|
45
|
+
n = n * 58n + BigInt(i);
|
|
46
|
+
}
|
|
47
|
+
const hex = n.toString(16);
|
|
48
|
+
const buf = Buffer.from(hex.length % 2 ? "0" + hex : hex, "hex");
|
|
49
|
+
return new Uint8Array(buf);
|
|
50
|
+
}
|
|
51
|
+
function parsePrivateKey(raw) {
|
|
52
|
+
if (raw.startsWith("orbsk-") || raw.startsWith("orb-sk-")) {
|
|
53
|
+
const dash = raw.indexOf("-", raw.indexOf("-") + 1);
|
|
54
|
+
return b58dec(raw.slice(dash + 1));
|
|
55
|
+
}
|
|
56
|
+
const buf = Buffer.from(raw, "base64");
|
|
57
|
+
return new Uint8Array(buf);
|
|
58
|
+
}
|
|
59
|
+
async function derivePublicKey(sk) {
|
|
60
|
+
const sodium = await getSodium();
|
|
61
|
+
return sodium.crypto_scalarmult_base(sk);
|
|
62
|
+
}
|
|
63
|
+
async function decryptSealed(ciphertext, privateKeyRaw) {
|
|
64
|
+
const sodium = await getSodium();
|
|
65
|
+
const sk = parsePrivateKey(privateKeyRaw);
|
|
66
|
+
const pk = await derivePublicKey(sk);
|
|
67
|
+
const ct = sodium.from_base64(ciphertext, sodium.base64_variants.ORIGINAL);
|
|
68
|
+
const plain = sodium.crypto_box_seal_open(ct, pk, sk);
|
|
69
|
+
return sodium.to_string(plain);
|
|
70
|
+
}
|
|
71
|
+
var _sodium = null;
|
|
72
|
+
async function getSodium() {
|
|
73
|
+
if (_sodium) return _sodium;
|
|
74
|
+
try {
|
|
75
|
+
const mod = await import("libsodium-wrappers");
|
|
76
|
+
const sodium = mod.default ?? mod;
|
|
77
|
+
await sodium.ready;
|
|
78
|
+
_sodium = sodium;
|
|
79
|
+
return _sodium;
|
|
80
|
+
} catch {
|
|
81
|
+
throw new Error(
|
|
82
|
+
"[orbseal-sdk] libsodium-wrappers is required to decrypt secrets.\nInstall it: npm install libsodium-wrappers"
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// src/config.ts
|
|
88
|
+
var ResolvedConfig = class {
|
|
89
|
+
/** ETag of this snapshot — use for conditional re-resolve. */
|
|
90
|
+
etag;
|
|
91
|
+
/** ISO timestamp of when this snapshot was resolved. */
|
|
92
|
+
resolvedAt;
|
|
93
|
+
#config;
|
|
94
|
+
#secrets;
|
|
95
|
+
// still ciphertext
|
|
96
|
+
#privateKey;
|
|
97
|
+
#decrypted = /* @__PURE__ */ new Map();
|
|
98
|
+
constructor(raw, privateKey) {
|
|
99
|
+
this.#config = raw.config;
|
|
100
|
+
this.#secrets = raw.secrets;
|
|
101
|
+
this.#privateKey = privateKey;
|
|
102
|
+
this.etag = raw.etag;
|
|
103
|
+
this.resolvedAt = raw.resolved_at;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Get a typed config value.
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* const url = config.get<string>('api_url');
|
|
110
|
+
* const retries = config.get<number>('max_retries');
|
|
111
|
+
* const queue = config.get<'default'|'priority'|'batch'>('queue_name');
|
|
112
|
+
*/
|
|
113
|
+
get(key) {
|
|
114
|
+
if (!(key in this.#config)) {
|
|
115
|
+
throw new Error(`[orbseal] config key not found: "${key}"`);
|
|
116
|
+
}
|
|
117
|
+
return this.#config[key];
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Get a config value or `undefined` if not set.
|
|
121
|
+
*/
|
|
122
|
+
getOrNull(key) {
|
|
123
|
+
return this.#config[key];
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Decrypt and return a secret value.
|
|
127
|
+
* Result is cached — repeated calls are free.
|
|
128
|
+
*
|
|
129
|
+
* Requires `privateKey` to be set in `OrbOptions`.
|
|
130
|
+
*
|
|
131
|
+
* @example
|
|
132
|
+
* const webhookSecret = await config.getSecret('webhook_secret');
|
|
133
|
+
*/
|
|
134
|
+
async getSecret(key) {
|
|
135
|
+
if (this.#decrypted.has(key)) {
|
|
136
|
+
return this.#decrypted.get(key);
|
|
137
|
+
}
|
|
138
|
+
if (!(key in this.#secrets)) {
|
|
139
|
+
throw new Error(`[orbseal] secret not found: "${key}"`);
|
|
140
|
+
}
|
|
141
|
+
if (!this.#privateKey) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`[orbseal] privateKey is required to decrypt secrets.
|
|
144
|
+
Set it in ConfigClient options: new ConfigClient({ ..., privateKey: process.env.ORBSEAL_PRIVATE_KEY })`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
const plain = await decryptSealed(this.#secrets[key], this.#privateKey);
|
|
148
|
+
this.#decrypted.set(key, plain);
|
|
149
|
+
return plain;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Returns true if the key exists as a plain config value.
|
|
153
|
+
*/
|
|
154
|
+
has(key) {
|
|
155
|
+
return key in this.#config;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Returns true if the key exists as a secret.
|
|
159
|
+
*/
|
|
160
|
+
hasSecret(key) {
|
|
161
|
+
return key in this.#secrets;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* All plain config keys available in this snapshot.
|
|
165
|
+
*/
|
|
166
|
+
keys() {
|
|
167
|
+
return Object.keys(this.#config);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* All secret keys available in this snapshot (ciphertext, not decrypted).
|
|
171
|
+
*/
|
|
172
|
+
secretKeys() {
|
|
173
|
+
return Object.keys(this.#secrets);
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// src/client.ts
|
|
178
|
+
var DEFAULT_API_BASE = "https://api.orbseal.com";
|
|
179
|
+
var ConfigClient = class {
|
|
180
|
+
#apiKey;
|
|
181
|
+
#project;
|
|
182
|
+
#environment;
|
|
183
|
+
#privateKey;
|
|
184
|
+
#apiBase;
|
|
185
|
+
// ─── ETag cache ─────────────────────────────────────────────────────────────
|
|
186
|
+
#cached = null;
|
|
187
|
+
#cachedFor = null;
|
|
188
|
+
// "<userId|''>:<etag>"
|
|
189
|
+
constructor(opts) {
|
|
190
|
+
if (!opts.apiKey) throw new Error("[orbseal] apiKey is required");
|
|
191
|
+
if (!opts.project) throw new Error("[orbseal] project is required");
|
|
192
|
+
if (!opts.environment) throw new Error("[orbseal] environment is required");
|
|
193
|
+
this.#apiKey = opts.apiKey;
|
|
194
|
+
this.#project = opts.project;
|
|
195
|
+
this.#environment = opts.environment;
|
|
196
|
+
this.#privateKey = opts.privateKey;
|
|
197
|
+
this.#apiBase = (opts.apiBase ?? DEFAULT_API_BASE).replace(/\/+$/, "");
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Resolve the current config snapshot from the edge.
|
|
201
|
+
*
|
|
202
|
+
* - Pass `userId` to include user-scoped overrides.
|
|
203
|
+
* - Pass `etag` from a previous resolve to get a cached response when nothing changed.
|
|
204
|
+
* Returns the same `ResolvedConfig` instance when the server confirms the etag is current.
|
|
205
|
+
*
|
|
206
|
+
* @example
|
|
207
|
+
* // Simple — always fetches fresh
|
|
208
|
+
* const config = await orb.resolve();
|
|
209
|
+
*
|
|
210
|
+
* // With user overrides
|
|
211
|
+
* const config = await orb.resolve({ userId: 'u_abc123' });
|
|
212
|
+
*
|
|
213
|
+
* // With etag caching (returns cached instance if unchanged)
|
|
214
|
+
* const config = await orb.resolve({ etag: prev?.etag });
|
|
215
|
+
*/
|
|
216
|
+
async resolve(opts = {}) {
|
|
217
|
+
const { userId, etag } = opts;
|
|
218
|
+
const cacheKey = `${userId ?? ""}:${etag ?? ""}`;
|
|
219
|
+
if (etag && this.#cached && this.#cachedFor === cacheKey) {
|
|
220
|
+
return this.#cached;
|
|
221
|
+
}
|
|
222
|
+
const url = this.#buildUrl(userId);
|
|
223
|
+
const headers = {
|
|
224
|
+
"Authorization": `Bearer ${this.#apiKey}`,
|
|
225
|
+
"Content-Type": "application/json"
|
|
226
|
+
};
|
|
227
|
+
if (etag) headers["If-None-Match"] = etag;
|
|
228
|
+
const res = await fetch(url, { headers });
|
|
229
|
+
if (res.status === 304 && this.#cached) {
|
|
230
|
+
return this.#cached;
|
|
231
|
+
}
|
|
232
|
+
if (!res.ok) {
|
|
233
|
+
let message = `HTTP ${res.status}`;
|
|
234
|
+
try {
|
|
235
|
+
const body = await res.json();
|
|
236
|
+
message = body.error ?? body.code ?? message;
|
|
237
|
+
} catch {
|
|
238
|
+
}
|
|
239
|
+
throw new Error(`[orbseal] resolve failed: ${message}`);
|
|
240
|
+
}
|
|
241
|
+
const raw = await res.json();
|
|
242
|
+
const resolved = new ResolvedConfig(raw, this.#privateKey);
|
|
243
|
+
this.#cached = resolved;
|
|
244
|
+
this.#cachedFor = `${userId ?? ""}:${resolved.etag}`;
|
|
245
|
+
return resolved;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Project + environment the client is scoped to.
|
|
249
|
+
*/
|
|
250
|
+
get scope() {
|
|
251
|
+
return { project: this.#project, environment: this.#environment };
|
|
252
|
+
}
|
|
253
|
+
// ─── Internal ────────────────────────────────────────────────────────────────
|
|
254
|
+
#buildUrl(userId) {
|
|
255
|
+
const params = new URLSearchParams({
|
|
256
|
+
project: this.#project,
|
|
257
|
+
environment: this.#environment
|
|
258
|
+
});
|
|
259
|
+
if (userId) params.set("user", userId);
|
|
260
|
+
return `${this.#apiBase}/v1/config/resolve?${params}`;
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
264
|
+
0 && (module.exports = {
|
|
265
|
+
ConfigClient,
|
|
266
|
+
ResolvedConfig
|
|
267
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
interface OrbOptions {
|
|
2
|
+
/** App key (orb_live_... or orb_admin_...) */
|
|
3
|
+
apiKey: string;
|
|
4
|
+
/** Project slug as defined in orb.yaml */
|
|
5
|
+
project: string;
|
|
6
|
+
/** Environment slug (e.g. "production", "staging") */
|
|
7
|
+
environment: string;
|
|
8
|
+
/**
|
|
9
|
+
* Private key for decrypting sealed secrets.
|
|
10
|
+
* Accepts orbsk-<base58> format (from `orbseal keygen`) or raw base64.
|
|
11
|
+
* Required only if you call `config.getSecret(...)`.
|
|
12
|
+
*/
|
|
13
|
+
privateKey?: string;
|
|
14
|
+
/** Override the API base URL. Default: https://api.orbseal.com */
|
|
15
|
+
apiBase?: string;
|
|
16
|
+
}
|
|
17
|
+
interface ResolveOptions {
|
|
18
|
+
/** End-user ID for user-scoped overrides. */
|
|
19
|
+
userId?: string;
|
|
20
|
+
/**
|
|
21
|
+
* ETag from a previous resolve call.
|
|
22
|
+
* If the server's etag matches, returns the cached config unchanged.
|
|
23
|
+
*/
|
|
24
|
+
etag?: string | null;
|
|
25
|
+
}
|
|
26
|
+
interface RawResolveResponse {
|
|
27
|
+
config: Record<string, unknown>;
|
|
28
|
+
secrets: Record<string, string>;
|
|
29
|
+
resolved_at: string;
|
|
30
|
+
etag: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolved config snapshot returned by `orb.resolve()`.
|
|
35
|
+
*
|
|
36
|
+
* Config values are immediately available via `.get()`.
|
|
37
|
+
* Secrets are decrypted lazily on the first `.getSecret()` call —
|
|
38
|
+
* decryption happens in your runtime; the plaintext never leaves your app.
|
|
39
|
+
*/
|
|
40
|
+
declare class ResolvedConfig {
|
|
41
|
+
#private;
|
|
42
|
+
/** ETag of this snapshot — use for conditional re-resolve. */
|
|
43
|
+
readonly etag: string;
|
|
44
|
+
/** ISO timestamp of when this snapshot was resolved. */
|
|
45
|
+
readonly resolvedAt: string;
|
|
46
|
+
constructor(raw: RawResolveResponse, privateKey?: string);
|
|
47
|
+
/**
|
|
48
|
+
* Get a typed config value.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* const url = config.get<string>('api_url');
|
|
52
|
+
* const retries = config.get<number>('max_retries');
|
|
53
|
+
* const queue = config.get<'default'|'priority'|'batch'>('queue_name');
|
|
54
|
+
*/
|
|
55
|
+
get<T = unknown>(key: string): T;
|
|
56
|
+
/**
|
|
57
|
+
* Get a config value or `undefined` if not set.
|
|
58
|
+
*/
|
|
59
|
+
getOrNull<T = unknown>(key: string): T | undefined;
|
|
60
|
+
/**
|
|
61
|
+
* Decrypt and return a secret value.
|
|
62
|
+
* Result is cached — repeated calls are free.
|
|
63
|
+
*
|
|
64
|
+
* Requires `privateKey` to be set in `OrbOptions`.
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* const webhookSecret = await config.getSecret('webhook_secret');
|
|
68
|
+
*/
|
|
69
|
+
getSecret(key: string): Promise<string>;
|
|
70
|
+
/**
|
|
71
|
+
* Returns true if the key exists as a plain config value.
|
|
72
|
+
*/
|
|
73
|
+
has(key: string): boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Returns true if the key exists as a secret.
|
|
76
|
+
*/
|
|
77
|
+
hasSecret(key: string): boolean;
|
|
78
|
+
/**
|
|
79
|
+
* All plain config keys available in this snapshot.
|
|
80
|
+
*/
|
|
81
|
+
keys(): string[];
|
|
82
|
+
/**
|
|
83
|
+
* All secret keys available in this snapshot (ciphertext, not decrypted).
|
|
84
|
+
*/
|
|
85
|
+
secretKeys(): string[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* OrbSeal config client.
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* ```ts
|
|
93
|
+
* import { ConfigClient } from '@dotlabshq/orbseal-sdk';
|
|
94
|
+
*
|
|
95
|
+
* const orb = new ConfigClient({
|
|
96
|
+
* apiKey: process.env.ORBSEAL_API_KEY!,
|
|
97
|
+
* project: 'taskflow',
|
|
98
|
+
* environment: 'production',
|
|
99
|
+
* privateKey: process.env.ORBSEAL_PRIVATE_KEY, // needed for secrets
|
|
100
|
+
* });
|
|
101
|
+
*
|
|
102
|
+
* const config = await orb.resolve();
|
|
103
|
+
* const apiUrl = config.get<string>('api_url');
|
|
104
|
+
* const secret = await config.getSecret('webhook_secret');
|
|
105
|
+
* ```
|
|
106
|
+
*/
|
|
107
|
+
declare class ConfigClient {
|
|
108
|
+
#private;
|
|
109
|
+
constructor(opts: OrbOptions);
|
|
110
|
+
/**
|
|
111
|
+
* Resolve the current config snapshot from the edge.
|
|
112
|
+
*
|
|
113
|
+
* - Pass `userId` to include user-scoped overrides.
|
|
114
|
+
* - Pass `etag` from a previous resolve to get a cached response when nothing changed.
|
|
115
|
+
* Returns the same `ResolvedConfig` instance when the server confirms the etag is current.
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* // Simple — always fetches fresh
|
|
119
|
+
* const config = await orb.resolve();
|
|
120
|
+
*
|
|
121
|
+
* // With user overrides
|
|
122
|
+
* const config = await orb.resolve({ userId: 'u_abc123' });
|
|
123
|
+
*
|
|
124
|
+
* // With etag caching (returns cached instance if unchanged)
|
|
125
|
+
* const config = await orb.resolve({ etag: prev?.etag });
|
|
126
|
+
*/
|
|
127
|
+
resolve(opts?: ResolveOptions): Promise<ResolvedConfig>;
|
|
128
|
+
/**
|
|
129
|
+
* Project + environment the client is scoped to.
|
|
130
|
+
*/
|
|
131
|
+
get scope(): {
|
|
132
|
+
project: string;
|
|
133
|
+
environment: string;
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export { ConfigClient, type OrbOptions, type RawResolveResponse, type ResolveOptions, ResolvedConfig };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
interface OrbOptions {
|
|
2
|
+
/** App key (orb_live_... or orb_admin_...) */
|
|
3
|
+
apiKey: string;
|
|
4
|
+
/** Project slug as defined in orb.yaml */
|
|
5
|
+
project: string;
|
|
6
|
+
/** Environment slug (e.g. "production", "staging") */
|
|
7
|
+
environment: string;
|
|
8
|
+
/**
|
|
9
|
+
* Private key for decrypting sealed secrets.
|
|
10
|
+
* Accepts orbsk-<base58> format (from `orbseal keygen`) or raw base64.
|
|
11
|
+
* Required only if you call `config.getSecret(...)`.
|
|
12
|
+
*/
|
|
13
|
+
privateKey?: string;
|
|
14
|
+
/** Override the API base URL. Default: https://api.orbseal.com */
|
|
15
|
+
apiBase?: string;
|
|
16
|
+
}
|
|
17
|
+
interface ResolveOptions {
|
|
18
|
+
/** End-user ID for user-scoped overrides. */
|
|
19
|
+
userId?: string;
|
|
20
|
+
/**
|
|
21
|
+
* ETag from a previous resolve call.
|
|
22
|
+
* If the server's etag matches, returns the cached config unchanged.
|
|
23
|
+
*/
|
|
24
|
+
etag?: string | null;
|
|
25
|
+
}
|
|
26
|
+
interface RawResolveResponse {
|
|
27
|
+
config: Record<string, unknown>;
|
|
28
|
+
secrets: Record<string, string>;
|
|
29
|
+
resolved_at: string;
|
|
30
|
+
etag: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolved config snapshot returned by `orb.resolve()`.
|
|
35
|
+
*
|
|
36
|
+
* Config values are immediately available via `.get()`.
|
|
37
|
+
* Secrets are decrypted lazily on the first `.getSecret()` call —
|
|
38
|
+
* decryption happens in your runtime; the plaintext never leaves your app.
|
|
39
|
+
*/
|
|
40
|
+
declare class ResolvedConfig {
|
|
41
|
+
#private;
|
|
42
|
+
/** ETag of this snapshot — use for conditional re-resolve. */
|
|
43
|
+
readonly etag: string;
|
|
44
|
+
/** ISO timestamp of when this snapshot was resolved. */
|
|
45
|
+
readonly resolvedAt: string;
|
|
46
|
+
constructor(raw: RawResolveResponse, privateKey?: string);
|
|
47
|
+
/**
|
|
48
|
+
* Get a typed config value.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* const url = config.get<string>('api_url');
|
|
52
|
+
* const retries = config.get<number>('max_retries');
|
|
53
|
+
* const queue = config.get<'default'|'priority'|'batch'>('queue_name');
|
|
54
|
+
*/
|
|
55
|
+
get<T = unknown>(key: string): T;
|
|
56
|
+
/**
|
|
57
|
+
* Get a config value or `undefined` if not set.
|
|
58
|
+
*/
|
|
59
|
+
getOrNull<T = unknown>(key: string): T | undefined;
|
|
60
|
+
/**
|
|
61
|
+
* Decrypt and return a secret value.
|
|
62
|
+
* Result is cached — repeated calls are free.
|
|
63
|
+
*
|
|
64
|
+
* Requires `privateKey` to be set in `OrbOptions`.
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* const webhookSecret = await config.getSecret('webhook_secret');
|
|
68
|
+
*/
|
|
69
|
+
getSecret(key: string): Promise<string>;
|
|
70
|
+
/**
|
|
71
|
+
* Returns true if the key exists as a plain config value.
|
|
72
|
+
*/
|
|
73
|
+
has(key: string): boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Returns true if the key exists as a secret.
|
|
76
|
+
*/
|
|
77
|
+
hasSecret(key: string): boolean;
|
|
78
|
+
/**
|
|
79
|
+
* All plain config keys available in this snapshot.
|
|
80
|
+
*/
|
|
81
|
+
keys(): string[];
|
|
82
|
+
/**
|
|
83
|
+
* All secret keys available in this snapshot (ciphertext, not decrypted).
|
|
84
|
+
*/
|
|
85
|
+
secretKeys(): string[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* OrbSeal config client.
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* ```ts
|
|
93
|
+
* import { ConfigClient } from '@dotlabshq/orbseal-sdk';
|
|
94
|
+
*
|
|
95
|
+
* const orb = new ConfigClient({
|
|
96
|
+
* apiKey: process.env.ORBSEAL_API_KEY!,
|
|
97
|
+
* project: 'taskflow',
|
|
98
|
+
* environment: 'production',
|
|
99
|
+
* privateKey: process.env.ORBSEAL_PRIVATE_KEY, // needed for secrets
|
|
100
|
+
* });
|
|
101
|
+
*
|
|
102
|
+
* const config = await orb.resolve();
|
|
103
|
+
* const apiUrl = config.get<string>('api_url');
|
|
104
|
+
* const secret = await config.getSecret('webhook_secret');
|
|
105
|
+
* ```
|
|
106
|
+
*/
|
|
107
|
+
declare class ConfigClient {
|
|
108
|
+
#private;
|
|
109
|
+
constructor(opts: OrbOptions);
|
|
110
|
+
/**
|
|
111
|
+
* Resolve the current config snapshot from the edge.
|
|
112
|
+
*
|
|
113
|
+
* - Pass `userId` to include user-scoped overrides.
|
|
114
|
+
* - Pass `etag` from a previous resolve to get a cached response when nothing changed.
|
|
115
|
+
* Returns the same `ResolvedConfig` instance when the server confirms the etag is current.
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* // Simple — always fetches fresh
|
|
119
|
+
* const config = await orb.resolve();
|
|
120
|
+
*
|
|
121
|
+
* // With user overrides
|
|
122
|
+
* const config = await orb.resolve({ userId: 'u_abc123' });
|
|
123
|
+
*
|
|
124
|
+
* // With etag caching (returns cached instance if unchanged)
|
|
125
|
+
* const config = await orb.resolve({ etag: prev?.etag });
|
|
126
|
+
*/
|
|
127
|
+
resolve(opts?: ResolveOptions): Promise<ResolvedConfig>;
|
|
128
|
+
/**
|
|
129
|
+
* Project + environment the client is scoped to.
|
|
130
|
+
*/
|
|
131
|
+
get scope(): {
|
|
132
|
+
project: string;
|
|
133
|
+
environment: string;
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export { ConfigClient, type OrbOptions, type RawResolveResponse, type ResolveOptions, ResolvedConfig };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// src/decrypt.ts
|
|
2
|
+
var B58_ALPHA = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
3
|
+
function b58dec(s) {
|
|
4
|
+
let n = 0n;
|
|
5
|
+
for (const ch of s) {
|
|
6
|
+
const i = B58_ALPHA.indexOf(ch);
|
|
7
|
+
if (i < 0) throw new Error(`Invalid base58 character: ${ch}`);
|
|
8
|
+
n = n * 58n + BigInt(i);
|
|
9
|
+
}
|
|
10
|
+
const hex = n.toString(16);
|
|
11
|
+
const buf = Buffer.from(hex.length % 2 ? "0" + hex : hex, "hex");
|
|
12
|
+
return new Uint8Array(buf);
|
|
13
|
+
}
|
|
14
|
+
function parsePrivateKey(raw) {
|
|
15
|
+
if (raw.startsWith("orbsk-") || raw.startsWith("orb-sk-")) {
|
|
16
|
+
const dash = raw.indexOf("-", raw.indexOf("-") + 1);
|
|
17
|
+
return b58dec(raw.slice(dash + 1));
|
|
18
|
+
}
|
|
19
|
+
const buf = Buffer.from(raw, "base64");
|
|
20
|
+
return new Uint8Array(buf);
|
|
21
|
+
}
|
|
22
|
+
async function derivePublicKey(sk) {
|
|
23
|
+
const sodium = await getSodium();
|
|
24
|
+
return sodium.crypto_scalarmult_base(sk);
|
|
25
|
+
}
|
|
26
|
+
async function decryptSealed(ciphertext, privateKeyRaw) {
|
|
27
|
+
const sodium = await getSodium();
|
|
28
|
+
const sk = parsePrivateKey(privateKeyRaw);
|
|
29
|
+
const pk = await derivePublicKey(sk);
|
|
30
|
+
const ct = sodium.from_base64(ciphertext, sodium.base64_variants.ORIGINAL);
|
|
31
|
+
const plain = sodium.crypto_box_seal_open(ct, pk, sk);
|
|
32
|
+
return sodium.to_string(plain);
|
|
33
|
+
}
|
|
34
|
+
var _sodium = null;
|
|
35
|
+
async function getSodium() {
|
|
36
|
+
if (_sodium) return _sodium;
|
|
37
|
+
try {
|
|
38
|
+
const mod = await import("libsodium-wrappers");
|
|
39
|
+
const sodium = mod.default ?? mod;
|
|
40
|
+
await sodium.ready;
|
|
41
|
+
_sodium = sodium;
|
|
42
|
+
return _sodium;
|
|
43
|
+
} catch {
|
|
44
|
+
throw new Error(
|
|
45
|
+
"[orbseal-sdk] libsodium-wrappers is required to decrypt secrets.\nInstall it: npm install libsodium-wrappers"
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// src/config.ts
|
|
51
|
+
var ResolvedConfig = class {
|
|
52
|
+
/** ETag of this snapshot — use for conditional re-resolve. */
|
|
53
|
+
etag;
|
|
54
|
+
/** ISO timestamp of when this snapshot was resolved. */
|
|
55
|
+
resolvedAt;
|
|
56
|
+
#config;
|
|
57
|
+
#secrets;
|
|
58
|
+
// still ciphertext
|
|
59
|
+
#privateKey;
|
|
60
|
+
#decrypted = /* @__PURE__ */ new Map();
|
|
61
|
+
constructor(raw, privateKey) {
|
|
62
|
+
this.#config = raw.config;
|
|
63
|
+
this.#secrets = raw.secrets;
|
|
64
|
+
this.#privateKey = privateKey;
|
|
65
|
+
this.etag = raw.etag;
|
|
66
|
+
this.resolvedAt = raw.resolved_at;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Get a typed config value.
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* const url = config.get<string>('api_url');
|
|
73
|
+
* const retries = config.get<number>('max_retries');
|
|
74
|
+
* const queue = config.get<'default'|'priority'|'batch'>('queue_name');
|
|
75
|
+
*/
|
|
76
|
+
get(key) {
|
|
77
|
+
if (!(key in this.#config)) {
|
|
78
|
+
throw new Error(`[orbseal] config key not found: "${key}"`);
|
|
79
|
+
}
|
|
80
|
+
return this.#config[key];
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Get a config value or `undefined` if not set.
|
|
84
|
+
*/
|
|
85
|
+
getOrNull(key) {
|
|
86
|
+
return this.#config[key];
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Decrypt and return a secret value.
|
|
90
|
+
* Result is cached — repeated calls are free.
|
|
91
|
+
*
|
|
92
|
+
* Requires `privateKey` to be set in `OrbOptions`.
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* const webhookSecret = await config.getSecret('webhook_secret');
|
|
96
|
+
*/
|
|
97
|
+
async getSecret(key) {
|
|
98
|
+
if (this.#decrypted.has(key)) {
|
|
99
|
+
return this.#decrypted.get(key);
|
|
100
|
+
}
|
|
101
|
+
if (!(key in this.#secrets)) {
|
|
102
|
+
throw new Error(`[orbseal] secret not found: "${key}"`);
|
|
103
|
+
}
|
|
104
|
+
if (!this.#privateKey) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`[orbseal] privateKey is required to decrypt secrets.
|
|
107
|
+
Set it in ConfigClient options: new ConfigClient({ ..., privateKey: process.env.ORBSEAL_PRIVATE_KEY })`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
const plain = await decryptSealed(this.#secrets[key], this.#privateKey);
|
|
111
|
+
this.#decrypted.set(key, plain);
|
|
112
|
+
return plain;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Returns true if the key exists as a plain config value.
|
|
116
|
+
*/
|
|
117
|
+
has(key) {
|
|
118
|
+
return key in this.#config;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Returns true if the key exists as a secret.
|
|
122
|
+
*/
|
|
123
|
+
hasSecret(key) {
|
|
124
|
+
return key in this.#secrets;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* All plain config keys available in this snapshot.
|
|
128
|
+
*/
|
|
129
|
+
keys() {
|
|
130
|
+
return Object.keys(this.#config);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* All secret keys available in this snapshot (ciphertext, not decrypted).
|
|
134
|
+
*/
|
|
135
|
+
secretKeys() {
|
|
136
|
+
return Object.keys(this.#secrets);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// src/client.ts
|
|
141
|
+
var DEFAULT_API_BASE = "https://api.orbseal.com";
|
|
142
|
+
var ConfigClient = class {
|
|
143
|
+
#apiKey;
|
|
144
|
+
#project;
|
|
145
|
+
#environment;
|
|
146
|
+
#privateKey;
|
|
147
|
+
#apiBase;
|
|
148
|
+
// ─── ETag cache ─────────────────────────────────────────────────────────────
|
|
149
|
+
#cached = null;
|
|
150
|
+
#cachedFor = null;
|
|
151
|
+
// "<userId|''>:<etag>"
|
|
152
|
+
constructor(opts) {
|
|
153
|
+
if (!opts.apiKey) throw new Error("[orbseal] apiKey is required");
|
|
154
|
+
if (!opts.project) throw new Error("[orbseal] project is required");
|
|
155
|
+
if (!opts.environment) throw new Error("[orbseal] environment is required");
|
|
156
|
+
this.#apiKey = opts.apiKey;
|
|
157
|
+
this.#project = opts.project;
|
|
158
|
+
this.#environment = opts.environment;
|
|
159
|
+
this.#privateKey = opts.privateKey;
|
|
160
|
+
this.#apiBase = (opts.apiBase ?? DEFAULT_API_BASE).replace(/\/+$/, "");
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Resolve the current config snapshot from the edge.
|
|
164
|
+
*
|
|
165
|
+
* - Pass `userId` to include user-scoped overrides.
|
|
166
|
+
* - Pass `etag` from a previous resolve to get a cached response when nothing changed.
|
|
167
|
+
* Returns the same `ResolvedConfig` instance when the server confirms the etag is current.
|
|
168
|
+
*
|
|
169
|
+
* @example
|
|
170
|
+
* // Simple — always fetches fresh
|
|
171
|
+
* const config = await orb.resolve();
|
|
172
|
+
*
|
|
173
|
+
* // With user overrides
|
|
174
|
+
* const config = await orb.resolve({ userId: 'u_abc123' });
|
|
175
|
+
*
|
|
176
|
+
* // With etag caching (returns cached instance if unchanged)
|
|
177
|
+
* const config = await orb.resolve({ etag: prev?.etag });
|
|
178
|
+
*/
|
|
179
|
+
async resolve(opts = {}) {
|
|
180
|
+
const { userId, etag } = opts;
|
|
181
|
+
const cacheKey = `${userId ?? ""}:${etag ?? ""}`;
|
|
182
|
+
if (etag && this.#cached && this.#cachedFor === cacheKey) {
|
|
183
|
+
return this.#cached;
|
|
184
|
+
}
|
|
185
|
+
const url = this.#buildUrl(userId);
|
|
186
|
+
const headers = {
|
|
187
|
+
"Authorization": `Bearer ${this.#apiKey}`,
|
|
188
|
+
"Content-Type": "application/json"
|
|
189
|
+
};
|
|
190
|
+
if (etag) headers["If-None-Match"] = etag;
|
|
191
|
+
const res = await fetch(url, { headers });
|
|
192
|
+
if (res.status === 304 && this.#cached) {
|
|
193
|
+
return this.#cached;
|
|
194
|
+
}
|
|
195
|
+
if (!res.ok) {
|
|
196
|
+
let message = `HTTP ${res.status}`;
|
|
197
|
+
try {
|
|
198
|
+
const body = await res.json();
|
|
199
|
+
message = body.error ?? body.code ?? message;
|
|
200
|
+
} catch {
|
|
201
|
+
}
|
|
202
|
+
throw new Error(`[orbseal] resolve failed: ${message}`);
|
|
203
|
+
}
|
|
204
|
+
const raw = await res.json();
|
|
205
|
+
const resolved = new ResolvedConfig(raw, this.#privateKey);
|
|
206
|
+
this.#cached = resolved;
|
|
207
|
+
this.#cachedFor = `${userId ?? ""}:${resolved.etag}`;
|
|
208
|
+
return resolved;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Project + environment the client is scoped to.
|
|
212
|
+
*/
|
|
213
|
+
get scope() {
|
|
214
|
+
return { project: this.#project, environment: this.#environment };
|
|
215
|
+
}
|
|
216
|
+
// ─── Internal ────────────────────────────────────────────────────────────────
|
|
217
|
+
#buildUrl(userId) {
|
|
218
|
+
const params = new URLSearchParams({
|
|
219
|
+
project: this.#project,
|
|
220
|
+
environment: this.#environment
|
|
221
|
+
});
|
|
222
|
+
if (userId) params.set("user", userId);
|
|
223
|
+
return `${this.#apiBase}/v1/config/resolve?${params}`;
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
export {
|
|
227
|
+
ConfigClient,
|
|
228
|
+
ResolvedConfig
|
|
229
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dotlabshq/orbseal-sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Official JavaScript/TypeScript SDK for OrbSeal — typed config and E2E-encrypted secrets",
|
|
5
|
+
"keywords": ["orbseal", "config", "secrets", "encryption", "edge"],
|
|
6
|
+
"homepage": "https://orbseal.com",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/dotlabshq/orbseal-sdk"
|
|
10
|
+
},
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"import": "./dist/index.js",
|
|
17
|
+
"require": "./dist/index.cjs"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"main": "./dist/index.cjs",
|
|
21
|
+
"module": "./dist/index.js",
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"files": ["dist", "README.md"],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
|
|
26
|
+
"dev": "tsup src/index.ts --format esm,cjs --dts --watch",
|
|
27
|
+
"typecheck": "tsc --noEmit"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"libsodium-wrappers": ">=0.7"
|
|
31
|
+
},
|
|
32
|
+
"peerDependenciesMeta": {
|
|
33
|
+
"libsodium-wrappers": {
|
|
34
|
+
"optional": true
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@baseworks/tsconfig": "workspace:*",
|
|
39
|
+
"@types/libsodium-wrappers": "^0.7.14",
|
|
40
|
+
"tsup": "^8.0.0",
|
|
41
|
+
"typescript": "^5.6.0"
|
|
42
|
+
}
|
|
43
|
+
}
|