import { type NextRequest } from 'next/server'; import { getSupabaseAdmin } from '@/lib/supabase/admin'; import { jsonNoStore } from '@/lib/admin/response'; import { generateDeviceToken, hashToken, } from '@/lib/device'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; type TokenBody = { device_code?: unknown }; type Pairing = { id: number; status: string; user_id: string | null; expires_at: string; interval_seconds: number; last_polled_at: string | null; }; /** * Device-token endpoint (PUBLIC). The device polls this with its device_code. * Returns authorization_pending until the user approves, then mints a device * token exactly once. */ export async function POST(req: NextRequest) { let body: TokenBody; try { body = (await req.json()) as TokenBody; } catch { return jsonNoStore({ error: 'invalid_grant' }, { status: 400 }); } const deviceCode = typeof body.device_code === 'string' ? body.device_code : ''; if (!deviceCode) { return jsonNoStore({ error: 'invalid_grant' }, { status: 400 }); } const admin = getSupabaseAdmin(); const { data: pairing, error: pairingErr } = await admin .from('device_pairings') .select('id, status, user_id, expires_at, interval_seconds, last_polled_at') .eq('device_code_hash', hashToken(deviceCode)) .maybeSingle(); if (pairingErr) { return jsonNoStore({ error: 'failed' }, { status: 500 }); } if (!pairing) { return jsonNoStore({ error: 'invalid_grant' }, { status: 400 }); } // Optional slow_down: reject when polled faster than the advertised interval. const now = Date.now(); if (pairing.last_polled_at) { const sinceLast = now - new Date(pairing.last_polled_at).getTime(); if (sinceLast < pairing.interval_seconds * 1000) { await admin .from('device_pairings') .update({ last_polled_at: new Date(now).toISOString() }) .eq('id', pairing.id); return jsonNoStore({ error: 'slow_down' }, { status: 429 }); } } await admin .from('device_pairings') .update({ last_polled_at: new Date(now).toISOString() }) .eq('id', pairing.id); if (new Date(pairing.expires_at).getTime() < now) { await admin .from('device_pairings') .update({ status: 'expired' }) .eq('id', pairing.id); return jsonNoStore({ error: 'expired_token' }, { status: 400 }); } if (pairing.status === 'pending') { return jsonNoStore({ error: 'authorization_pending' }, { status: 400 }); } if (pairing.status === 'approved' && pairing.user_id) { const deviceToken = generateDeviceToken(); const { error: insErr } = await admin.from('device_tokens').insert({ token_hash: hashToken(deviceToken), user_id: pairing.user_id, label: 'HA add-on', }); if (insErr) { return jsonNoStore({ error: 'failed' }, { status: 500 }); } const { error: claimErr } = await admin .from('device_pairings') .update({ status: 'claimed', claimed_at: new Date(now).toISOString() }) .eq('id', pairing.id) .eq('status', 'approved'); if (claimErr) { return jsonNoStore({ error: 'failed' }, { status: 500 }); } return jsonNoStore({ device_token: deviceToken, token_type: 'Bearer' }); } // claimed or expired. return jsonNoStore({ error: 'expired_token' }, { status: 400 }); }