cloudcruise 0.0.1 → 0.0.2-alpha.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.
@@ -0,0 +1,224 @@
1
+ import { openSSE } from './sse.js';
2
+ import { SimpleEventEmitter } from './events.js';
3
+ import { AsyncEventQueue } from './asyncQueue.js';
4
+ function isFinalEvent(eventType) {
5
+ return eventType === 'execution.success' || eventType === 'execution.failed' || eventType === 'execution.stopped';
6
+ }
7
+ export class ConnectionManager {
8
+ baseUrl;
9
+ apiKey;
10
+ clientId;
11
+ conn = null;
12
+ connecting = false;
13
+ connected = false;
14
+ reconnecting = false;
15
+ reconnectDelays = [1000, 3000, 10000];
16
+ sessions = new Map();
17
+ constructor(baseUrl, apiKey) {
18
+ this.baseUrl = baseUrl.replace(/\/$/, '');
19
+ this.apiKey = apiKey;
20
+ }
21
+ async ensureClientId() {
22
+ if (this.clientId)
23
+ return this.clientId;
24
+ this.clientId = this.generateClientId();
25
+ return this.clientId;
26
+ }
27
+ /*
28
+ try to use crypto.randomUUID if the platform is supported. otherwise fallback to other methods: https://stackoverflow.com/questions/105034/how-do-i-create-a-guid-uuid/2117523#2117523
29
+ */
30
+ generateClientId() {
31
+ const cryptoObj = globalThis.crypto;
32
+ // Preferred: native CSPRNG-backed UUID
33
+ if (cryptoObj?.randomUUID) {
34
+ return cryptoObj.randomUUID();
35
+ }
36
+ // Fallback: RFC4122 v4 built from CSPRNG bytes
37
+ if (cryptoObj?.getRandomValues) {
38
+ const bytes = new Uint8Array(16);
39
+ cryptoObj.getRandomValues(bytes);
40
+ bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
41
+ bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10xx
42
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0'));
43
+ return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10).join('')}`;
44
+ }
45
+ // Last resort: non-CSPRNG timestamp + Math.random
46
+ let d = Date.now();
47
+ let d2 = typeof performance !== 'undefined' && typeof performance.now === 'function'
48
+ ? Math.floor(performance.now() * 1000)
49
+ : 0;
50
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
51
+ let r = Math.random() * 16;
52
+ if (d > 0) {
53
+ r = ((d + r) % 16) | 0;
54
+ d = Math.floor(d / 16);
55
+ }
56
+ else {
57
+ r = ((d2 + r) % 16) | 0;
58
+ d2 = Math.floor(d2 / 16);
59
+ }
60
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
61
+ return v.toString(16);
62
+ });
63
+ }
64
+ async connectIfNeeded() {
65
+ if (this.connected || this.connecting)
66
+ return;
67
+ if (!this.clientId)
68
+ await this.ensureClientId();
69
+ await this.openMuxConnection();
70
+ }
71
+ subscribe(sessionId, opts) {
72
+ // Kick off connection if not already connected
73
+ try {
74
+ void this.connectIfNeeded();
75
+ }
76
+ catch { }
77
+ // Ensure channel exists
78
+ let channel = this.sessions.get(sessionId);
79
+ if (!channel) {
80
+ channel = {
81
+ sessionId,
82
+ emitter: new SimpleEventEmitter(),
83
+ subscribers: new Set(),
84
+ ended: false
85
+ };
86
+ this.sessions.set(sessionId, channel);
87
+ }
88
+ // Create per-handle queue
89
+ const queue = new AsyncEventQueue();
90
+ channel.subscribers.add(queue);
91
+ // Propagate abort to handle close
92
+ if (opts?.signal) {
93
+ const onAbort = () => sub.close();
94
+ opts.signal.addEventListener('abort', onAbort, { once: true });
95
+ }
96
+ const sub = {
97
+ on: (event, handler) => channel.emitter.on(event, handler),
98
+ close: () => {
99
+ if (!channel)
100
+ return;
101
+ queue.close();
102
+ channel.subscribers.delete(queue);
103
+ // If no more subscribers and channel is ended, remove it
104
+ if (channel.subscribers.size === 0 && channel.ended) {
105
+ this.sessions.delete(sessionId);
106
+ }
107
+ },
108
+ [Symbol.asyncIterator]() {
109
+ return queue[Symbol.asyncIterator]();
110
+ }
111
+ };
112
+ return sub;
113
+ }
114
+ async openMuxConnection() {
115
+ if (this.connecting || this.connected)
116
+ return;
117
+ if (!this.clientId)
118
+ await this.ensureClientId();
119
+ this.connecting = true;
120
+ const headers = {
121
+ 'cc-key': this.apiKey
122
+ };
123
+ const url = `${this.baseUrl}/run/clients/${this.clientId}/events`;
124
+ const emitAll = (event, payload) => {
125
+ for (const ch of this.sessions.values()) {
126
+ ch.emitter.emit(event, payload);
127
+ }
128
+ };
129
+ try {
130
+ this.conn = openSSE(url, {
131
+ onOpen: () => {
132
+ this.connected = true;
133
+ this.connecting = false;
134
+ emitAll('open');
135
+ },
136
+ onEvent: (evt) => {
137
+ if (evt.event === 'ping') {
138
+ emitAll('ping', evt);
139
+ return;
140
+ }
141
+ if (evt.event === 'run.event') {
142
+ const raw = evt;
143
+ const data = raw.data?.data;
144
+ if (!data) {
145
+ return;
146
+ }
147
+ const sessionId = data.payload?.session_id;
148
+ if (!sessionId) {
149
+ return;
150
+ }
151
+ const channel = this.sessions.get(sessionId);
152
+ if (!channel) {
153
+ return;
154
+ }
155
+ const msg = { event: 'run.event', data };
156
+ // fan-out to all subscribers
157
+ for (const q of channel.subscribers)
158
+ q.push(msg);
159
+ channel.emitter.emit('run.event', msg);
160
+ const eventType = data.event;
161
+ if (isFinalEvent(eventType)) {
162
+ channel.ended = true;
163
+ channel.emitter.emit('end', { type: eventType });
164
+ for (const q of channel.subscribers)
165
+ q.close();
166
+ channel.subscribers.clear();
167
+ // Remove the channel after notifying
168
+ this.sessions.delete(sessionId);
169
+ }
170
+ return;
171
+ }
172
+ const u = evt;
173
+ },
174
+ onError: (err) => {
175
+ // Surface error to all channels and attempt reconnect
176
+ emitAll('error', err);
177
+ if (!this.reconnecting)
178
+ this.scheduleReconnect();
179
+ },
180
+ onClose: () => {
181
+ this.connected = false;
182
+ this.connecting = false;
183
+ emitAll('close');
184
+ if (!this.reconnecting)
185
+ this.scheduleReconnect();
186
+ }
187
+ }, {
188
+ headers,
189
+ withCredentials: false
190
+ });
191
+ }
192
+ catch (e) {
193
+ this.connected = false;
194
+ this.connecting = false;
195
+ if (!this.reconnecting)
196
+ this.scheduleReconnect();
197
+ }
198
+ }
199
+ scheduleReconnect() {
200
+ if (this.reconnecting)
201
+ return;
202
+ this.reconnecting = true;
203
+ (async () => {
204
+ for (const delay of this.reconnectDelays) {
205
+ // Notify listeners about reconnect attempt
206
+ for (const ch of this.sessions.values())
207
+ ch.emitter.emit('reconnect', { attemptDelayMs: delay });
208
+ await new Promise(r => setTimeout(r, delay));
209
+ try {
210
+ await this.openMuxConnection();
211
+ if (this.connected) {
212
+ this.reconnecting = false;
213
+ return;
214
+ }
215
+ }
216
+ catch {
217
+ // continue to next delay
218
+ }
219
+ }
220
+ // Give up after exhausting delays; next event/subscribe will try again
221
+ this.reconnecting = false;
222
+ })();
223
+ }
224
+ }
@@ -0,0 +1,2 @@
1
+ export type CloudCruiseEnvVar = 'CLOUDCRUISE_API_KEY' | 'CLOUDCRUISE_BASE_URL' | 'CLOUDCRUISE_ENCRYPTION_KEY';
2
+ export declare function getEnv(key: CloudCruiseEnvVar): string | undefined;
@@ -0,0 +1,9 @@
1
+ export function getEnv(key) {
2
+ if (typeof process !== 'undefined' && process.env && process.env[key]) {
3
+ return process.env[key];
4
+ }
5
+ else if (typeof globalThis !== 'undefined' && globalThis[key]) {
6
+ return globalThis[key];
7
+ }
8
+ return undefined;
9
+ }
@@ -0,0 +1,7 @@
1
+ export type EventHandler<T = unknown> = (event: T) => void;
2
+ export declare class SimpleEventEmitter {
3
+ private listeners;
4
+ on(event: string, handler: EventHandler<unknown>): () => void;
5
+ emit(event: string, payload?: unknown): void;
6
+ clear(): void;
7
+ }
@@ -0,0 +1,23 @@
1
+ export class SimpleEventEmitter {
2
+ listeners = new Map();
3
+ on(event, handler) {
4
+ if (!this.listeners.has(event))
5
+ this.listeners.set(event, new Set());
6
+ this.listeners.get(event).add(handler);
7
+ return () => {
8
+ const set = this.listeners.get(event);
9
+ if (set)
10
+ set.delete(handler);
11
+ };
12
+ }
13
+ emit(event, payload) {
14
+ const set = this.listeners.get(event);
15
+ if (!set)
16
+ return;
17
+ for (const handler of set)
18
+ handler(payload);
19
+ }
20
+ clear() {
21
+ this.listeners.clear();
22
+ }
23
+ }
@@ -0,0 +1,24 @@
1
+ export interface SSEHandlers {
2
+ onOpen?: () => void;
3
+ onEvent?: (evt: {
4
+ event: string;
5
+ data?: unknown;
6
+ id?: string;
7
+ raw?: string;
8
+ }) => void;
9
+ onError?: (error: Error) => void;
10
+ onClose?: () => void;
11
+ }
12
+ export interface SSEOptions {
13
+ headers?: HeadersInit;
14
+ withCredentials?: boolean;
15
+ signal?: AbortSignal;
16
+ }
17
+ export interface SSEConnection {
18
+ close(): void;
19
+ }
20
+ /**
21
+ * Open an SSE connection using either native EventSource (browser, cookie auth)
22
+ * or fetch streaming (Node 18+ and modern browsers) when custom headers are needed.
23
+ */
24
+ export declare function openSSE(url: string, handlers: SSEHandlers, opts?: SSEOptions): SSEConnection;
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Open an SSE connection using either native EventSource (browser, cookie auth)
3
+ * or fetch streaming (Node 18+ and modern browsers) when custom headers are needed.
4
+ */
5
+ export function openSSE(url, handlers, opts) {
6
+ const { onOpen, onEvent, onError, onClose } = handlers;
7
+ const { headers, withCredentials, signal } = opts ?? {};
8
+ const shouldUseEventSource = () => typeof window !== 'undefined' &&
9
+ 'EventSource' in window &&
10
+ withCredentials === true &&
11
+ (!headers || Object.keys(headers).length === 0);
12
+ const parseJSON = (text) => {
13
+ try {
14
+ return text ? JSON.parse(text) : undefined;
15
+ }
16
+ catch {
17
+ return text || undefined;
18
+ }
19
+ };
20
+ const createEventSourceConnection = () => {
21
+ const ES = window.EventSource;
22
+ const es = new ES(url, { withCredentials: true });
23
+ const onOpenHandler = () => onOpen?.();
24
+ const onErrorHandler = (e) => onError?.(e instanceof Error ? e : new Error('EventSource error'));
25
+ const onRunEvent = (e) => {
26
+ const raw = String(e.data ?? '');
27
+ const parsed = parseJSON(raw);
28
+ onEvent?.({ event: 'run.event', data: parsed, id: e.lastEventId, raw });
29
+ };
30
+ const onPing = (e) => {
31
+ const raw = String(e.data ?? '');
32
+ const parsed = parseJSON(raw);
33
+ onEvent?.({ event: 'ping', data: parsed, id: e.lastEventId, raw });
34
+ };
35
+ es.addEventListener('open', onOpenHandler);
36
+ es.addEventListener('error', onErrorHandler);
37
+ es.addEventListener('run.event', onRunEvent);
38
+ es.addEventListener('ping', onPing);
39
+ if (signal) {
40
+ const onAbort = () => {
41
+ es.close();
42
+ onClose?.();
43
+ };
44
+ signal.addEventListener('abort', onAbort, { once: true });
45
+ }
46
+ return {
47
+ close() {
48
+ es.close();
49
+ onClose?.();
50
+ },
51
+ };
52
+ };
53
+ const createFetchConnection = () => {
54
+ const controller = new AbortController();
55
+ const abort = () => controller.abort();
56
+ if (signal)
57
+ signal.addEventListener('abort', abort, { once: true });
58
+ const parseFrame = (frame) => {
59
+ let event = 'message';
60
+ let data = '';
61
+ let id;
62
+ for (const line of frame.split(/\r?\n/)) {
63
+ if (!line || line.startsWith(':'))
64
+ continue;
65
+ const idx = line.indexOf(':');
66
+ const field = idx === -1 ? line : line.slice(0, idx);
67
+ const value = idx === -1 ? '' : line.slice(idx + 1).trimStart();
68
+ if (field === 'event')
69
+ event = value;
70
+ else if (field === 'data')
71
+ data += (data ? '\n' : '') + value;
72
+ else if (field === 'id')
73
+ id = value;
74
+ }
75
+ const parsed = parseJSON(data);
76
+ return { event, data: parsed, id, raw: frame };
77
+ };
78
+ (async () => {
79
+ try {
80
+ const res = await fetch(url, {
81
+ method: 'GET',
82
+ headers: {
83
+ Accept: 'text/event-stream',
84
+ 'Cache-Control': 'no-cache',
85
+ ...(headers ?? {}),
86
+ },
87
+ credentials: withCredentials ? 'include' : 'same-origin',
88
+ signal: controller.signal,
89
+ });
90
+ if (!res.ok || !res.body)
91
+ throw new Error(`SSE HTTP ${res.status}`);
92
+ onOpen?.();
93
+ const reader = res.body.getReader();
94
+ const decoder = new TextDecoder();
95
+ let buffer = '';
96
+ while (true) {
97
+ const { done, value } = await reader.read();
98
+ if (done)
99
+ break;
100
+ const chunk = decoder.decode(value, { stream: true });
101
+ const parts = (buffer + chunk).split(/\r?\n\r?\n/);
102
+ buffer = parts.pop() ?? '';
103
+ for (const frame of parts) {
104
+ const evt = parseFrame(frame);
105
+ onEvent?.(evt);
106
+ }
107
+ }
108
+ onClose?.();
109
+ }
110
+ catch (err) {
111
+ onError?.(err instanceof Error ? err : new Error(String(err)));
112
+ }
113
+ })();
114
+ return {
115
+ close() {
116
+ abort();
117
+ onClose?.();
118
+ },
119
+ };
120
+ };
121
+ return shouldUseEventSource() ? createEventSourceConnection() : createFetchConnection();
122
+ }
@@ -0,0 +1,28 @@
1
+ import type { GetVaultEntriesFilters, VaultEntry } from './types.js';
2
+ export declare class VaultClient {
3
+ private readonly makeRequest;
4
+ private readonly encryptionKey;
5
+ constructor(makeRequest: <T = any>(method: 'GET' | 'POST' | 'PUT' | 'DELETE', path: string, body?: any) => Promise<T>, encryptionKey: string);
6
+ /**
7
+ * Creates a new vault entry
8
+ */
9
+ create(domain: string, permissioned_user_id: string, options?: Partial<Omit<VaultEntry, 'id' | 'created_at' | 'domain' | 'permissioned_user_id'>>): Promise<VaultEntry>;
10
+ /**
11
+ * Gets vault entries, optionally filtered
12
+ * @param filters - Optional filters for the request
13
+ * @param filters.permissioned_user_id - Filter by user ID
14
+ * @param filters.domain - Filter by domain
15
+ * @param filters.decryptCredentials - Whether to decrypt sensitive fields (default: true)
16
+ */
17
+ get(filters?: GetVaultEntriesFilters): Promise<VaultEntry[]>;
18
+ /**
19
+ * Updates an existing vault entry
20
+ */
21
+ update(id: string, updates: Partial<VaultEntry>): Promise<VaultEntry>;
22
+ /**
23
+ * Deletes a vault entry by domain and permissioned user ID
24
+ * @param domain - The domain of the vault entry to delete
25
+ * @param permissioned_user_id - The permissioned user ID of the vault entry to delete
26
+ */
27
+ delete(domain: string, permissioned_user_id: string): Promise<void>;
28
+ }
@@ -0,0 +1,76 @@
1
+ import { encryptSensitiveFields, decryptSensitiveFields } from './utils.js';
2
+ export class VaultClient {
3
+ makeRequest;
4
+ encryptionKey;
5
+ constructor(makeRequest, encryptionKey) {
6
+ this.makeRequest = makeRequest;
7
+ this.encryptionKey = encryptionKey;
8
+ }
9
+ /**
10
+ * Creates a new vault entry
11
+ */
12
+ async create(domain, permissioned_user_id, options) {
13
+ const entry = {
14
+ domain,
15
+ permissioned_user_id,
16
+ ...options
17
+ };
18
+ let processedEntry = { ...entry };
19
+ // Encrypt sensitive fields
20
+ processedEntry = await encryptSensitiveFields(processedEntry, this.encryptionKey);
21
+ const response = await this.makeRequest('POST', '/vault', processedEntry);
22
+ // Decrypt response using encryption key
23
+ return await decryptSensitiveFields(response, this.encryptionKey);
24
+ }
25
+ /**
26
+ * Gets vault entries, optionally filtered
27
+ * @param filters - Optional filters for the request
28
+ * @param filters.permissioned_user_id - Filter by user ID
29
+ * @param filters.domain - Filter by domain
30
+ * @param filters.decryptCredentials - Whether to decrypt sensitive fields (default: true)
31
+ */
32
+ async get(filters) {
33
+ let path = '/vault';
34
+ if (filters && (filters.permissioned_user_id || filters.domain)) {
35
+ const params = new URLSearchParams();
36
+ if (filters.permissioned_user_id) {
37
+ params.append('permissioned_user_id', filters.permissioned_user_id);
38
+ }
39
+ if (filters.domain) {
40
+ params.append('domain', filters.domain);
41
+ }
42
+ path += `?${params.toString()}`;
43
+ }
44
+ const response = await this.makeRequest('GET', path);
45
+ let entries = Array.isArray(response) ? response : [response];
46
+ // Conditionally decrypt sensitive fields based on decryptCredentials flag
47
+ const shouldDecrypt = filters?.decryptCredentials !== false;
48
+ if (shouldDecrypt) {
49
+ entries = await Promise.all(entries.map(entry => decryptSensitiveFields(entry, this.encryptionKey)));
50
+ }
51
+ return entries;
52
+ }
53
+ /**
54
+ * Updates an existing vault entry
55
+ */
56
+ async update(id, updates) {
57
+ const entry = {
58
+ id,
59
+ ...updates
60
+ };
61
+ let processedEntry = { ...entry };
62
+ // Encrypt sensitive fields
63
+ processedEntry = await encryptSensitiveFields(processedEntry, this.encryptionKey);
64
+ const response = await this.makeRequest('PUT', '/vault', processedEntry);
65
+ // Decrypt response using encryption key
66
+ return await decryptSensitiveFields(response, this.encryptionKey);
67
+ }
68
+ /**
69
+ * Deletes a vault entry by domain and permissioned user ID
70
+ * @param domain - The domain of the vault entry to delete
71
+ * @param permissioned_user_id - The permissioned user ID of the vault entry to delete
72
+ */
73
+ async delete(domain, permissioned_user_id) {
74
+ await this.makeRequest('DELETE', '/vault', { domain, permissioned_user_id });
75
+ }
76
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * CloudCruise Vault API Type Definitions
3
+ */
4
+ export interface VaultPostPutHeadersInBody {
5
+ name: string;
6
+ value: string;
7
+ }
8
+ export interface ProxyConfig {
9
+ enable: boolean;
10
+ target_ip?: string;
11
+ }
12
+ export interface VaultEntry {
13
+ id?: string;
14
+ domain: string;
15
+ permissioned_user_id: string;
16
+ workspace_id?: string;
17
+ user_id?: string;
18
+ password?: string;
19
+ user_name?: string;
20
+ tfa_secret?: string;
21
+ user_agent?: string;
22
+ user_alias?: string;
23
+ location?: string;
24
+ ip_address?: string;
25
+ session_id?: string;
26
+ allow_multiple_sessions?: boolean;
27
+ cookies?: any;
28
+ local_storage?: any;
29
+ session_storage?: any;
30
+ persist_cookies?: boolean;
31
+ persist_local_storage?: boolean;
32
+ persist_session_storage?: boolean;
33
+ cookie_domain_to_store?: string | null;
34
+ proxy?: ProxyConfig;
35
+ proxy_string?: string | null;
36
+ headers?: VaultPostPutHeadersInBody[];
37
+ created_at?: string | null;
38
+ }
39
+ export interface GetVaultEntriesFilters {
40
+ permissioned_user_id?: string;
41
+ domain?: string;
42
+ decryptCredentials?: boolean;
43
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * CloudCruise Vault API Type Definitions
3
+ */
4
+ export {};
@@ -0,0 +1,33 @@
1
+ /**
2
+ * AES-256-GCM Encryption utilities for CloudCruise vault data
3
+ * Uses 12-byte IV and returns concatenated hex: iv(24 hex) + ciphertext + tag(32 hex)
4
+ */
5
+ /**
6
+ * Encrypts sensitive data using AES-256-GCM
7
+ * @param data - Data to encrypt (will be JSON stringified)
8
+ * @param keyHex - Hex-encoded encryption key
9
+ * @returns Concatenated hex string: iv(24 hex) + ciphertext + tag(32 hex)
10
+ */
11
+ export declare function encryptData(data: any, keyHex: string): Promise<string>;
12
+ /**
13
+ * Decrypts data using AES-256-GCM
14
+ * @param encryptedHex - Concatenated hex: iv(24 hex) + ciphertext + tag(32 hex)
15
+ * @param keyHex - Hex-encoded encryption key
16
+ * @returns Decrypted and parsed data
17
+ */
18
+ export declare function decryptData(encryptedHex: string, keyHex: string): Promise<any>;
19
+ /**
20
+ * Encrypts sensitive fields in a vault entry
21
+ * Fields encrypted: user_name, password, tfa_secret (if present)
22
+ * @param entry - Vault entry with potentially sensitive data
23
+ * @param encryptionKey - Hex-encoded encryption key
24
+ * @returns Entry with encrypted sensitive fields
25
+ */
26
+ export declare function encryptSensitiveFields(entry: any, encryptionKey: string): Promise<any>;
27
+ /**
28
+ * Decrypts sensitive fields in a vault entry
29
+ * @param entry - Vault entry with encrypted sensitive fields
30
+ * @param encryptionKey - Hex-encoded encryption key
31
+ * @returns Entry with decrypted sensitive fields
32
+ */
33
+ export declare function decryptSensitiveFields(entry: any, encryptionKey: string): Promise<any>;