@lobu/core 3.0.5 → 3.0.7
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 +3 -3
- package/src/__tests__/encryption.test.ts +103 -0
- package/src/__tests__/fixtures/factories.ts +76 -0
- package/src/__tests__/fixtures/index.ts +9 -0
- package/src/__tests__/fixtures/mock-fetch.ts +32 -0
- package/src/__tests__/fixtures/mock-queue.ts +50 -0
- package/src/__tests__/fixtures/mock-redis.ts +300 -0
- package/src/__tests__/retry.test.ts +134 -0
- package/src/__tests__/sanitize.test.ts +158 -0
- package/src/agent-policy.ts +207 -0
- package/src/agent-store.ts +220 -0
- package/src/api-types.ts +256 -0
- package/src/command-registry.ts +73 -0
- package/src/constants.ts +60 -0
- package/src/errors.ts +220 -0
- package/src/index.ts +131 -0
- package/src/integration-types.ts +26 -0
- package/src/logger.ts +248 -0
- package/src/modules.ts +184 -0
- package/src/otel.ts +306 -0
- package/src/plugin-types.ts +46 -0
- package/src/provider-config-types.ts +54 -0
- package/src/redis/base-store.ts +200 -0
- package/src/sentry.ts +54 -0
- package/src/trace.ts +32 -0
- package/src/types.ts +430 -0
- package/src/utils/encryption.ts +78 -0
- package/src/utils/env.ts +50 -0
- package/src/utils/json.ts +37 -0
- package/src/utils/lock.ts +75 -0
- package/src/utils/mcp-tool-instructions.ts +5 -0
- package/src/utils/retry.ts +91 -0
- package/src/utils/sanitize.ts +127 -0
- package/src/worker/auth.ts +100 -0
- package/src/worker/transport.ts +107 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import * as crypto from "node:crypto";
|
|
2
|
+
import { createLogger } from "../logger";
|
|
3
|
+
|
|
4
|
+
const IV_LENGTH = 12; // 96-bit nonce for AES-GCM
|
|
5
|
+
const logger = createLogger("encryption");
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Get encryption key from environment with validation
|
|
9
|
+
*
|
|
10
|
+
* IMPORTANT: The ENCRYPTION_KEY must be exactly 32 bytes (256 bits) for AES-256.
|
|
11
|
+
* Generate a secure key using: `openssl rand -base64 32` or `openssl rand -hex 32`
|
|
12
|
+
*/
|
|
13
|
+
function getEncryptionKey(): Buffer {
|
|
14
|
+
const key = process.env.ENCRYPTION_KEY || "";
|
|
15
|
+
if (!key) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
"ENCRYPTION_KEY environment variable is required for secure operation"
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Try to decode as base64 first (most common format)
|
|
22
|
+
let keyBuffer: Buffer;
|
|
23
|
+
try {
|
|
24
|
+
keyBuffer = Buffer.from(key, "base64");
|
|
25
|
+
if (keyBuffer.length === 32) {
|
|
26
|
+
return keyBuffer;
|
|
27
|
+
}
|
|
28
|
+
} catch (err) {
|
|
29
|
+
logger.debug("ENCRYPTION_KEY is not valid base64, trying hex format", err);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Try as hex (must be exactly 64 hex characters for 32 bytes)
|
|
33
|
+
if (/^[0-9a-fA-F]{64}$/.test(key)) {
|
|
34
|
+
keyBuffer = Buffer.from(key, "hex");
|
|
35
|
+
if (keyBuffer.length === 32) {
|
|
36
|
+
return keyBuffer;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
throw new Error(
|
|
41
|
+
"ENCRYPTION_KEY must be a base64 or hex encoded 32-byte key. " +
|
|
42
|
+
"Generate a valid key with: openssl rand -base64 32"
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Encrypt a string using AES-256-GCM
|
|
48
|
+
*/
|
|
49
|
+
export function encrypt(text: string): string {
|
|
50
|
+
const encryptionKey = getEncryptionKey();
|
|
51
|
+
const iv = crypto.randomBytes(IV_LENGTH);
|
|
52
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", encryptionKey, iv);
|
|
53
|
+
const encrypted = Buffer.concat([
|
|
54
|
+
cipher.update(text, "utf8"),
|
|
55
|
+
cipher.final(),
|
|
56
|
+
]);
|
|
57
|
+
const tag = cipher.getAuthTag();
|
|
58
|
+
return `${iv.toString("hex")}:${tag.toString("hex")}:${encrypted.toString("hex")}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Decrypt a string encrypted with AES-256-GCM
|
|
63
|
+
*/
|
|
64
|
+
export function decrypt(text: string): string {
|
|
65
|
+
const encryptionKey = getEncryptionKey();
|
|
66
|
+
const parts = text.split(":");
|
|
67
|
+
if (parts.length !== 3) throw new Error("Invalid encrypted format");
|
|
68
|
+
const iv = Buffer.from(parts[0]!, "hex");
|
|
69
|
+
const tag = Buffer.from(parts[1]!, "hex");
|
|
70
|
+
const encryptedText = Buffer.from(parts[2]!, "hex");
|
|
71
|
+
const decipher = crypto.createDecipheriv("aes-256-gcm", encryptionKey, iv);
|
|
72
|
+
decipher.setAuthTag(tag);
|
|
73
|
+
const decrypted = Buffer.concat([
|
|
74
|
+
decipher.update(encryptedText),
|
|
75
|
+
decipher.final(),
|
|
76
|
+
]);
|
|
77
|
+
return decrypted.toString("utf8");
|
|
78
|
+
}
|
package/src/utils/env.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { ConfigError } from "../errors";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Get required environment variable
|
|
5
|
+
* Throws ConfigError if not set
|
|
6
|
+
*/
|
|
7
|
+
export function getRequiredEnv(name: string): string {
|
|
8
|
+
const value = process.env[name];
|
|
9
|
+
if (!value) {
|
|
10
|
+
throw new ConfigError(`Missing required environment variable: ${name}`);
|
|
11
|
+
}
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Get optional environment variable with default
|
|
17
|
+
*/
|
|
18
|
+
export function getOptionalEnv(name: string, defaultValue: string): string {
|
|
19
|
+
return process.env[name] || defaultValue;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Get optional number environment variable with default
|
|
24
|
+
* Throws ConfigError if value is not a valid number
|
|
25
|
+
*/
|
|
26
|
+
export function getOptionalNumber(name: string, defaultValue: number): number {
|
|
27
|
+
const value = process.env[name];
|
|
28
|
+
if (!value) return defaultValue;
|
|
29
|
+
const parsed = parseInt(value, 10);
|
|
30
|
+
if (Number.isNaN(parsed)) {
|
|
31
|
+
throw new ConfigError(
|
|
32
|
+
`Invalid number for ${name}: ${value} (expected integer)`
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return parsed;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Get optional boolean environment variable with default
|
|
40
|
+
* Accepts "true", "1", "yes" as truthy values
|
|
41
|
+
*/
|
|
42
|
+
export function getOptionalBoolean(
|
|
43
|
+
name: string,
|
|
44
|
+
defaultValue: boolean
|
|
45
|
+
): boolean {
|
|
46
|
+
const value = process.env[name];
|
|
47
|
+
if (!value) return defaultValue;
|
|
48
|
+
const lower = value.toLowerCase();
|
|
49
|
+
return lower === "true" || lower === "1" || lower === "yes";
|
|
50
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createLogger } from "../logger";
|
|
2
|
+
|
|
3
|
+
const logger = createLogger("json-utils");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Safely parse JSON string
|
|
7
|
+
* Returns null on parse failure instead of throwing
|
|
8
|
+
*/
|
|
9
|
+
export function safeJsonParse<T = unknown>(
|
|
10
|
+
data: string,
|
|
11
|
+
fallback: T | null = null
|
|
12
|
+
): T | null {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(data) as T;
|
|
15
|
+
} catch (error) {
|
|
16
|
+
logger.debug("JSON parse failed", {
|
|
17
|
+
error: error instanceof Error ? error.message : String(error),
|
|
18
|
+
dataPreview: data.substring(0, 100),
|
|
19
|
+
});
|
|
20
|
+
return fallback;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Safely stringify value to JSON
|
|
26
|
+
* Returns null on stringify failure instead of throwing
|
|
27
|
+
*/
|
|
28
|
+
export function safeJsonStringify(value: unknown): string | null {
|
|
29
|
+
try {
|
|
30
|
+
return JSON.stringify(value);
|
|
31
|
+
} catch (error) {
|
|
32
|
+
logger.error("JSON stringify failed", {
|
|
33
|
+
error: error instanceof Error ? error.message : String(error),
|
|
34
|
+
});
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Async lock for serializing concurrent operations
|
|
3
|
+
* Prevents race conditions in async code by ensuring only one operation runs at a time
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* ```typescript
|
|
7
|
+
* class StreamSession {
|
|
8
|
+
* private streamLock = new AsyncLock();
|
|
9
|
+
*
|
|
10
|
+
* async appendDelta(delta: string) {
|
|
11
|
+
* return this.streamLock.acquire(() => this.appendDeltaUnsafe(delta));
|
|
12
|
+
* }
|
|
13
|
+
*
|
|
14
|
+
* private async appendDeltaUnsafe(delta: string) {
|
|
15
|
+
* // Critical section - only one execution at a time
|
|
16
|
+
* }
|
|
17
|
+
* }
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export class AsyncLock {
|
|
21
|
+
private lock: Promise<void> = Promise.resolve();
|
|
22
|
+
private lockContext: string;
|
|
23
|
+
|
|
24
|
+
constructor(context: string = "unknown") {
|
|
25
|
+
this.lockContext = context;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Acquire lock and execute function exclusively
|
|
30
|
+
*
|
|
31
|
+
* @param fn - The async function to execute with exclusive access
|
|
32
|
+
* @param timeoutMs - Maximum time to wait for lock acquisition (default: 30s)
|
|
33
|
+
* @returns The result of the function
|
|
34
|
+
* @throws Error if lock acquisition times out
|
|
35
|
+
*/
|
|
36
|
+
async acquire<T>(
|
|
37
|
+
fn: () => Promise<T>,
|
|
38
|
+
timeoutMs: number = 30000
|
|
39
|
+
): Promise<T> {
|
|
40
|
+
const currentLock = this.lock;
|
|
41
|
+
let releaseLock: (() => void) | undefined;
|
|
42
|
+
|
|
43
|
+
// Create new lock that will be released when fn completes
|
|
44
|
+
this.lock = new Promise<void>((resolve) => {
|
|
45
|
+
releaseLock = resolve;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
// Wait for previous operation with timeout to prevent deadlock
|
|
52
|
+
const lockTimeout = new Promise<never>((_, reject) => {
|
|
53
|
+
timeoutId = setTimeout(() => {
|
|
54
|
+
reject(
|
|
55
|
+
new Error(
|
|
56
|
+
`Lock acquisition timeout after ${timeoutMs}ms - possible deadlock in ${this.lockContext}`
|
|
57
|
+
)
|
|
58
|
+
);
|
|
59
|
+
}, timeoutMs);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
await Promise.race([currentLock, lockTimeout]);
|
|
63
|
+
|
|
64
|
+
// Execute function with exclusive access
|
|
65
|
+
return await fn();
|
|
66
|
+
} finally {
|
|
67
|
+
// Clear the timeout to prevent leak
|
|
68
|
+
if (timeoutId !== undefined) {
|
|
69
|
+
clearTimeout(timeoutId);
|
|
70
|
+
}
|
|
71
|
+
// Always release lock, even on error
|
|
72
|
+
releaseLock?.();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { createLogger } from "../logger";
|
|
2
|
+
|
|
3
|
+
const logger = createLogger("retry");
|
|
4
|
+
|
|
5
|
+
export interface RetryOptions {
|
|
6
|
+
maxRetries?: number;
|
|
7
|
+
baseDelay?: number;
|
|
8
|
+
strategy?: "exponential" | "linear";
|
|
9
|
+
jitter?: boolean;
|
|
10
|
+
onRetry?: (attempt: number, error: Error) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Retry a function with configurable backoff strategy
|
|
15
|
+
*
|
|
16
|
+
* @param fn - The async function to retry
|
|
17
|
+
* @param options - Retry configuration
|
|
18
|
+
* @returns The result of the function
|
|
19
|
+
* @throws The last error if all retries fail
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```typescript
|
|
23
|
+
* // Exponential backoff (default)
|
|
24
|
+
* const result = await retryWithBackoff(
|
|
25
|
+
* () => fetch('https://api.example.com'),
|
|
26
|
+
* { maxRetries: 3, baseDelay: 1000 }
|
|
27
|
+
* );
|
|
28
|
+
*
|
|
29
|
+
* // Linear backoff with jitter
|
|
30
|
+
* const result = await retryWithBackoff(
|
|
31
|
+
* () => createDeployment(),
|
|
32
|
+
* {
|
|
33
|
+
* maxRetries: 3,
|
|
34
|
+
* strategy: 'linear',
|
|
35
|
+
* jitter: true,
|
|
36
|
+
* baseDelay: 2000,
|
|
37
|
+
* onRetry: (attempt, error) => {
|
|
38
|
+
* logger.warn(`Attempt ${attempt} failed: ${error.message}`);
|
|
39
|
+
* }
|
|
40
|
+
* }
|
|
41
|
+
* );
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export async function retryWithBackoff<T>(
|
|
45
|
+
fn: () => Promise<T>,
|
|
46
|
+
options: RetryOptions = {}
|
|
47
|
+
): Promise<T> {
|
|
48
|
+
const {
|
|
49
|
+
maxRetries = 3,
|
|
50
|
+
baseDelay = 1000,
|
|
51
|
+
strategy = "exponential",
|
|
52
|
+
jitter = false,
|
|
53
|
+
onRetry,
|
|
54
|
+
} = options;
|
|
55
|
+
|
|
56
|
+
let lastError: Error | undefined;
|
|
57
|
+
|
|
58
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
59
|
+
try {
|
|
60
|
+
return await fn();
|
|
61
|
+
} catch (error) {
|
|
62
|
+
lastError = error as Error;
|
|
63
|
+
|
|
64
|
+
if (attempt < maxRetries) {
|
|
65
|
+
// Calculate delay based on strategy
|
|
66
|
+
const delay =
|
|
67
|
+
strategy === "exponential"
|
|
68
|
+
? baseDelay * 2 ** attempt
|
|
69
|
+
: baseDelay * (attempt + 1);
|
|
70
|
+
|
|
71
|
+
// Add jitter if requested (0-1000ms random)
|
|
72
|
+
const jitterMs = jitter ? Math.random() * 1000 : 0;
|
|
73
|
+
const finalDelay = delay + jitterMs;
|
|
74
|
+
|
|
75
|
+
// Notify caller of retry
|
|
76
|
+
if (onRetry) {
|
|
77
|
+
onRetry(attempt + 1, lastError);
|
|
78
|
+
} else {
|
|
79
|
+
logger.warn(
|
|
80
|
+
`Retry attempt ${attempt + 1}/${maxRetries} after ${Math.round(finalDelay)}ms`,
|
|
81
|
+
{ error: lastError.message }
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
await new Promise((resolve) => setTimeout(resolve, finalDelay));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
throw lastError;
|
|
91
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sanitize filename to prevent path traversal attacks
|
|
3
|
+
* Removes directory separators and dangerous characters
|
|
4
|
+
*
|
|
5
|
+
* @param filename - The filename to sanitize
|
|
6
|
+
* @param maxLength - Maximum filename length (default: 255)
|
|
7
|
+
* @returns Safe filename
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* sanitizeFilename("../../etc/passwd") // "etc_passwd"
|
|
12
|
+
* sanitizeFilename("file<>|name.txt") // "file___name.txt"
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
export function sanitizeFilename(
|
|
16
|
+
filename: string,
|
|
17
|
+
maxLength: number = 255
|
|
18
|
+
): string {
|
|
19
|
+
// Remove any directory path components
|
|
20
|
+
const basename = filename.replace(/^.*[\\/]/, "");
|
|
21
|
+
|
|
22
|
+
// Remove null bytes and other dangerous characters
|
|
23
|
+
const sanitized = basename.replace(/[^\w\s.-]/g, "_");
|
|
24
|
+
|
|
25
|
+
// Prevent hidden files and parent directory references
|
|
26
|
+
const safe = sanitized.replace(/^\.+/, "").replace(/\.{2,}/g, ".");
|
|
27
|
+
|
|
28
|
+
// Ensure filename is not empty after sanitization
|
|
29
|
+
if (!safe || safe.length === 0) {
|
|
30
|
+
return "unnamed_file";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Limit filename length
|
|
34
|
+
return safe.length > maxLength ? safe.substring(0, maxLength) : safe;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Sanitize conversation ID for filesystem usage
|
|
39
|
+
* Removes any characters that aren't safe for directory names
|
|
40
|
+
*
|
|
41
|
+
* @param conversationId - The conversation ID to sanitize
|
|
42
|
+
* @returns Safe conversation ID
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```typescript
|
|
46
|
+
* sanitizeConversationId("1756766056.836119") // "1756766056.836119"
|
|
47
|
+
* sanitizeConversationId("thread/123/../456") // "thread_123___456"
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export function sanitizeConversationId(conversationId: string): string {
|
|
51
|
+
return conversationId.replace(/[^a-zA-Z0-9.-]/g, "_");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Sanitize sensitive data from objects before logging
|
|
56
|
+
* Redacts API keys, tokens, and other credentials
|
|
57
|
+
*
|
|
58
|
+
* @param obj - Object to sanitize
|
|
59
|
+
* @param sensitiveKeys - Additional sensitive key names to redact
|
|
60
|
+
* @returns Sanitized object safe for logging
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```typescript
|
|
64
|
+
* const config = {
|
|
65
|
+
* apiKey: "secret-key-123",
|
|
66
|
+
* timeout: 5000,
|
|
67
|
+
* env: { TOKEN: "bearer-xyz" }
|
|
68
|
+
* };
|
|
69
|
+
*
|
|
70
|
+
* sanitizeForLogging(config)
|
|
71
|
+
* // {
|
|
72
|
+
* // apiKey: "[REDACTED:14]",
|
|
73
|
+
* // timeout: 5000,
|
|
74
|
+
* // env: { TOKEN: "[REDACTED:10]" }
|
|
75
|
+
* // }
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
export function sanitizeForLogging(
|
|
79
|
+
obj: any,
|
|
80
|
+
additionalSensitiveKeys: string[] = []
|
|
81
|
+
): any {
|
|
82
|
+
if (!obj || typeof obj !== "object") {
|
|
83
|
+
return obj;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const defaultSensitiveKeys = [
|
|
87
|
+
"anthropic_api_key",
|
|
88
|
+
"api_key",
|
|
89
|
+
"apiKey",
|
|
90
|
+
"token",
|
|
91
|
+
"password",
|
|
92
|
+
"secret",
|
|
93
|
+
"authorization",
|
|
94
|
+
"bearer",
|
|
95
|
+
"credentials",
|
|
96
|
+
"privateKey",
|
|
97
|
+
"private_key",
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
const sensitiveKeys = [...defaultSensitiveKeys, ...additionalSensitiveKeys];
|
|
101
|
+
|
|
102
|
+
const sanitized = Array.isArray(obj) ? [...obj] : { ...obj };
|
|
103
|
+
|
|
104
|
+
for (const key in sanitized) {
|
|
105
|
+
const lowerKey = key.toLowerCase();
|
|
106
|
+
const isSensitive = sensitiveKeys.some((k) => lowerKey.includes(k));
|
|
107
|
+
|
|
108
|
+
if (isSensitive && typeof sanitized[key] === "string") {
|
|
109
|
+
// Redact but show length for debugging
|
|
110
|
+
sanitized[key] = `[REDACTED:${sanitized[key].length}]`;
|
|
111
|
+
} else if (key === "env" && typeof sanitized[key] === "object") {
|
|
112
|
+
// Recursively sanitize env object
|
|
113
|
+
sanitized[key] = sanitizeForLogging(
|
|
114
|
+
sanitized[key],
|
|
115
|
+
additionalSensitiveKeys
|
|
116
|
+
);
|
|
117
|
+
} else if (typeof sanitized[key] === "object") {
|
|
118
|
+
// Recursively sanitize nested objects
|
|
119
|
+
sanitized[key] = sanitizeForLogging(
|
|
120
|
+
sanitized[key],
|
|
121
|
+
additionalSensitiveKeys
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return sanitized;
|
|
127
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { createLogger } from "../logger";
|
|
2
|
+
import { decrypt, encrypt } from "../utils/encryption";
|
|
3
|
+
|
|
4
|
+
const logger = createLogger("worker-auth");
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Worker authentication using encrypted conversation ID
|
|
8
|
+
* Token format: encrypted(userId:conversationId:deploymentName:timestamp)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface WorkerTokenData {
|
|
12
|
+
userId: string;
|
|
13
|
+
conversationId: string;
|
|
14
|
+
channelId: string;
|
|
15
|
+
teamId?: string; // Optional - not all platforms have teams
|
|
16
|
+
agentId?: string; // Space ID for multi-tenant isolation
|
|
17
|
+
connectionId?: string;
|
|
18
|
+
deploymentName: string;
|
|
19
|
+
timestamp: number;
|
|
20
|
+
platform?: string;
|
|
21
|
+
sessionKey?: string;
|
|
22
|
+
traceId?: string; // Trace ID for end-to-end observability
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Generate a worker authentication token by encrypting thread metadata
|
|
27
|
+
*/
|
|
28
|
+
export function generateWorkerToken(
|
|
29
|
+
userId: string,
|
|
30
|
+
conversationId: string,
|
|
31
|
+
deploymentName: string,
|
|
32
|
+
options: {
|
|
33
|
+
channelId: string;
|
|
34
|
+
teamId?: string;
|
|
35
|
+
agentId?: string;
|
|
36
|
+
connectionId?: string;
|
|
37
|
+
platform?: string;
|
|
38
|
+
sessionKey?: string;
|
|
39
|
+
traceId?: string; // Trace ID for end-to-end observability
|
|
40
|
+
}
|
|
41
|
+
): string {
|
|
42
|
+
// Validate required fields
|
|
43
|
+
if (!options.channelId) {
|
|
44
|
+
throw new Error("channelId is required for worker token generation");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const timestamp = Date.now();
|
|
48
|
+
const payload: WorkerTokenData = {
|
|
49
|
+
userId,
|
|
50
|
+
conversationId,
|
|
51
|
+
channelId: options.channelId,
|
|
52
|
+
teamId: options.teamId, // Can be undefined - that's ok
|
|
53
|
+
agentId: options.agentId, // Space ID for multi-tenant credential lookup
|
|
54
|
+
connectionId: options.connectionId,
|
|
55
|
+
deploymentName,
|
|
56
|
+
timestamp,
|
|
57
|
+
platform: options.platform,
|
|
58
|
+
sessionKey: options.sessionKey,
|
|
59
|
+
traceId: options.traceId, // Trace ID for observability
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Encrypt the payload
|
|
63
|
+
const encrypted = encrypt(JSON.stringify(payload));
|
|
64
|
+
return encrypted;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Verify and decrypt a worker authentication token
|
|
69
|
+
*/
|
|
70
|
+
export function verifyWorkerToken(token: string): WorkerTokenData | null {
|
|
71
|
+
try {
|
|
72
|
+
// Decrypt the token
|
|
73
|
+
const decrypted = decrypt(token);
|
|
74
|
+
const data = JSON.parse(decrypted) as WorkerTokenData;
|
|
75
|
+
|
|
76
|
+
if (
|
|
77
|
+
!data.conversationId ||
|
|
78
|
+
!data.userId ||
|
|
79
|
+
!data.deploymentName ||
|
|
80
|
+
!data.timestamp
|
|
81
|
+
) {
|
|
82
|
+
logger.error("Worker token rejected: missing required fields");
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Check token expiration (default 24h)
|
|
87
|
+
const parsedTtl = parseInt(process.env.WORKER_TOKEN_TTL_MS ?? "", 10);
|
|
88
|
+
const ttl =
|
|
89
|
+
!Number.isNaN(parsedTtl) && parsedTtl > 0 ? parsedTtl : 86400000;
|
|
90
|
+
if (Date.now() - data.timestamp > ttl) {
|
|
91
|
+
logger.error("Worker token rejected: expired");
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return data;
|
|
96
|
+
} catch (error) {
|
|
97
|
+
logger.error("Error verifying token:", error);
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker Transport Interface
|
|
3
|
+
* Defines how workers communicate with the gateway (platform-agnostic)
|
|
4
|
+
*
|
|
5
|
+
* This abstraction allows different transport implementations:
|
|
6
|
+
* - HTTP (current implementation)
|
|
7
|
+
* - WebSocket (for real-time bidirectional communication)
|
|
8
|
+
* - gRPC (for high-performance scenarios)
|
|
9
|
+
* - Message Queue (for asynchronous processing)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Transport interface for worker-to-gateway communication
|
|
14
|
+
*/
|
|
15
|
+
export interface WorkerTransport {
|
|
16
|
+
/**
|
|
17
|
+
* Set the job ID for this worker session
|
|
18
|
+
* Used to correlate responses with the originating request
|
|
19
|
+
*/
|
|
20
|
+
setJobId(jobId: string): void;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Set module-specific data to be included in responses
|
|
24
|
+
* Allows modules to attach metadata to worker responses
|
|
25
|
+
*/
|
|
26
|
+
setModuleData(moduleData: Record<string, unknown>): void;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Send a streaming delta to the gateway
|
|
30
|
+
*
|
|
31
|
+
* @param delta - The content delta to send
|
|
32
|
+
* @param isFullReplacement - If true, replaces entire content; if false, appends
|
|
33
|
+
* @param isFinal - If true, indicates this is the final delta
|
|
34
|
+
*/
|
|
35
|
+
sendStreamDelta(
|
|
36
|
+
delta: string,
|
|
37
|
+
isFullReplacement?: boolean,
|
|
38
|
+
isFinal?: boolean
|
|
39
|
+
): Promise<void>;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Signal that the worker has completed processing
|
|
43
|
+
* Optionally includes a final delta
|
|
44
|
+
*
|
|
45
|
+
* @param finalDelta - Optional final content delta
|
|
46
|
+
*/
|
|
47
|
+
signalDone(finalDelta?: string): Promise<void>;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Signal successful completion without additional content
|
|
51
|
+
*/
|
|
52
|
+
signalCompletion(): Promise<void>;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Signal that an error occurred during processing
|
|
56
|
+
*
|
|
57
|
+
* @param error - The error that occurred
|
|
58
|
+
*/
|
|
59
|
+
signalError(error: Error, errorCode?: string): Promise<void>;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Send a status update to the gateway
|
|
63
|
+
* Used for long-running operations to show progress
|
|
64
|
+
*
|
|
65
|
+
* @param elapsedSeconds - Time elapsed since operation started
|
|
66
|
+
* @param state - Current state description (e.g., "processing", "waiting for API")
|
|
67
|
+
*/
|
|
68
|
+
sendStatusUpdate(elapsedSeconds: number, state: string): Promise<void>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Configuration for creating a worker transport
|
|
73
|
+
*/
|
|
74
|
+
export interface WorkerTransportConfig {
|
|
75
|
+
/** Gateway URL for sending responses */
|
|
76
|
+
gatewayUrl: string;
|
|
77
|
+
|
|
78
|
+
/** Authentication token for worker */
|
|
79
|
+
workerToken: string;
|
|
80
|
+
|
|
81
|
+
/** User ID who initiated the request */
|
|
82
|
+
userId: string;
|
|
83
|
+
|
|
84
|
+
/** Channel/conversation ID */
|
|
85
|
+
channelId: string;
|
|
86
|
+
|
|
87
|
+
/** Conversation ID for organizing messages */
|
|
88
|
+
conversationId: string;
|
|
89
|
+
|
|
90
|
+
/** Original message timestamp/ID */
|
|
91
|
+
originalMessageTs: string;
|
|
92
|
+
|
|
93
|
+
/** Bot's response message timestamp/ID (if exists) */
|
|
94
|
+
botResponseTs?: string;
|
|
95
|
+
|
|
96
|
+
/** Team/workspace ID (required for all platforms) */
|
|
97
|
+
teamId: string;
|
|
98
|
+
|
|
99
|
+
/** Platform identifier (slack, whatsapp, api, etc.) */
|
|
100
|
+
platform?: string;
|
|
101
|
+
|
|
102
|
+
/** Platform-specific metadata needed for response routing */
|
|
103
|
+
platformMetadata?: Record<string, unknown>;
|
|
104
|
+
|
|
105
|
+
/** IDs of messages already processed (for deduplication) */
|
|
106
|
+
processedMessageIds?: string[];
|
|
107
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"outDir": "dist",
|
|
4
|
+
"rootDir": "src",
|
|
5
|
+
"declaration": true,
|
|
6
|
+
"declarationMap": true,
|
|
7
|
+
"sourceMap": true,
|
|
8
|
+
"noEmit": false,
|
|
9
|
+
"moduleResolution": "node",
|
|
10
|
+
"module": "CommonJS",
|
|
11
|
+
"target": "ES2020",
|
|
12
|
+
"strict": true,
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"esModuleInterop": true,
|
|
15
|
+
"allowSyntheticDefaultImports": true,
|
|
16
|
+
"composite": true
|
|
17
|
+
},
|
|
18
|
+
"include": ["src/**/*"],
|
|
19
|
+
"exclude": ["node_modules", "dist"]
|
|
20
|
+
}
|