343 lines
10 KiB
TypeScript
343 lines
10 KiB
TypeScript
import { Client } from 'https://deno.land/x/postgres@v0.17.0/mod.ts';
|
|
import { join, dirname, fromFileUrl } from 'https://deno.land/std@0.224.0/path/mod.ts';
|
|
|
|
/* eslint-disable no-console */
|
|
const rawPort = Deno.env.get('PORT');
|
|
const hasValidNumericPort = rawPort !== undefined && /^\d+$/.test(rawPort);
|
|
const parsedPort = hasValidNumericPort ? Number(rawPort) : Number.NaN;
|
|
const port =
|
|
Number.isInteger(parsedPort) && parsedPort >= 1 && parsedPort <= 65535 ? parsedPort : 7133;
|
|
const hostname = Deno.env.get('HOST') ?? '::';
|
|
|
|
if (rawPort !== undefined && port === 7133 && rawPort !== '7133') {
|
|
console.warn(`Invalid PORT value "${rawPort}", falling back to 7133`);
|
|
}
|
|
|
|
console.log(`Deno serverless runtime running on ${hostname}:${port}`);
|
|
|
|
// Configuration
|
|
const WORKER_TIMEOUT_MS = parseInt(Deno.env.get('WORKER_TIMEOUT_MS') ?? '60000');
|
|
|
|
// Worker template code - loaded on first use
|
|
let workerTemplateCode: string | null = null;
|
|
|
|
async function getWorkerTemplateCode(): Promise<string> {
|
|
if (!workerTemplateCode) {
|
|
const currentDir = dirname(fromFileUrl(import.meta.url));
|
|
workerTemplateCode = await Deno.readTextFile(join(currentDir, 'worker-template.js'));
|
|
}
|
|
return workerTemplateCode;
|
|
}
|
|
|
|
// Decrypt function for Deno (compatible with Node.js encryption)
|
|
async function decryptSecret(ciphertext: string, key: string): Promise<string> {
|
|
try {
|
|
const parts = ciphertext.split(':');
|
|
if (parts.length !== 3) {
|
|
throw new Error('Invalid ciphertext format');
|
|
}
|
|
|
|
// Get the encryption key by hashing the JWT secret
|
|
const keyData = new TextEncoder().encode(key);
|
|
const hashBuffer = await crypto.subtle.digest('SHA-256', keyData);
|
|
const cryptoKey = await crypto.subtle.importKey('raw', hashBuffer, { name: 'AES-GCM' }, false, [
|
|
'decrypt',
|
|
]);
|
|
|
|
// Extract IV, auth tag, and encrypted data
|
|
const iv = Uint8Array.from(parts[0].match(/.{2}/g)!.map((byte) => parseInt(byte, 16)));
|
|
const authTag = Uint8Array.from(parts[1].match(/.{2}/g)!.map((byte) => parseInt(byte, 16)));
|
|
const encrypted = Uint8Array.from(parts[2].match(/.{2}/g)!.map((byte) => parseInt(byte, 16)));
|
|
|
|
// Combine encrypted data and auth tag (GCM expects them together)
|
|
const cipherData = new Uint8Array(encrypted.length + authTag.length);
|
|
cipherData.set(encrypted);
|
|
cipherData.set(authTag, encrypted.length);
|
|
|
|
// Decrypt
|
|
const decryptedBuffer = await crypto.subtle.decrypt(
|
|
{ name: 'AES-GCM', iv },
|
|
cryptoKey,
|
|
cipherData
|
|
);
|
|
|
|
return new TextDecoder().decode(decryptedBuffer);
|
|
} catch (error) {
|
|
console.error('Failed to decrypt secret:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Database connection
|
|
const dbConfig = {
|
|
user: Deno.env.get('POSTGRES_USER') || 'postgres',
|
|
password: Deno.env.get('POSTGRES_PASSWORD') || 'postgres',
|
|
database: Deno.env.get('POSTGRES_DB') || 'insforge',
|
|
hostname: Deno.env.get('POSTGRES_HOST') || 'postgres',
|
|
port: parseInt(Deno.env.get('POSTGRES_PORT') || '5432', 10),
|
|
};
|
|
|
|
// Get function code from database
|
|
async function getFunctionCode(slug: string): Promise<string | null> {
|
|
const client = new Client(dbConfig);
|
|
|
|
try {
|
|
await client.connect();
|
|
|
|
const result = await client.queryObject<{ code: string }>`
|
|
SELECT code FROM functions.definitions
|
|
WHERE slug = ${slug} AND status = 'active'
|
|
`;
|
|
|
|
if (!result.rows.length) {
|
|
return null;
|
|
}
|
|
|
|
return result.rows[0].code;
|
|
} catch (error) {
|
|
console.error(`Error fetching function ${slug}:`, error);
|
|
return null;
|
|
} finally {
|
|
await client.end();
|
|
}
|
|
}
|
|
|
|
// Get all secrets from main secrets table and decrypt them
|
|
async function getFunctionSecrets(): Promise<Record<string, string>> {
|
|
const client = new Client(dbConfig);
|
|
|
|
try {
|
|
await client.connect();
|
|
|
|
// Get the encryption key from environment
|
|
const encryptionKey = Deno.env.get('ENCRYPTION_KEY') || Deno.env.get('JWT_SECRET');
|
|
if (!encryptionKey) {
|
|
console.error('No encryption key available for decrypting secrets');
|
|
return {};
|
|
}
|
|
|
|
// Fetch all active secrets from system.secrets table
|
|
const result = await client.queryObject<{
|
|
key: string;
|
|
value_ciphertext: string;
|
|
}>`
|
|
SELECT key, value_ciphertext
|
|
FROM system.secrets
|
|
WHERE is_active = true
|
|
AND (expires_at IS NULL OR expires_at > NOW())
|
|
`;
|
|
|
|
const secrets: Record<string, string> = {};
|
|
|
|
// Decrypt each secret
|
|
for (const row of result.rows) {
|
|
try {
|
|
secrets[row.key] = await decryptSecret(row.value_ciphertext, encryptionKey);
|
|
} catch (error) {
|
|
console.error(`Failed to decrypt secret ${row.key}:`, error);
|
|
// Skip this secret if decryption fails
|
|
}
|
|
}
|
|
|
|
return secrets;
|
|
} catch (error) {
|
|
console.error('Error fetching secrets:', error);
|
|
return {};
|
|
} finally {
|
|
await client.end();
|
|
}
|
|
}
|
|
|
|
// Execute function in isolated worker
|
|
async function executeInWorker(code: string, request: Request): Promise<Response> {
|
|
// Get worker template
|
|
const template = await getWorkerTemplateCode();
|
|
|
|
// Fetch all function secrets
|
|
const secrets = await getFunctionSecrets();
|
|
|
|
// Create blob for worker
|
|
const workerBlob = new Blob([template], { type: 'application/javascript' });
|
|
const workerUrl = URL.createObjectURL(workerBlob);
|
|
|
|
return new Promise(async (resolve) => {
|
|
const worker = new Worker(workerUrl, {
|
|
type: 'module',
|
|
deno: {
|
|
permissions: {
|
|
// RESTRICTED: Native environment access is disabled to shield host secrets.
|
|
// Secrets are passed explicitly via message payload.
|
|
env: false,
|
|
// SDK/External REQUIREMENT: Allow network access for API connectivity and external integrations.
|
|
net: true,
|
|
read: false,
|
|
write: false,
|
|
run: false,
|
|
ffi: false,
|
|
sys: false,
|
|
import: false,
|
|
hrtime: false,
|
|
},
|
|
},
|
|
});
|
|
|
|
// Set timeout for worker execution
|
|
const timeout = setTimeout(() => {
|
|
worker.terminate();
|
|
URL.revokeObjectURL(workerUrl);
|
|
resolve(
|
|
new Response(JSON.stringify({ error: 'Function timeout' }), {
|
|
status: 504,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
);
|
|
}, WORKER_TIMEOUT_MS);
|
|
|
|
// Handle worker response
|
|
worker.onmessage = (e) => {
|
|
clearTimeout(timeout);
|
|
worker.terminate();
|
|
URL.revokeObjectURL(workerUrl);
|
|
|
|
if (e.data.success) {
|
|
const { response } = e.data;
|
|
// The worker now properly sends null for bodyless responses
|
|
resolve(
|
|
new Response(response.body, {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
headers: response.headers,
|
|
})
|
|
);
|
|
} else {
|
|
resolve(
|
|
new Response(JSON.stringify({ error: e.data.error }), {
|
|
status: e.data.status || 500,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
);
|
|
}
|
|
};
|
|
|
|
// Handle worker errors
|
|
worker.onerror = (error) => {
|
|
clearTimeout(timeout);
|
|
worker.terminate();
|
|
URL.revokeObjectURL(workerUrl);
|
|
console.error('Worker error:', error);
|
|
resolve(
|
|
new Response(JSON.stringify({ error: 'Worker execution error' }), {
|
|
status: 500,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
);
|
|
};
|
|
|
|
// Prepare request data
|
|
const body = request.body ? await request.text() : null;
|
|
const requestData = {
|
|
url: request.url,
|
|
method: request.method,
|
|
headers: Object.fromEntries(request.headers),
|
|
body,
|
|
};
|
|
|
|
// Send message with code, request data, and secrets
|
|
worker.postMessage({ code, requestData, secrets });
|
|
});
|
|
}
|
|
|
|
Deno.serve({ hostname, port }, async (req: Request) => {
|
|
const url = new URL(req.url);
|
|
const pathname = url.pathname;
|
|
|
|
// Health check
|
|
if (pathname === '/health') {
|
|
return new Response(
|
|
JSON.stringify({
|
|
status: 'ok',
|
|
runtime: 'deno',
|
|
version: Deno.version.deno,
|
|
typescript: Deno.version.typescript,
|
|
v8: Deno.version.v8,
|
|
}),
|
|
{
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}
|
|
);
|
|
}
|
|
|
|
// Function execution - match ONLY exact slug, no subpaths
|
|
const slugMatch = pathname.match(/^\/([a-zA-Z0-9_-]+)$/);
|
|
if (slugMatch) {
|
|
const slug = slugMatch[1];
|
|
const startTime = Date.now();
|
|
|
|
// Get function code from database
|
|
const code = await getFunctionCode(slug);
|
|
|
|
if (!code) {
|
|
return new Response(JSON.stringify({ error: 'Function not found or not active' }), {
|
|
status: 404,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
// Execute in worker with original request
|
|
try {
|
|
const response = await executeInWorker(code, req);
|
|
const duration = Date.now() - startTime;
|
|
|
|
// Log completed invocations only
|
|
console.log(
|
|
JSON.stringify({
|
|
timestamp: new Date().toISOString(),
|
|
level: 'info',
|
|
slug,
|
|
method: req.method,
|
|
status: response.status,
|
|
duration: `${duration}ms`,
|
|
})
|
|
);
|
|
|
|
return response;
|
|
} catch (error) {
|
|
const duration = Date.now() - startTime;
|
|
console.error(
|
|
JSON.stringify({
|
|
timestamp: new Date().toISOString(),
|
|
level: 'error',
|
|
slug,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
duration: `${duration}ms`,
|
|
})
|
|
);
|
|
return new Response(JSON.stringify({ error: 'Function execution failed' }), {
|
|
status: 500,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
}
|
|
|
|
// Runtime info
|
|
if (pathname === '/info') {
|
|
return new Response(
|
|
JSON.stringify({
|
|
runtime: 'deno',
|
|
version: Deno.version,
|
|
env: Deno.env.get('DENO_ENV') || 'production',
|
|
database: {
|
|
host: dbConfig.hostname,
|
|
database: dbConfig.database,
|
|
},
|
|
}),
|
|
{
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}
|
|
);
|
|
}
|
|
|
|
// 404
|
|
return new Response('Not Found', { status: 404 });
|
|
});
|