mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:25:20 +00:00
Some checks are pending
Deploy / preflight (push) Waiting to run
Deploy / verify (push) Blocked by required conditions
Deploy / deploy staging (teqo-staging) (push) Blocked by required conditions
Deploy / requeue when main advanced (push) Blocked by required conditions
Deploy / deploy production (teqo-1313) (push) Blocked by required conditions
Collection archivePhoto ganha o grupo catalog (legenda/descrição/cena/texto visível/temas/pessoas/município/origem/catalogedAt), curatedFields (o que a assessoria edita nunca é sobrescrito), takenOn e searchText com índice GIN trgm (migration aditiva add_archive_photo_catalog); hook beforeValidate deriva o índice e marca curadoria só em edição humana; pipeline transacional baixa os bytes privados, prepara JPEG <=1024px com sharp e mescla o VLM local com o gazetteer/catálogo curado (pessoas só do TEXTO, nunca de rosto); CLI pnpm archive:catalog (plan/--apply/--verify, recibo honesto, idempotente por catalogedAt) com engine OpenAI-compatible na rede privada atrás de guarda de host (ARCHIVE_VISION_ALLOW_REMOTE é o escape); client HTTP com retry/timeout e parser zod fail-closed; resolver de município extraído para owner compartilhado com o catálogo de peças delegando; unit/int novos e runbook staging-primeiro.
488 lines
18 KiB
JavaScript
488 lines
18 KiB
JavaScript
/**
|
||
* Shared CLI skeleton for scripts/*.mjs (Pass 3 P3-H; the `scripts/lib/` home
|
||
* exists since B5 F2). `die`, `sha256`, the download→cache→sha256 prologue,
|
||
* `writeText`, the dotenv preamble and the local-host list were each spelled
|
||
* 5–7 times — the newest script copied the OLD shape, i.e. live drift. The
|
||
* convention guard in `codebaseConventions.unit.spec.ts` fails the build on
|
||
* any of these re-spelled outside this module.
|
||
*/
|
||
import { createHash } from 'node:crypto'
|
||
import { readFileSync } from 'node:fs'
|
||
import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
|
||
import { createRequire } from 'node:module'
|
||
import { dirname, join } from 'node:path'
|
||
|
||
const require = createRequire(import.meta.url)
|
||
|
||
/** Labelled `die` factory: `dieWithLabel('seed:tse')` → the script's `die`. */
|
||
export const dieWithLabel = (label) => (message) => {
|
||
console.error(`\n[${label}] ${message}\n`)
|
||
process.exit(1)
|
||
}
|
||
|
||
export const sha256Hex = (buffer) => createHash('sha256').update(buffer).digest('hex')
|
||
|
||
/**
|
||
* `--flag=value` / `--flag` parser for the self-contained CLIs that use the
|
||
* equals form (the auto-unblock family). The historical `--flag value` parser
|
||
* lives in `agent-forgejo.mjs` (`parseArgs`); keeping the equals form here
|
||
* means an absent flag can never swallow the next argv as its value.
|
||
*
|
||
* @param {string[]} argv
|
||
* @returns {{ flags: Record<string, string | boolean>, positional: string[] }}
|
||
*/
|
||
export const parseEqualsFlags = (argv) => {
|
||
const flags = {}
|
||
const positional = []
|
||
for (const arg of Array.isArray(argv) ? argv : []) {
|
||
if (typeof arg !== 'string' || !arg.startsWith('--')) {
|
||
positional.push(arg)
|
||
continue
|
||
}
|
||
const body = arg.slice(2)
|
||
const equals = body.indexOf('=')
|
||
if (equals === -1) flags[body] = true
|
||
else flags[body.slice(0, equals)] = body.slice(equals + 1)
|
||
}
|
||
return { flags, positional }
|
||
}
|
||
|
||
/** The dotenv preamble every DB-touching script needs (.env.local wins over .env). */
|
||
export const loadCliEnv = () => {
|
||
const { config: loadEnv } = require('dotenv')
|
||
loadEnv({ path: '.env.local' })
|
||
loadEnv({ path: '.env' })
|
||
}
|
||
|
||
/**
|
||
* Local database hosts — docker-compose service name, the job-network service
|
||
* names of the Forgejo-era runner (OPS62 X1: services by name, no host
|
||
* publish — the GitHub hosted CI reaches services on published localhost
|
||
* ports, OPS71; `postgres-int` remains active, `postgres-build` is a
|
||
* defensive legacy entry since OPS88 removed the second container), and the
|
||
* bridge gateway (OPS50: legacy per-port publish kept in the allowlist).
|
||
* THE one list.
|
||
*/
|
||
export const LOCAL_HOSTS = new Set([
|
||
'localhost',
|
||
'127.0.0.1',
|
||
'0.0.0.0',
|
||
'::1',
|
||
'postgres',
|
||
'postgres-int',
|
||
'postgres-build',
|
||
])
|
||
|
||
/**
|
||
* Gateway of the default route, read from /proc/net/route (Linux). In the
|
||
* Forgejo-era CI the self-hosted runner published each job's `services:`
|
||
* Postgres on the bridge gateway IP (192.168.x.1 — differs per job), so a job
|
||
* reached it via that IP instead of localhost; the GitHub hosted CI (OPS71)
|
||
* reaches services on published localhost ports instead, and the bridge
|
||
* gateway stays in the allowlist for Docker-based runners. The same octet
|
||
* order the ci-pr.yml awk produces (the /proc fields are little-endian).
|
||
* Returns null when there is no default route or the file is unreadable
|
||
* (macOS/Windows dev boxes).
|
||
*/
|
||
export const defaultGatewayHost = () => {
|
||
try {
|
||
const routes = readFileSync('/proc/net/route', 'utf8').split('\n')
|
||
for (const line of routes) {
|
||
const fields = line.trim().split(/\s+/)
|
||
if (fields[1] !== '00000000') continue
|
||
const hex = fields[2] ?? ''
|
||
if (hex === '00000000' || hex.length < 8) return null
|
||
const octets = [
|
||
parseInt(hex.slice(6, 8), 16),
|
||
parseInt(hex.slice(4, 6), 16),
|
||
parseInt(hex.slice(2, 4), 16),
|
||
parseInt(hex.slice(0, 2), 16),
|
||
]
|
||
const isRfc1918 =
|
||
octets[0] === 10 ||
|
||
(octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) ||
|
||
(octets[0] === 192 && octets[1] === 168)
|
||
if (!isRfc1918) return null
|
||
return octets.join('.')
|
||
}
|
||
} catch {
|
||
// /proc/net/route is Linux-only; fall through to the static list.
|
||
}
|
||
return null
|
||
}
|
||
|
||
const gatewayHost = defaultGatewayHost()
|
||
if (gatewayHost) LOCAL_HOSTS.add(gatewayHost)
|
||
|
||
/**
|
||
* The one test-database name contract: `teqo_test` or `teqo_<worktree>_test`.
|
||
* Shared by `tests/helpers/assertTestDatabase.ts` (test suite guard) and
|
||
* `scripts/db-reset.mjs` (OPS88) so a DROP-SCHEMA script can never accept the
|
||
* dev database `teqo` or any non-test name.
|
||
*/
|
||
export const TEST_DATABASE_NAME_RE = /^teqo(_[a-z0-9]+)?_test$/
|
||
|
||
export const ALLOW_REMOTE_DB_FLAG = 'ALLOW_REMOTE_DB'
|
||
|
||
/**
|
||
* Truthy intent-flag check (the one spelling of the `true`/`1` semantics).
|
||
* Deliberately exact: no trim, no case-folding — `TRUE` or `yes` stay refused
|
||
* (relaxing a guard is a product decision, not a refactor byproduct).
|
||
*/
|
||
export const isTruthyEnv = (value) => value === 'true' || value === '1'
|
||
|
||
export const isRemoteDbOverrideSet = () => isTruthyEnv(process.env[ALLOW_REMOTE_DB_FLAG])
|
||
|
||
/**
|
||
* Hostname of a connection string, or null when it is missing/unparseable.
|
||
*
|
||
* @param {unknown} databaseUrl
|
||
* @returns {string | null}
|
||
*/
|
||
export const databaseHostname = (databaseUrl) => {
|
||
try {
|
||
return new URL(String(databaseUrl ?? '')).hostname
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Database name of a connection string (percent-decoded), or null when the URL
|
||
* is missing/unparseable or its name carries invalid percent-encoding. THE one
|
||
* spelling of the name extraction shared by the fail-closed DB-name guards
|
||
* (`db-reset`, `assertTestDatabase`, staging test-account) — a decode bug here
|
||
* would silently misdirect a guard, so it is unit-pinned.
|
||
*
|
||
* @param {unknown} databaseUrl
|
||
* @returns {string | null}
|
||
*/
|
||
export const databaseName = (databaseUrl) => {
|
||
try {
|
||
const url = new URL(String(databaseUrl ?? ''))
|
||
return decodeURIComponent(url.pathname.replace(/^\//, ''))
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* True when the connection string targets one of the LOCAL_HOSTS.
|
||
*
|
||
* @param {unknown} databaseUrl
|
||
* @returns {boolean}
|
||
*/
|
||
export const isLocalDatabaseUrl = (databaseUrl) => {
|
||
const host = databaseHostname(databaseUrl)
|
||
return host !== null && LOCAL_HOSTS.has(host)
|
||
}
|
||
|
||
/**
|
||
* C155 write guard: any write run whose target is not provably local — or that
|
||
* runs under `NODE_ENV=production` (the homeserver env file) or with the
|
||
* `ALLOW_REMOTE_DB` escape — requires an explicit intent flag. The proxy socat
|
||
* used on the homeserver rewrites the host to 127.0.0.1, so the host check
|
||
* alone cannot see production; NODE_ENV is the honest signal there. Read-only
|
||
* modes never call this.
|
||
*
|
||
* @param {{ nodeEnv?: string | null, databaseUrl?: string | null, allowRemoteDb?: boolean }} [options]
|
||
* @returns {boolean}
|
||
*/
|
||
export const requiresWriteConfirm = ({
|
||
nodeEnv = process.env.NODE_ENV,
|
||
databaseUrl = process.env.DATABASE_URL,
|
||
allowRemoteDb = isRemoteDbOverrideSet(),
|
||
} = {}) => nodeEnv === 'production' || allowRemoteDb || !isLocalDatabaseUrl(databaseUrl)
|
||
|
||
export const mirroredMediaRequired = ({
|
||
nodeEnv = process.env.NODE_ENV,
|
||
databaseUrl = process.env.DATABASE_URL,
|
||
allowRemoteDb = isRemoteDbOverrideSet(),
|
||
s3Enabled = false,
|
||
} = {}) => requiresWriteConfirm({ nodeEnv, databaseUrl, allowRemoteDb }) && s3Enabled !== true
|
||
|
||
/** Human label of the write target for the guard messages (`host/name`). */
|
||
export const databaseTarget = () => {
|
||
const url = process.env.DATABASE_URL
|
||
const host = databaseHostname(url)
|
||
if (host === null) return '(DATABASE_URL ausente ou inválida)'
|
||
return `${host}${new URL(url).pathname}`
|
||
}
|
||
|
||
/**
|
||
* Declared environment → exact database name. The name is the honest
|
||
* discriminator on the homeserver: the socat proxy rewrites the host to
|
||
* 127.0.0.1 and `NODE_ENV=production` is set in both env files, so the host
|
||
* check alone cannot tell staging from production (C195/C231).
|
||
*/
|
||
export const TEQO_ENV_DATABASE_BY_ENV = Object.freeze({
|
||
staging: 'teqo_staging',
|
||
production: 'teqo_1313',
|
||
})
|
||
|
||
/**
|
||
* Fail-closed write-target guard shared by the ingest CLIs (C195/C231).
|
||
* Outside a test run the declared `TEQO_ENV` (`staging`|`production`) is
|
||
* mandatory and must match the exact database name; inside vitest only a
|
||
* `teqo*_test` database is accepted. `ALLOW_REMOTE_DB` is always refused —
|
||
* an ingest never runs from a workstation against a remote database.
|
||
*
|
||
* @param {{
|
||
* databaseUrl?: string | null,
|
||
* teqoEnv?: string | null,
|
||
* allowRemoteDb?: boolean,
|
||
* isTest?: boolean,
|
||
* }} [options]
|
||
* @returns {{ environment: 'staging' | 'production' | 'test', databaseName: string }}
|
||
*/
|
||
export const assertEnvironmentDatabaseTarget = ({
|
||
databaseUrl = process.env.DATABASE_URL,
|
||
teqoEnv = process.env.TEQO_ENV,
|
||
allowRemoteDb = isRemoteDbOverrideSet(),
|
||
isTest = process.env.NODE_ENV === 'test' || isTruthyEnv(process.env.VITEST),
|
||
} = {}) => {
|
||
if (databaseUrl === undefined || databaseUrl === null || String(databaseUrl).trim() === '') {
|
||
throw new Error(
|
||
'DATABASE_URL ausente — rode no ambiente do alvo (env file do stack) para o guard saber onde escrever.',
|
||
)
|
||
}
|
||
|
||
const targetDatabaseName = databaseName(databaseUrl)
|
||
|
||
let protocol = null
|
||
try {
|
||
protocol = new URL(String(databaseUrl ?? '')).protocol
|
||
} catch {
|
||
protocol = null
|
||
}
|
||
if (protocol !== 'postgresql:') {
|
||
throw new Error(
|
||
`protocolo "${protocol ?? '(inválido)'}" ≠ "postgresql:" — a ingestão só escreve num alvo PostgreSQL.`,
|
||
)
|
||
}
|
||
|
||
if (!isLocalDatabaseUrl(databaseUrl)) {
|
||
// Never echo the URL — it carries the DB password.
|
||
throw new Error(
|
||
`host "${databaseHostname(databaseUrl) ?? '(inválido)'}" fora do allowlist local — a ingestão nunca escreve em alvo remoto.`,
|
||
)
|
||
}
|
||
|
||
if (allowRemoteDb) {
|
||
throw new Error(
|
||
'ALLOW_REMOTE_DB está setado — remova; a ingestão não precisa do override remoto.',
|
||
)
|
||
}
|
||
|
||
if (isTest) {
|
||
if (targetDatabaseName === null || !TEST_DATABASE_NAME_RE.test(targetDatabaseName)) {
|
||
throw new Error(
|
||
`banco-alvo "${targetDatabaseName ?? '(inválido)'}" não é de teste — a ingestão em teste só escreve em "teqo*_test".`,
|
||
)
|
||
}
|
||
return { environment: 'test', databaseName: targetDatabaseName }
|
||
}
|
||
|
||
if (teqoEnv !== 'staging' && teqoEnv !== 'production') {
|
||
throw new Error(
|
||
`TEQO_ENV="${teqoEnv ?? '(ausente)'}" — declare o ambiente do alvo: TEQO_ENV=staging ou TEQO_ENV=production.`,
|
||
)
|
||
}
|
||
|
||
const expected = TEQO_ENV_DATABASE_BY_ENV[teqoEnv]
|
||
if (targetDatabaseName !== expected) {
|
||
throw new Error(
|
||
`banco-alvo "${targetDatabaseName ?? '(inválido)'}" ≠ "${expected}" para TEQO_ENV="${teqoEnv}" — ` +
|
||
'o comando recusa escrever num alvo que não corresponde ao ambiente declarado.',
|
||
)
|
||
}
|
||
|
||
return { environment: teqoEnv, databaseName: targetDatabaseName }
|
||
}
|
||
|
||
/**
|
||
* C155/C215 write guard shared by the import CLIs: a non-local/production
|
||
* target (or the `ALLOW_REMOTE_DB` escape) refuses the run unless the command's
|
||
* own intent flag is set. The homeserver env sets `NODE_ENV=production` and the
|
||
* proxy rewrites the DB host, so the explicit flag is what makes the write
|
||
* deliberate.
|
||
*
|
||
* @param {{ label: string, flag: string, command: string, hint?: string }} options
|
||
*/
|
||
export const assertWriteConfirm = ({ label, flag, command, hint = '' }) => {
|
||
if (!requiresWriteConfirm()) return
|
||
if (isTruthyEnv(process.env[flag])) return
|
||
dieWithLabel(label)(
|
||
`alvo de escrita não-local/produção detectado (${databaseTarget()}).\n` +
|
||
`Confirme a intenção com:\n ${flag}=1 ${command} …` +
|
||
(hint ? `\n${hint}` : ''),
|
||
)
|
||
}
|
||
|
||
/**
|
||
* True when the host is reachable only from the private network: loopback,
|
||
* RFC1918, CGNAT/tailnet (`100.64/10`), link-local/ULA IPv6, `.local`/`.internal`
|
||
* suffixes or a single-label name (docker network). A public IP or domain is
|
||
* NOT private — it is exactly what the C232 guard refuses by default.
|
||
*
|
||
* @param {unknown} hostname
|
||
* @returns {boolean}
|
||
*/
|
||
export const isPrivateVisionHost = (hostname) => {
|
||
const host = String(hostname ?? '')
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/^\[|\]$/g, '')
|
||
if (host === '') return false
|
||
// IPv6 literal (loopback/link-local/ULA). The `includes(':')` gate is what
|
||
// keeps a NAME starting with "fc"/"fd" (e.g. fdic.gov) from passing here —
|
||
// it must fall through to the domain checks below.
|
||
if (
|
||
host.includes(':') &&
|
||
(host === '::1' || host.startsWith('fe80:') || host.startsWith('fc') || host.startsWith('fd'))
|
||
) {
|
||
return true
|
||
}
|
||
if (!host.includes('.')) return true
|
||
if (host.endsWith('.local') || host.endsWith('.internal')) return true
|
||
|
||
const parts = host.split('.')
|
||
if (parts.length !== 4 || parts.some((part) => !/^\d{1,3}$/.test(part))) return false
|
||
const octets = parts.map(Number)
|
||
if (octets.some((octet) => octet > 255)) return false
|
||
const [first, second] = octets
|
||
return (
|
||
first === 10 ||
|
||
first === 127 ||
|
||
(first === 192 && second === 168) ||
|
||
(first === 172 && second >= 16 && second <= 31) ||
|
||
(first === 100 && second >= 64 && second <= 127)
|
||
)
|
||
}
|
||
|
||
/**
|
||
* C232 — the vision engine reads the citizens' photos, so it must live on the
|
||
* private network: a public host is refused unless the run declares the
|
||
* `ARCHIVE_VISION_ALLOW_REMOTE=1` escape (the product guardrail is "PII/rostos
|
||
* não vão a terceiro sem decisão explícita e contrato"). Returns the scope so
|
||
* the receipt records which mode ran — the escape is never invisible.
|
||
*
|
||
* @param {{ baseUrl?: string | null, allowRemote?: boolean, label?: string }} [options]
|
||
* @returns {{ scope: 'local' | 'remote', host: string }}
|
||
*/
|
||
export const assertLocalVisionEndpoint = ({
|
||
baseUrl = process.env.ARCHIVE_VISION_BASE_URL,
|
||
allowRemote = isTruthyEnv(process.env.ARCHIVE_VISION_ALLOW_REMOTE),
|
||
label = 'archive:catalog',
|
||
} = {}) => {
|
||
const raw = String(baseUrl ?? '').trim()
|
||
if (raw === '') {
|
||
dieWithLabel(label)(
|
||
'ARCHIVE_VISION_BASE_URL ausente — configure o endpoint do engine de visão (ex.: http://100.94.122.26:11434/v1).',
|
||
)
|
||
}
|
||
|
||
let url
|
||
try {
|
||
url = new URL(raw)
|
||
} catch {
|
||
dieWithLabel(label)(`ARCHIVE_VISION_BASE_URL inválida: ${raw}`)
|
||
}
|
||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||
dieWithLabel(label)(`ARCHIVE_VISION_BASE_URL deve ser http(s), não "${url.protocol}".`)
|
||
}
|
||
|
||
const host = url.hostname
|
||
if (isPrivateVisionHost(host)) return { scope: 'local', host }
|
||
if (allowRemote) return { scope: 'remote', host }
|
||
|
||
dieWithLabel(label)(
|
||
`ARCHIVE_VISION_BASE_URL aponta para host público "${host}" — as fotos do acervo (PII/rostos de cidadãos) não vão a terceiro sem decisão explícita e contrato.\n` +
|
||
' Rode o engine na rede privada (LAN/tailnet) ou, ciente do risco, declare ARCHIVE_VISION_ALLOW_REMOTE=1.',
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Port for `next dev` (OPS40). Next's CLI resolves its port via commander's
|
||
* `.env('PORT')` BEFORE `@next/env` loads `.env.local`, so a `PORT` written by
|
||
* the worktree provisioner was silently ignored and every `pnpm dev` bound
|
||
* 3000. `loadCliEnv()` has already merged real env > `.env.local` > `.env`
|
||
* (override:false) into `process.env`, so reading `env.PORT` gives exactly the
|
||
* precedence the repo wants: real env (Playwright's webServer) wins, then the
|
||
* worktree's file, then no port at all (Next's 3000 default + allowRetry).
|
||
* Returns null when absent (caller omits `-p`); throws fail-closed on a
|
||
* non-empty invalid value (PORT=abc must not silently fall back to 3000).
|
||
*
|
||
* @param {Record<string, string | undefined>} [env]
|
||
*/
|
||
export const resolveDevPort = (env = process.env) => {
|
||
const raw = (env.PORT ?? '').trim()
|
||
if (raw === '') return null
|
||
const port = Number(raw)
|
||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||
throw new Error(`PORT inválida: ${JSON.stringify(raw)} (esperado 1..65535)`)
|
||
}
|
||
return port
|
||
}
|
||
|
||
/**
|
||
* `next dev` argv for a resolved port. The port MUST go as a CLI flag — the
|
||
* env-file value never reaches commander's `env('PORT')`. A null port omits
|
||
* `-p`, keeping Next's default 3000 and its "retry a free port" behavior.
|
||
*/
|
||
export const nextDevArgs = (port) => (port === null ? ['dev'] : ['dev', '-p', String(port)])
|
||
|
||
/**
|
||
* download → cache → sha256 with provenance logging. `ext` picks the cache
|
||
* filename; when it is 'json' the parsed body rides along. `expectedSha256`
|
||
* fails hard on mismatch (provenance-pinned sources, e.g. TSE zips).
|
||
* `download` is injected (`downloadToBuffer` from src/lib/electionResultsZip.ts
|
||
* needs the script's tsx loader, so it cannot be imported here).
|
||
*/
|
||
export const ensureCachedDownload = async ({
|
||
label,
|
||
key,
|
||
url,
|
||
ext,
|
||
cacheDir,
|
||
expectedSha256,
|
||
download,
|
||
}) => {
|
||
await mkdir(cacheDir, { recursive: true })
|
||
const path = join(cacheDir, `${key}.${ext}`)
|
||
let buffer
|
||
try {
|
||
await access(path)
|
||
console.log(`[${label}] cache hit ${path}`)
|
||
buffer = await readFile(path)
|
||
} catch (error) {
|
||
if (error?.code !== 'ENOENT') throw error
|
||
console.log(`[${label}] downloading ${url}`)
|
||
buffer = await download(url)
|
||
await writeFile(path, buffer)
|
||
console.log(`[${label}] saved ${path} (${buffer.length} bytes)`)
|
||
}
|
||
|
||
const hash = sha256Hex(buffer)
|
||
if (expectedSha256 && hash !== expectedSha256) {
|
||
dieWithLabel(label)(
|
||
`SHA-256 mismatch for ${key}\n expected=${expectedSha256}\n actual=${hash}\n Delete ${path} and retry.`,
|
||
)
|
||
}
|
||
|
||
return {
|
||
url,
|
||
hash,
|
||
buffer,
|
||
// 'json' and 'geojson' both carry a JSON body.
|
||
...(ext.endsWith('json') ? { json: JSON.parse(buffer.toString('utf8')) } : {}),
|
||
}
|
||
}
|
||
|
||
/** Write a repo file with the directory walk and the byte-count log line. */
|
||
export const writeRepoFile = async ({ label, root, relativePath, body }) => {
|
||
const path = join(root, relativePath)
|
||
await mkdir(dirname(path), { recursive: true })
|
||
await writeFile(path, body)
|
||
console.log(`[${label}] wrote ${relativePath} (${Buffer.byteLength(body)} bytes)`)
|
||
}
|