441 lines
16 KiB
TypeScript
441 lines
16 KiB
TypeScript
import path from 'node:path'
|
|
import cors from 'cors'
|
|
import express from 'express'
|
|
import helmet from 'helmet'
|
|
import { FIELDLENS_SCHEMA_VERSION, isSyncRequest, type DeviceRegistration } from '@fieldlens/contracts'
|
|
import { getAccessRequest, listAccessRequests, requestAccess, reviewAccessRequest } from './access.js'
|
|
import { authenticate, canApproveAccess, requireAccess, requireAccessApprover } from './auth.js'
|
|
import { config } from './config.js'
|
|
import { loadDeviceContext } from './context.js'
|
|
import { databaseReady, pool } from './db.js'
|
|
import { log } from './log.js'
|
|
import { FIELDLENS_MCP_OAUTH_SCOPES, handleMcpRequest } from './mcp.js'
|
|
import { runMigrations } from './migrations.js'
|
|
import { processSyncEvent } from './sync.js'
|
|
|
|
const app = express()
|
|
app.disable('x-powered-by')
|
|
|
|
const keycloakOrigin = new URL(config.issuer).origin
|
|
app.use(helmet({
|
|
crossOriginEmbedderPolicy: false,
|
|
contentSecurityPolicy: {
|
|
directives: {
|
|
defaultSrc: ["'self'"],
|
|
scriptSrc: ["'self'"],
|
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
imgSrc: ["'self'", 'data:', 'blob:'],
|
|
mediaSrc: ["'self'", 'blob:'],
|
|
connectSrc: ["'self'", keycloakOrigin],
|
|
workerSrc: ["'self'", 'blob:'],
|
|
objectSrc: ["'none'"],
|
|
baseUri: ["'self'"],
|
|
frameAncestors: ["'none'"],
|
|
},
|
|
},
|
|
}))
|
|
app.use((_req, res, next) => {
|
|
res.setHeader('Permissions-Policy', 'camera=(self), microphone=(), geolocation=()')
|
|
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin')
|
|
next()
|
|
})
|
|
const desktopOrigins = new Set([
|
|
'tauri://localhost',
|
|
'http://tauri.localhost',
|
|
'https://tauri.localhost',
|
|
])
|
|
|
|
app.use(cors({
|
|
origin(origin, callback) {
|
|
if (!origin || origin === config.webOrigin || desktopOrigins.has(origin)) callback(null, true)
|
|
else callback(new Error('cors_origin_rejected'))
|
|
},
|
|
credentials: false,
|
|
}))
|
|
app.use(express.json({ limit: '1mb' }))
|
|
app.use((req, res, next) => {
|
|
const started = performance.now()
|
|
res.on('finish', () => log('info', 'http_request', {
|
|
method: req.method,
|
|
path: req.path,
|
|
status: res.statusCode,
|
|
durationMs: Math.round(performance.now() - started),
|
|
}))
|
|
next()
|
|
})
|
|
|
|
app.get('/api/health/live', (_req, res) => {
|
|
res.json({ status: 'live', version: process.env.FIELDLENS_VERSION ?? 'dev' })
|
|
})
|
|
|
|
function redirectDesktopDownload(res: express.Response, filename: string, baseUrl = config.desktopDownloadBaseUrl) {
|
|
const base = baseUrl.replace(/\/$/, '')
|
|
res.setHeader('Cache-Control', 'no-store')
|
|
res.redirect(302, `${base}/${filename}`)
|
|
}
|
|
|
|
app.get('/downloads/fieldlens-print-windows-x64-setup.exe', (_req, res) => {
|
|
redirectDesktopDownload(res, 'fieldlens-print-windows-x64-setup.exe')
|
|
})
|
|
|
|
app.get('/downloads/fieldlens-print-linux-x64.deb', (_req, res) => {
|
|
redirectDesktopDownload(res, 'fieldlens-print-linux-x64.deb', config.linuxDesktopDownloadBaseUrl)
|
|
})
|
|
|
|
app.get('/downloads/install.sh', (_req, res) => {
|
|
redirectDesktopDownload(res, 'install.sh', config.linuxDesktopDownloadBaseUrl)
|
|
})
|
|
|
|
app.get('/downloads/latest.json', (_req, res) => {
|
|
redirectDesktopDownload(res, 'latest.json')
|
|
})
|
|
|
|
app.get('/downloads/release.json', (_req, res) => {
|
|
redirectDesktopDownload(res, 'release.json')
|
|
})
|
|
|
|
app.get('/downloads/SHA256SUMS', (_req, res) => {
|
|
redirectDesktopDownload(res, 'SHA256SUMS')
|
|
})
|
|
|
|
app.get('/downloads/fieldlens-print-windows-x64-setup.exe.sig', (_req, res) => {
|
|
redirectDesktopDownload(res, 'fieldlens-print-windows-x64-setup.exe.sig')
|
|
})
|
|
|
|
app.get('/downloads/fieldlens-print-linux-x64.AppImage', (_req, res) => {
|
|
redirectDesktopDownload(res, 'fieldlens-print-linux-x64.AppImage')
|
|
})
|
|
|
|
app.get('/downloads/fieldlens-print-linux-x64.AppImage.sig', (_req, res) => {
|
|
redirectDesktopDownload(res, 'fieldlens-print-linux-x64.AppImage.sig')
|
|
})
|
|
|
|
// Backward-compatible aliases used by the first downloads page.
|
|
app.get('/downloads/fieldlens-print/windows-x64', (_req, res) => {
|
|
redirectDesktopDownload(res, 'fieldlens-print-windows-x64-setup.exe')
|
|
})
|
|
|
|
app.get('/downloads/fieldlens-print/linux-x64.deb', (_req, res) => {
|
|
redirectDesktopDownload(res, 'fieldlens-print-linux-x64.deb', config.linuxDesktopDownloadBaseUrl)
|
|
})
|
|
|
|
app.get('/api/health/ready', async (_req, res) => {
|
|
try {
|
|
await databaseReady()
|
|
res.json({ status: 'ready', database: 'ready', version: process.env.FIELDLENS_VERSION ?? 'dev' })
|
|
} catch {
|
|
log('error', 'database_readiness_failed')
|
|
res.status(503).json({ status: 'not-ready', database: 'unavailable' })
|
|
}
|
|
})
|
|
|
|
const mcpResourceMetadataUrl = new URL('/.well-known/oauth-protected-resource', config.mcpResourceUrl).href
|
|
function sendMcpProtectedResourceMetadata(res: express.Response) {
|
|
res.setHeader('Cache-Control', 'public, max-age=300')
|
|
res.json({
|
|
resource: config.mcpResourceUrl,
|
|
authorization_servers: [config.issuer],
|
|
scopes_supported: [...FIELDLENS_MCP_OAUTH_SCOPES],
|
|
bearer_methods_supported: ['header'],
|
|
})
|
|
}
|
|
app.get('/.well-known/oauth-protected-resource', (_req, res) => {
|
|
sendMcpProtectedResourceMetadata(res)
|
|
})
|
|
app.get('/.well-known/oauth-protected-resource/api/mcp', (_req, res) => {
|
|
sendMcpProtectedResourceMetadata(res)
|
|
})
|
|
|
|
app.use('/api/mcp', (_req, res, next) => {
|
|
res.setHeader(
|
|
'WWW-Authenticate',
|
|
`Bearer resource_metadata="${mcpResourceMetadataUrl}", scope="${FIELDLENS_MCP_OAUTH_SCOPES.join(' ')}"`,
|
|
)
|
|
res.setHeader('Cache-Control', 'no-store')
|
|
next()
|
|
}, authenticate, (_req, res, next) => {
|
|
res.removeHeader('WWW-Authenticate')
|
|
next()
|
|
}, requireAccess('operator'))
|
|
app.post('/api/mcp', handleMcpRequest)
|
|
app.get('/api/mcp', (_req, res) => { res.status(405).end() })
|
|
app.delete('/api/mcp', (_req, res) => { res.status(405).end() })
|
|
|
|
app.use('/api/v1', authenticate)
|
|
|
|
app.get('/api/v1/access/me', async (req, res) => {
|
|
const request = await getAccessRequest(req.identity!.subject)
|
|
const roles = req.identity!.roles.filter((role) => role.startsWith('fieldlens-'))
|
|
res.json({
|
|
status: roles.length > 0 ? 'approved' : request?.status ?? 'none',
|
|
role: roles[0] ?? request?.approvedRole ?? null,
|
|
canApprove: canApproveAccess(req.identity),
|
|
username: req.identity!.username ?? null,
|
|
email: req.identity!.email ?? null,
|
|
})
|
|
})
|
|
|
|
app.post('/api/v1/access/request', async (req, res) => {
|
|
const request = await requestAccess(req.identity!)
|
|
log('info', 'access_requested', { subject: req.identity!.subject, status: request.status })
|
|
res.status(request.status === 'approved' ? 200 : 202).json({
|
|
status: request.status,
|
|
role: request.approvedRole,
|
|
})
|
|
})
|
|
|
|
app.get('/api/v1/access/requests', requireAccessApprover, async (req, res) => {
|
|
const status = req.query.status === 'approved' || req.query.status === 'rejected'
|
|
? req.query.status
|
|
: 'pending'
|
|
const requests = await listAccessRequests(status)
|
|
res.json({ requests })
|
|
})
|
|
|
|
app.post('/api/v1/access/requests/:subject/approve', requireAccessApprover, async (req, res) => {
|
|
const request = await reviewAccessRequest(String(req.params.subject), 'approved', req.identity!.subject)
|
|
if (!request) {
|
|
res.status(404).json({ error: 'access_request_not_found' })
|
|
return
|
|
}
|
|
log('info', 'access_approved', { subject: request.subject, reviewer: req.identity!.subject })
|
|
res.json({ status: request.status, role: request.approvedRole })
|
|
})
|
|
|
|
app.post('/api/v1/access/requests/:subject/reject', requireAccessApprover, async (req, res) => {
|
|
const request = await reviewAccessRequest(String(req.params.subject), 'rejected', req.identity!.subject)
|
|
if (!request) {
|
|
res.status(404).json({ error: 'access_request_not_found' })
|
|
return
|
|
}
|
|
log('info', 'access_rejected', { subject: request.subject, reviewer: req.identity!.subject })
|
|
res.json({ status: request.status })
|
|
})
|
|
|
|
app.get('/api/v1/me', requireAccess('operator'), (req, res) => {
|
|
res.json({
|
|
subject: req.identity!.subject,
|
|
username: req.identity!.username,
|
|
roles: req.identity!.roles.filter((role) => role.startsWith('fieldlens-')),
|
|
clientId: req.identity!.clientId,
|
|
})
|
|
})
|
|
|
|
app.get('/api/v1/stations', requireAccess('operator'), async (_req, res) => {
|
|
const result = await pool.query(`
|
|
SELECT s.id, s.site_id AS "siteId", s.name, s.station_type AS "stationType", s.active
|
|
FROM stations s
|
|
JOIN sites si ON si.id = s.site_id
|
|
WHERE si.tenant_id = $1 AND s.active = true
|
|
ORDER BY s.name
|
|
`, [config.tenantId])
|
|
res.json({ stations: result.rows })
|
|
})
|
|
|
|
app.post('/api/v1/devices/register', requireAccess('operator'), async (req, res) => {
|
|
const body = req.body as Partial<DeviceRegistration>
|
|
if (typeof body.installationId !== 'string' || body.schemaVersion !== FIELDLENS_SCHEMA_VERSION) {
|
|
res.status(400).json({ error: 'invalid_device_registration' })
|
|
return
|
|
}
|
|
|
|
const client = await pool.connect()
|
|
try {
|
|
const requestedStation = typeof body.stationId === 'string' ? body.stationId : null
|
|
const station = await client.query<{
|
|
id: string
|
|
siteId: string
|
|
name: string
|
|
stationType: string
|
|
active: boolean
|
|
}>(`
|
|
SELECT s.id, s.site_id AS "siteId", s.name, s.station_type AS "stationType", s.active
|
|
FROM stations s
|
|
JOIN sites si ON si.id = s.site_id
|
|
WHERE si.tenant_id = $1 AND s.active = true
|
|
AND ($2::uuid IS NULL OR s.id = $2::uuid)
|
|
ORDER BY CASE WHEN $2::uuid IS NOT NULL AND s.id = $2::uuid THEN 0 ELSE 1 END, s.created_at
|
|
LIMIT 1
|
|
`, [config.tenantId, requestedStation])
|
|
|
|
if (!station.rows[0]) {
|
|
res.status(409).json({ error: 'no_active_station' })
|
|
return
|
|
}
|
|
|
|
const deviceId = crypto.randomUUID()
|
|
const result = await client.query<{ id: string }>(`
|
|
INSERT INTO devices (id, station_id, installation_id, label, last_seen_at, app_version)
|
|
VALUES ($1,$2,$3,$4,now(),$5)
|
|
ON CONFLICT (installation_id) DO UPDATE SET
|
|
station_id = EXCLUDED.station_id,
|
|
label = COALESCE(EXCLUDED.label, devices.label),
|
|
last_seen_at = now(),
|
|
app_version = EXCLUDED.app_version
|
|
RETURNING id
|
|
`, [deviceId, station.rows[0].id, body.installationId, body.label ?? null, body.appVersion ?? null])
|
|
|
|
log('info', 'device_registered', { deviceId: result.rows[0]!.id, stationId: station.rows[0].id })
|
|
res.json({ id: result.rows[0]!.id, station: station.rows[0] })
|
|
} finally {
|
|
client.release()
|
|
}
|
|
})
|
|
|
|
app.post('/api/v1/sync', requireAccess('operator'), async (req, res) => {
|
|
if (!isSyncRequest(req.body)) {
|
|
res.status(400).json({ error: 'invalid_sync_request' })
|
|
return
|
|
}
|
|
|
|
const client = await pool.connect()
|
|
try {
|
|
const context = await loadDeviceContext(client, req.body.deviceId)
|
|
if (!context) {
|
|
res.status(403).json({ error: 'device_not_registered_for_tenant' })
|
|
return
|
|
}
|
|
|
|
const acknowledgements = []
|
|
for (const event of req.body.events) {
|
|
acknowledgements.push(await processSyncEvent(client, event, context, req.identity!))
|
|
}
|
|
await client.query('UPDATE devices SET last_seen_at = now() WHERE id = $1', [context.deviceId])
|
|
const accepted = acknowledgements.filter((ack) => ack.status === 'accepted').length
|
|
const duplicate = acknowledgements.filter((ack) => ack.status === 'duplicate').length
|
|
const rejected = acknowledgements.filter((ack) => ack.status === 'rejected').length
|
|
log('info', 'sync_batch', { deviceId: context.deviceId, size: acknowledgements.length, accepted, duplicate, rejected })
|
|
res.json({ schemaVersion: FIELDLENS_SCHEMA_VERSION, acknowledgements })
|
|
} finally {
|
|
client.release()
|
|
}
|
|
})
|
|
|
|
app.get('/api/v1/print-captures', requireAccess('operator'), async (req, res) => {
|
|
const requested = Number(req.query.limit ?? 50)
|
|
const limit = Number.isFinite(requested) ? Math.max(1, Math.min(200, Math.trunc(requested))) : 50
|
|
const result = await pool.query(`
|
|
SELECT id, device_id AS "deviceId", source, stream_format AS "streamFormat",
|
|
byte_count AS "byteCount", barcode_count AS "barcodeCount", forwarded,
|
|
failure_kind AS "failureKind", app_version AS "appVersion",
|
|
captured_at AS "capturedAt", received_at AS "receivedAt"
|
|
FROM print_capture_diagnostics WHERE tenant_id = $1
|
|
ORDER BY captured_at DESC LIMIT $2
|
|
`, [config.tenantId, limit])
|
|
res.json({ captures: result.rows })
|
|
})
|
|
|
|
app.get('/api/v1/scans', requireAccess('supervisor'), async (req, res) => {
|
|
const limit = Math.max(1, Math.min(500, Number(req.query.limit ?? 100)))
|
|
const result = await pool.query(`
|
|
SELECT id, station_id AS "stationId", device_id AS "deviceId", raw_value AS "rawValue",
|
|
format, duplicate, source, source_metadata AS "sourceMetadata",
|
|
captured_at AS "capturedAt", received_at AS "receivedAt"
|
|
FROM scans WHERE tenant_id = $1
|
|
ORDER BY captured_at DESC LIMIT $2
|
|
`, [config.tenantId, limit])
|
|
res.json({ scans: result.rows })
|
|
})
|
|
|
|
app.get('/api/v1/pallet-sessions/:id', requireAccess('supervisor'), async (req, res) => {
|
|
const session = await pool.query(`
|
|
SELECT ps.* FROM pallet_sessions ps
|
|
WHERE ps.id = $1 AND ps.tenant_id = $2
|
|
`, [req.params.id, config.tenantId])
|
|
if (!session.rows[0]) {
|
|
res.status(404).json({ error: 'not_found' })
|
|
return
|
|
}
|
|
const observations = await pool.query(
|
|
'SELECT * FROM pallet_observations WHERE pallet_session_id = $1 ORDER BY carton_index',
|
|
[req.params.id],
|
|
)
|
|
const exceptions = await pool.query(
|
|
'SELECT * FROM pallet_exceptions WHERE pallet_session_id = $1 ORDER BY created_at',
|
|
[req.params.id],
|
|
)
|
|
res.json({ session: session.rows[0], observations: observations.rows, exceptions: exceptions.rows })
|
|
})
|
|
|
|
app.get('/api/v1/reference/labels/:ean', requireAccess('operator'), async (req, res) => {
|
|
const started = performance.now()
|
|
try {
|
|
const result = await pool.query<{ fields_json: Record<string, unknown> }>(`
|
|
SELECT fields_json FROM reference_label_specs
|
|
WHERE tenant_id = $1 AND ean = $2 AND active = true
|
|
ORDER BY updated_at DESC
|
|
`, [config.tenantId, req.params.ean])
|
|
res.json({ rows: result.rows.map((row) => row.fields_json) })
|
|
log('info', 'reference_lookup', {
|
|
ean: req.params.ean,
|
|
rows: result.rowCount,
|
|
durationMs: Math.round(performance.now() - started),
|
|
})
|
|
} catch {
|
|
log('error', 'reference_lookup_failed', {
|
|
ean: req.params.ean,
|
|
durationMs: Math.round(performance.now() - started),
|
|
})
|
|
res.status(503).json({ error: 'reference_provider_unavailable' })
|
|
}
|
|
})
|
|
|
|
app.get('/api/v1/ops/summary', requireAccess('supervisor'), async (_req, res) => {
|
|
const [stations, devices, scans, sessions, exceptions] = await Promise.all([
|
|
pool.query('SELECT count(*)::int AS count FROM stations s JOIN sites si ON si.id=s.site_id WHERE si.tenant_id=$1 AND s.active=true', [config.tenantId]),
|
|
pool.query('SELECT count(*)::int AS count, max(last_seen_at) AS last_seen FROM devices d JOIN stations s ON s.id=d.station_id JOIN sites si ON si.id=s.site_id WHERE si.tenant_id=$1', [config.tenantId]),
|
|
pool.query("SELECT count(*)::int AS count FROM scans WHERE tenant_id=$1 AND captured_at > now() - interval '24 hours'", [config.tenantId]),
|
|
pool.query("SELECT count(*)::int AS count FROM pallet_sessions WHERE tenant_id=$1 AND started_at > now() - interval '24 hours'", [config.tenantId]),
|
|
pool.query('SELECT count(*)::int AS count FROM pallet_exceptions pe JOIN pallet_sessions ps ON ps.id=pe.pallet_session_id WHERE ps.tenant_id=$1', [config.tenantId]),
|
|
])
|
|
res.json({
|
|
stations: stations.rows[0],
|
|
devices: devices.rows[0],
|
|
scans24h: scans.rows[0],
|
|
palletSessions24h: sessions.rows[0],
|
|
conformityExceptions: exceptions.rows[0],
|
|
})
|
|
})
|
|
|
|
if (config.staticDir) {
|
|
const staticDir = path.resolve(config.staticDir)
|
|
app.use(express.static(staticDir, {
|
|
etag: true,
|
|
maxAge: '1h',
|
|
setHeaders(res, filename) {
|
|
if (filename.endsWith('sw.js') || filename.includes('workbox-')) {
|
|
res.setHeader('Cache-Control', 'no-cache')
|
|
}
|
|
},
|
|
}))
|
|
app.use((req, res, next) => {
|
|
if (req.method !== 'GET' || req.path.startsWith('/api/')) return next()
|
|
res.setHeader('Cache-Control', 'no-cache')
|
|
res.sendFile(path.join(staticDir, 'index.html'))
|
|
})
|
|
}
|
|
|
|
await runMigrations()
|
|
|
|
const server = app.listen(config.port, '0.0.0.0', () => {
|
|
log('info', 'server_started', {
|
|
port: config.port,
|
|
environment: config.environment,
|
|
version: process.env.FIELDLENS_VERSION ?? 'dev',
|
|
})
|
|
})
|
|
|
|
async function shutdown(signal: string) {
|
|
log('info', 'shutdown_started', { signal })
|
|
server.close(async () => {
|
|
await pool.end()
|
|
process.exit(0)
|
|
})
|
|
setTimeout(() => process.exit(1), 10_000).unref()
|
|
}
|
|
|
|
process.on('SIGTERM', () => void shutdown('SIGTERM'))
|
|
process.on('SIGINT', () => void shutdown('SIGINT'))
|
|
|
|
export { app }
|