Admin 7c9d136b4d
Some checks failed
CI / verify (push) Has been cancelled
CI / print-rust (push) Has been cancelled
fix(mcp): expose standard protected resource metadata
2026-09-27 18:18:50 +00:00

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 }