import { createHash, createHmac } from 'node:crypto' import type { DB } from './db' import type { Fetcher } from './gmail' import { getSetting, setSetting } from './repos-reminders' import { previousMonth, upsertAwsUsage } from './repos-aws' /** * AWS Cost Explorer ingestion — SigV4 signed with node:crypto (no SDK), fetch * injected (Fetcher) so tests stub the network. GetCostAndUsage is grouped by * the cost-allocation tag 'client'; each tag value maps to a client by code and * upserts one aws_usage row per (client, month). Untagged/unknown tag values are * returned in the result, never dropped. Credentials arrive as a parameter — * the caller (server wiring / route) reads them from env, never this module. */ export interface AwsCreds { accessKeyId: string; secretAccessKey: string } export interface SignedRequest { url: string; method: 'POST'; headers: Record; body: string } const REGION = 'us-east-1' // Cost Explorer is a global service pinned to us-east-1 const SERVICE = 'ce' const TARGET = 'AWSInsightsIndexService.GetCostAndUsage' function sha256Hex(data: string): string { return createHash('sha256').update(data, 'utf8').digest('hex') } function hmac(key: Buffer | string, data: string): Buffer { return createHmac('sha256', key).update(data, 'utf8').digest() } /** Full SigV4: canonical request → string-to-sign → signing key → Authorization header. */ export function signAwsRequest(args: { creds: AwsCreds; region: string; service: string; target: string; body: string; now: Date }): SignedRequest { const { creds, region, service, target, body, now } = args const host = `${service}.${region}.amazonaws.com` const amzDate = now.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '') // 20260201T000000Z const dateStamp = amzDate.slice(0, 8) // 20260201 const contentType = 'application/x-amz-json-1.1' // Canonical request — headers lowercased and sorted, body SHA-256 hashed. const payloadHash = sha256Hex(body) const canonicalHeaders = `content-type:${contentType}\n` + `host:${host}\n` + `x-amz-date:${amzDate}\n` + `x-amz-target:${target}\n` const signedHeaders = 'content-type;host;x-amz-date;x-amz-target' const canonicalRequest = ['POST', '/', '', canonicalHeaders, signedHeaders, payloadHash].join('\n') // String to sign. const scope = `${dateStamp}/${region}/${service}/aws4_request` const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256Hex(canonicalRequest)].join('\n') // Derived signing key + signature. const kDate = hmac(`AWS4${creds.secretAccessKey}`, dateStamp) const kRegion = hmac(kDate, region) const kService = hmac(kRegion, service) const kSigning = hmac(kService, 'aws4_request') const signature = createHmac('sha256', kSigning).update(stringToSign, 'utf8').digest('hex') const authorization = `AWS4-HMAC-SHA256 Credential=${creds.accessKeyId}/${scope}, ` + `SignedHeaders=${signedHeaders}, Signature=${signature}` return { url: `https://${host}/`, method: 'POST', body, // 'host' is set by undici on the wire too; keeping it here documents what was signed. headers: { 'content-type': contentType, host, 'x-amz-date': amzDate, 'x-amz-target': target, authorization }, } } export interface AwsCostDeps { f: Fetcher; creds: AwsCreds; now?: () => string } export interface PullResult { upserted: number; unknownTags: { tag: string; costPaise: number }[]; totalPaise: number } interface CeGroup { Keys?: string[]; Metrics?: { UnblendedCost?: { Amount?: string; Unit?: string } } } interface CeResponse { ResultsByTime?: { Groups?: CeGroup[] }[] } /** '2026-06' → { start: '2026-06-01', end: '2026-07-01' } (End is exclusive in Cost Explorer). */ function monthBounds(month: string): { start: string; end: string } { const [y, m] = month.split('-').map(Number) return { start: `${month}-01`, end: new Date(Date.UTC(y!, m!, 1)).toISOString().slice(0, 10) } } export async function pullMonthlyCosts(db: DB, deps: AwsCostDeps, month: string): Promise { const { start, end } = monthBounds(month) const body = JSON.stringify({ TimePeriod: { Start: start, End: end }, Granularity: 'MONTHLY', Metrics: ['UnblendedCost'], GroupBy: [{ Type: 'TAG', Key: 'client' }], }) const now = deps.now !== undefined ? new Date(deps.now()) : new Date() const signed = signAwsRequest({ creds: deps.creds, region: REGION, service: SERVICE, target: TARGET, body, now }) const res = await deps.f(signed.url, { method: 'POST', headers: signed.headers, body: signed.body }) const json = (await res.json().catch(() => ({}))) as CeResponse if (!res.ok) throw new Error(`Cost Explorer request failed: HTTP ${res.status}`) const groups = json.ResultsByTime?.[0]?.Groups ?? [] const unknownTags: { tag: string; costPaise: number }[] = [] let upserted = 0 let totalPaise = 0 for (const g of groups) { const cost = g.Metrics?.UnblendedCost const unit = cost?.Unit ?? 'INR' if (unit !== 'INR') throw new Error(`Cost Explorer returned ${unit}, expected INR — HQ-3a assumes INR billing`) const tag = (g.Keys?.[0] ?? '').split('$')[1] ?? '' // 'client$acme' → 'acme'; 'client$' → '' const costPaise = Math.round(Number(cost?.Amount ?? '0') * 100) totalPaise += costPaise const client = tag === '' ? undefined : db.prepare(`SELECT id FROM client WHERE lower(code)=lower(?)`).get(tag) as { id: string } | undefined if (client === undefined) { unknownTags.push({ tag, costPaise }); continue } upsertAwsUsage(db, 'system', { clientId: client.id, month, costPaise, source: 'auto' }) upserted += 1 } return { upserted, unknownTags, totalPaise } } /** Gate the pull to once per calendar month; the 6-hourly tick calls this. */ export async function maybePullAwsCosts(db: DB, deps: AwsCostDeps, today: string): Promise { const target = previousMonth(today) if (getSetting(db, 'aws.last_pull_month') === target) return null const out = await pullMonthlyCosts(db, deps, target) setSetting(db, 'system', 'aws.last_pull_month', target) return out }