Final audit polish — closes the remaining LOW + MED items the previous
tiers didn't reach:
* Validation hardening: me.preferences uses .strict() + 8KB cap
instead of unbounded .passthrough(); files.uploadFile gains
magic-byte verification (jpeg/png/gif/webp/pdf/doc/xlsx); OCR scan
endpoint enforces 10MB cap + magic-byte check on receipt images;
port logoUrl + me.avatarUrl reject javascript:/data: schemes via
a shared httpUrl refinement.
* Permission gates: document-sends/{brochure,berth-pdf} now require
email.send (was withAuth-only); document-sends/{preview,list} on
email.view; ai/email-draft on email.send; documents/[id]/send
uses send_for_signing (was create); expenses/export/parent-company
flips from hard isSuperAdmin to expenses.export for parity;
admin/users/options gated on reminders.assign_others (was withAuth).
* Envelope hygiene: auth/set-password switches the third {message}
variant to errorResponse + {data: {email}}; ai/email-draft wraps
jobId in {data: {jobId}}.
* UI polish: reports-list.handleDownload surfaces failures via
toastError (was console-only).
* Ops/infra: pin pnpm@10.33.2 across all three Dockerfiles +
packageManager field in package.json; Dockerfile.worker re-orders
user creation BEFORE pnpm install so node_modules / .cache dirs
are worker-owned (fixes tesseract.js + sharp EACCES at first PDF
parse); add Redis-ping HEALTHCHECK to the worker container.
* Public health endpoint: returns full env+appUrl payload only when
the caller presents X-Intake-Secret, otherwise a minimal {status}
so generic uptime monitors still work but anonymous internet
doesn't get deployment fingerprints.
* Per-port Documenso webhook secret: new system_settings key
+ listDocumensoWebhookSecrets() helper. The webhook receiver
iterates every configured per-port secret with timing-safe
comparison + falls back to env, then forwards the resolved portId
into handleDocumentExpired so two ports sharing a documensoId
cannot cross-mutate.
Deferred (handled in dedicated follow-up PRs):
* Tier 5.1 — direct service tests for portal-auth / users /
email-accounts / document-sends / sales-email-config. MED, large
test-writing scope.
* The {ok: true} → {data: null} envelope migration across
alerts/expenses/admin-ocr-settings/storage routes. Mechanical but
needs coordinated client + test updates.
* CSP-nonce migration (drop unsafe-inline) — needs middleware-level
nonce generation that the Next 15 router has to thread through.
* Idempotency-Key header on Documenso createDocument. Requires
schema column on documents to persist the key; deferred so it
doesn't bundle a migration into this commit.
* The 16 better-auth user_id FKs — separate dedicated migration
with care (some columns are NOT NULL today and cascade decisions
matter).
* PermissionGate / Skeleton / EmptyState wraps across 5 admin lists
(auditor-H §§36–37) and the residential-clients filter bar.
Test status: 1175/1175 vitest, tsc clean.
Refs: docs/audit-comprehensive-2026-05-05.md MED §§28,29,30 + LOW §§32–43
+ HIGH §9 (Documenso secrets follow-up).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
130 lines
4.7 KiB
TypeScript
130 lines
4.7 KiB
TypeScript
import { NextResponse } from 'next/server';
|
|
|
|
import { withAuth, withPermission, withRateLimit } from '@/lib/api/helpers';
|
|
import { errorResponse, ValidationError } from '@/lib/errors';
|
|
import { logger } from '@/lib/logger';
|
|
import { getResolvedOcrConfig } from '@/lib/services/ocr-config.service';
|
|
import {
|
|
runOcr,
|
|
type ParsedReceipt,
|
|
OCR_FEATURE,
|
|
OCR_ESTIMATED_TOKENS,
|
|
} from '@/lib/services/ocr-providers';
|
|
import { checkBudget, recordAiUsage } from '@/lib/services/ai-budget.service';
|
|
|
|
const EMPTY: ParsedReceipt = {
|
|
establishment: null,
|
|
date: null,
|
|
amount: null,
|
|
currency: null,
|
|
lineItems: [],
|
|
confidence: 0,
|
|
};
|
|
|
|
export const POST = withAuth(
|
|
withPermission(
|
|
'expenses',
|
|
'create',
|
|
withRateLimit('ocr', async (req, ctx) => {
|
|
try {
|
|
const formData = await req.formData();
|
|
const file = formData.get('file') as File | null;
|
|
if (!file) throw new ValidationError('A file is required');
|
|
// Hard 10 MB cap — without this any authenticated rep could grief
|
|
// their own port's AI budget by sending arbitrarily large images
|
|
// and burning OCR tokens (auditor-E3 §28).
|
|
const MAX_OCR_BYTES = 10 * 1024 * 1024;
|
|
if (file.size > MAX_OCR_BYTES) {
|
|
throw new ValidationError('Receipt image is too large (10 MB max).');
|
|
}
|
|
const buffer = Buffer.from(await file.arrayBuffer());
|
|
const mimeType = file.type || 'image/jpeg';
|
|
// Magic-byte gate so a forged Content-Type doesn't reach the OCR
|
|
// provider with arbitrary bytes.
|
|
const { bufferMatchesMime } = await import('@/lib/constants/file-validation');
|
|
const allowedOcrMimes = ['image/jpeg', 'image/png', 'image/webp'];
|
|
if (!allowedOcrMimes.includes(mimeType) || !bufferMatchesMime(buffer, mimeType)) {
|
|
throw new ValidationError('Unsupported receipt image type.');
|
|
}
|
|
|
|
const config = await getResolvedOcrConfig(ctx.portId);
|
|
// Tesseract.js (in-browser) is the default. The server only invokes
|
|
// an AI provider when (a) the port admin has flipped `aiEnabled` on
|
|
// and (b) a key resolves. Otherwise the client falls back to its
|
|
// local Tesseract result.
|
|
if (!config.aiEnabled) {
|
|
return NextResponse.json({
|
|
data: { parsed: EMPTY, source: 'manual', reason: 'ai-disabled' },
|
|
});
|
|
}
|
|
if (!config.apiKey) {
|
|
return NextResponse.json({
|
|
data: { parsed: EMPTY, source: 'manual', reason: 'no-ocr-configured' },
|
|
});
|
|
}
|
|
|
|
// Per-port budget gate - refuse the call before we spend tokens
|
|
// when the port has already hit its hard cap, or when the request
|
|
// would push it past the cap. Soft-cap warnings ride along on the
|
|
// success response so the UI can show a banner without blocking.
|
|
const budget = await checkBudget({
|
|
portId: ctx.portId,
|
|
estimatedTokens: OCR_ESTIMATED_TOKENS,
|
|
});
|
|
if (!budget.ok) {
|
|
return NextResponse.json({
|
|
data: {
|
|
parsed: EMPTY,
|
|
source: 'manual',
|
|
reason: 'budget-exceeded',
|
|
providerError: `AI budget reached (${budget.usedTokens}/${budget.capTokens} tokens this period).`,
|
|
},
|
|
});
|
|
}
|
|
|
|
try {
|
|
const result = await runOcr({
|
|
provider: config.provider,
|
|
model: config.model,
|
|
apiKey: config.apiKey,
|
|
imageBuffer: buffer,
|
|
mimeType,
|
|
});
|
|
await recordAiUsage({
|
|
portId: ctx.portId,
|
|
userId: ctx.userId,
|
|
feature: OCR_FEATURE,
|
|
provider: config.provider,
|
|
model: config.model,
|
|
inputTokens: result.usage.inputTokens,
|
|
outputTokens: result.usage.outputTokens,
|
|
requestId: result.usage.requestId,
|
|
});
|
|
return NextResponse.json({
|
|
data: {
|
|
parsed: result.parsed,
|
|
source: 'ai',
|
|
provider: config.provider,
|
|
model: config.model,
|
|
softCapWarning: budget.softCap,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
logger.error({ err, provider: config.provider }, 'OCR provider call failed');
|
|
// Provider hiccup - degrade to manual entry rather than 500-ing.
|
|
return NextResponse.json({
|
|
data: {
|
|
parsed: EMPTY,
|
|
source: 'manual',
|
|
reason: 'provider-error',
|
|
providerError: err instanceof Error ? err.message.slice(0, 200) : 'Unknown error',
|
|
},
|
|
});
|
|
}
|
|
} catch (error) {
|
|
return errorResponse(error);
|
|
}
|
|
}),
|
|
),
|
|
);
|