- Backend: Fastify service polling Met Office DataHub (Global Spot: hourly, three-hourly, daily endpoints) with Open-Meteo fallback, hourly refresh. Reuses settings' hotel_location/met_office integrations (same pattern as hvac's lib/weather.js) rather than its own credential copy. - Three display tiers stored in one forecast_points table: today (hourly), next 2 days (3-hourly), days 4-7 (plain daily) - 7 days total. - Public /api/public/forecast (no auth) feeds the reception kiosk display; /api/internal/forecast (WEATHER_SECRET bearer) lets other apps read the stored forecast instead of polling providers themselves; /api/admin/* (normal JWT + settings cap) is the staff status/refresh page. - Frontend: root / is the public full-screen Display (no AuthGate at all - first genuinely public app in the stack); /admin is the authed status page. apps.base_path is /weather/admin so the portal card opens the admin view directly, precedented by forecasting's history sub-route. - LXC 131 / 10.10.10.131, registered in auth/src/db.js, push-to-forgejo.sh, and stack-init/install-stack.sh (deploy_weather + --only dispatch + full install chain). - Typechecks and builds clean; not yet deployed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
59 lines
1.7 KiB
JavaScript
59 lines
1.7 KiB
JavaScript
import { jwtVerify } from 'jose'
|
|
import { isOnsite } from './ip-check.js'
|
|
|
|
const APP_SLUG = process.env.APP_SLUG || 'weather'
|
|
const secret = new TextEncoder().encode(process.env.CENTRAL_AUTH_SECRET || '')
|
|
|
|
// Only the /admin surface uses this — the reception display itself is deliberately
|
|
// public (see routes/forecast.js's publicForecastRoutes), so this only gates the
|
|
// staff status/refresh page.
|
|
export async function requireAuth(request, reply) {
|
|
const token = request.cookies?.hnf_session
|
|
if (!token) return reply.status(401).send({ error: 'Not authenticated' })
|
|
|
|
let payload
|
|
try {
|
|
const { payload: p } = await jwtVerify(token, secret)
|
|
payload = p
|
|
} catch {
|
|
return reply.status(401).send({ error: 'Invalid session' })
|
|
}
|
|
|
|
if (!payload.apps?.includes(APP_SLUG)) {
|
|
return reply.status(403).send({ error: 'No permission for this app' })
|
|
}
|
|
|
|
if (!payload.offsite_allowed) {
|
|
const clientIP = request.headers['x-real-ip'] || request.ip
|
|
if (!(await isOnsite(clientIP))) {
|
|
return reply.status(403).send({ error: 'Access restricted to site network' })
|
|
}
|
|
}
|
|
|
|
const prefix = `${APP_SLUG}:`
|
|
let caps
|
|
if (Array.isArray(payload.caps)) {
|
|
caps = payload.caps.filter(c => c.startsWith(prefix)).map(c => c.slice(prefix.length))
|
|
} else {
|
|
caps = ['use']
|
|
}
|
|
|
|
request.user = {
|
|
email: payload.sub,
|
|
name: payload.name,
|
|
is_admin: payload.is_admin ?? false,
|
|
caps,
|
|
}
|
|
}
|
|
|
|
export function hasCap(request, cap) {
|
|
return request.user?.is_admin === true || request.user?.caps?.includes(cap) === true
|
|
}
|
|
|
|
export function requireCap(cap) {
|
|
return async (request, reply) => {
|
|
if (!hasCap(request, cap)) {
|
|
return reply.status(403).send({ error: `Missing capability: ${cap}` })
|
|
}
|
|
}
|
|
}
|