/** * Fetch B2 Result Step — download a JSON result from Backblaze B2 via S3-compatible presigned URL. * Config: { * object_key: "{{context.diagnostic_object_key}}", // key in the bucket * output_key: "diagnostic_results", // context key for parsed JSON * bucket?: "wulf-audits", // defaults to B2_BUCKET env * } */ import crypto from 'crypto'; import { registerStepExecutor } from '../pipeline-engine'; import { PipelineStep, PipelineContext, StepExecutorResult } from '../../types/pipeline'; const B2_DEFAULTS = { bucket: process.env.B2_BUCKET || 'wulf-audits', region: process.env.B2_REGION || 'us-west-002', endpoint: process.env.B2_ENDPOINT || 's3.us-west-002.backblazeb2.com', keyId: process.env.B2_KEY_ID || '', appKey: process.env.B2_APP_KEY || '', }; function hmacSha256(key: string | Buffer, data: string): Buffer { return crypto.createHmac('sha256', key).update(data).digest(); } function generatePresignedUrl(objectKey: string, bucket?: string): string { const { region, endpoint, keyId, appKey } = B2_DEFAULTS; const bkt = bucket || B2_DEFAULTS.bucket; if (!keyId || !appKey) { throw new Error('B2_KEY_ID and B2_APP_KEY environment variables are required'); } const expiresIn = 600; // 10 minutes const method = 'GET'; const host = endpoint; const canonicalUri = `/${bkt}/${objectKey}`; const algorithm = 'AWS4-HMAC-SHA256'; const now = new Date(); const amzDate = now.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; const dateStamp = amzDate.slice(0, 8); const credentialScope = `${dateStamp}/${region}/s3/aws4_request`; const canonicalHeaders = `host:${host}\n`; const signedHeaders = 'host'; const qs: Record = { 'X-Amz-Algorithm': algorithm, 'X-Amz-Credential': encodeURIComponent(`${keyId}/${credentialScope}`), 'X-Amz-Date': amzDate, 'X-Amz-Expires': expiresIn.toString(), 'X-Amz-SignedHeaders': signedHeaders, }; const canonicalQueryString = Object.keys(qs) .sort() .map((k) => `${k}=${qs[k]}`) .join('&'); const payloadHash = 'UNSIGNED-PAYLOAD'; const canonicalRequest = [ method, canonicalUri, canonicalQueryString, canonicalHeaders, signedHeaders, payloadHash, ].join('\n'); const stringToSign = [ algorithm, amzDate, credentialScope, crypto.createHash('sha256').update(canonicalRequest).digest('hex'), ].join('\n'); // Derive signing key const kDate = hmacSha256('AWS4' + appKey, dateStamp); const kRegion = hmacSha256(kDate, region); const kService = hmacSha256(kRegion, 's3'); const kSigning = hmacSha256(kService, 'aws4_request'); const signature = crypto .createHmac('sha256', kSigning) .update(stringToSign) .digest('hex'); return `https://${host}${canonicalUri}?${canonicalQueryString}&X-Amz-Signature=${signature}`; } async function executeFetchB2Result( step: PipelineStep, _context: PipelineContext, _executionId: number ): Promise { const objectKey = step.config.object_key; const outputKey = step.config.output_key || 'b2_result'; const bucket = step.config.bucket; if (!objectKey) { return { success: false, error: 'Missing object_key in fetch_b2_result config' }; } console.log(`[PIPELINE:fetch_b2_result] Fetching ${objectKey} from B2`); try { const url = generatePresignedUrl(objectKey, bucket); const response = await fetch(url); if (!response.ok) { return { success: false, error: `B2 download failed: ${response.status} ${response.statusText}`, }; } const text = await response.text(); let parsed: any; try { parsed = JSON.parse(text); } catch { // Not JSON — store as raw text parsed = text; } console.log(`[PIPELINE:fetch_b2_result] Downloaded ${text.length} bytes, parsed as ${typeof parsed}`); return { success: true, output: { [outputKey]: parsed, [`${outputKey}_raw_length`]: text.length, b2_object_key: objectKey, }, }; } catch (err: any) { return { success: false, error: `B2 fetch error: ${err.message}`, }; } } registerStepExecutor('fetch_b2_result', executeFetchB2Result);