/** * Proxy genérico para o core-api. * * Fluxo: * 1. Lê cookie de sessão → busca tokens em Redis (refresh transparente se expirado) * 2. Resolve o tenant (dominio) a partir do hostname * 3. Busca codigoMunicipio via /publico/prefeitura/{dominio} (cacheado em Redis) * 4. Forward para core-api com Authorization + X-Municipio + X-Dominio * * Bypass: rotas /api/v1/publico/** podem ser acessadas sem sessão. */ export default defineEventHandler(async (event) => { const path = getRouterParam(event, 'path') ?? '' const isPublico = path.startsWith('publico/') let accessToken: string | null = null if (!isPublico) { const sid = readSessionCookie(event) if (!sid) { throw createError({ statusCode: 401, statusMessage: 'Sem sessão' }) } accessToken = await getValidAccessToken(sid) if (!accessToken) { clearSessionCookie(event) throw createError({ statusCode: 401, statusMessage: 'Sessão expirada' }) } } const dominio = tenantFromEvent(event) const prefeitura = await fetchPrefeituraInfo(dominio) if (!prefeitura) { throw createError({ statusCode: 400, statusMessage: `Tenant '${dominio}' não encontrado` }) } const cfg = useRuntimeConfig() const url = `${cfg.coreApiUrl}/api/v1/${path}` const query = getQuery(event) const method = event.method.toUpperCase() const body = ['GET', 'HEAD'].includes(method) ? undefined : await readRawBody(event) const headers: Record = { 'X-Municipio': String(prefeitura.codigoMunicipio), 'X-Dominio': prefeitura.dominio, } if (accessToken) headers.Authorization = `Bearer ${accessToken}` const contentType = getHeader(event, 'content-type') if (contentType) headers['Content-Type'] = contentType console.log(`[proxy] ${method} ${url} | X-Municipio: ${headers['X-Municipio']} | X-Dominio: ${headers['X-Dominio']} | auth: ${!!accessToken}`) try { const res = await $fetch.raw(url, { method: method as 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', query, body, headers, responseType: 'stream', }) setResponseStatus(event, res.status) for (const [name, value] of res.headers.entries()) { if (name === 'transfer-encoding') continue setResponseHeader(event, name, value) } return res._data } catch (err: unknown) { const fetchErr = err as { response?: { status?: number; _data?: unknown } } if (fetchErr.response) { const status = fetchErr.response.status ?? 500 const raw = fetchErr.response._data // responseType: 'stream' faz _data ser ReadableStream mesmo em erros — // lemos o stream e parseamos como JSON para que o cliente veja o envelope de erro let body: unknown = raw if (raw && typeof (raw as ReadableStream).getReader === 'function') { const reader = (raw as ReadableStream).getReader() const chunks: Uint8Array[] = [] for (;;) { const { value, done } = await reader.read() if (done) break if (value) chunks.push(value) } const totalLen = chunks.reduce((n, c) => n + c.length, 0) const merged = new Uint8Array(totalLen) let offset = 0 for (const c of chunks) { merged.set(c, offset); offset += c.length } const text = new TextDecoder().decode(merged) try { body = JSON.parse(text) } catch { body = text } } if (import.meta.dev) { console.error(`[proxy] ERRO ${status} ← ${url}`, body) } setResponseStatus(event, status) setResponseHeader(event, 'content-type', 'application/json; charset=utf-8') return body } throw err } })