/**
* Google Calendar API routes — OAuth flow + calendar management.
*/
import crypto from 'bare-crypto'
import { json, notFound, redirect } from '../response.js'
import { requireAuth } from './auth-helpers.js'
import { readJson } from '../response.js'
export function registerGcalRoutes (router, server) {
const gcal = server.gcal
// Status — always available, returns configured + connected state
router.get('/api/gcal/status', async (req, res) => {
if (!server.gcalConfigured) {
return json(res, { configured: false, connected: false })
}
// Try auth — if not signed in, just return configured status
let auth
try {
const result = await server.authManager.authorizeRequest(req, 'write')
if (!result.authorized || !result.auth) {
return json(res, { configured: true, connected: false })
}
auth = result.auth
} catch {
return json(res, { configured: true, connected: false })
}
const providerId = await getProviderIdForUser(server, auth.userId)
if (!providerId) return json(res, { configured: true, connected: false })
const entry = await server.db.get(`gcal:provider:${providerId}`)
if (!entry || !entry.value.connected) {
return json(res, { configured: true, connected: false })
}
json(res, {
configured: true,
connected: true,
calendarIds: entry.value.calendarIds || ['primary'],
connectedAt: entry.value.connectedAt
})
})
// Get OAuth2 authorization URL
router.get('/api/gcal/auth-url', async (req, res) => {
if (!server.gcalConfigured) {
return json(res, { error: 'Google Calendar not configured. Set GCAL_CLIENT_ID and GCAL_CLIENT_SECRET environment variables.' }, 400)
}
const auth = await requireAuth(req, server, 'write')
const providerId = await getProviderIdForUser(server, auth.userId)
if (!providerId) return json(res, { error: 'No provider profile' }, 403)
// Generate CSRF nonce
const nonce = crypto.randomBytes(16).toString('hex')
const state = Buffer.from(JSON.stringify({ providerId, nonce })).toString('base64url')
// Store nonce for validation (5-min TTL)
await server.db.put(`gcal:state:${nonce}`, {
providerId,
createdAt: Date.now()
})
// OAuth redirect URI must be a fixed public URL (Google blocks .local, private IPs)
const redirectUri = server.gcalRedirectUri
const url = gcal.getAuthUrl(redirectUri, state)
json(res, { url })
})
// OAuth2 callback — Google redirects here
router.get('/api/gcal/callback', async (req, res) => {
const url = new URL('http://x' + req.url)
const code = url.searchParams.get('code')
const stateParam = url.searchParams.get('state')
const error = url.searchParams.get('error')
if (error) {
return redirect(res, `${server.pathPrefix}/dashboard/settings?gcal=error&reason=${error}`)
}
if (!code || !stateParam) {
return redirect(res, `${server.pathPrefix}/dashboard/settings?gcal=error&reason=missing_params`)
}
// Decode and validate state
let state
try {
state = JSON.parse(Buffer.from(stateParam, 'base64url').toString())
} catch {
return redirect(res, `${server.pathPrefix}/dashboard/settings?gcal=error&reason=invalid_state`)
}
const { providerId, nonce } = state
// Validate CSRF nonce
const nonceEntry = await server.db.get(`gcal:state:${nonce}`)
if (!nonceEntry) {
return redirect(res, `${server.pathPrefix}/dashboard/settings?gcal=error&reason=expired_state`)
}
// Check nonce age (5 min max)
if (Date.now() - nonceEntry.value.createdAt > 5 * 60 * 1000) {
await server.db.del(`gcal:state:${nonce}`)
return redirect(res, `${server.pathPrefix}/dashboard/settings?gcal=error&reason=expired_state`)
}
// Consume nonce
await server.db.del(`gcal:state:${nonce}`)
// Must match the same fixed redirect URI used in auth-url
const redirectUri = server.gcalRedirectUri
try {
const tokens = await gcal.exchangeCode(code, redirectUri)
// Store tokens
await server.db.put(`gcal:provider:${providerId}`, {
connected: true,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
tokenExpiry: Date.now() + (tokens.expiresIn * 1000),
calendarIds: ['primary'],
connectedAt: Date.now()
})
redirect(res, `${server.pathPrefix}/dashboard/settings?gcal=connected`)
} catch (err) {
console.error('[GCal] Token exchange failed:', err.message)
redirect(res, `${server.pathPrefix}/dashboard/settings?gcal=error&reason=token_exchange_failed`)
}
})
// List available calendars from Google
router.get('/api/gcal/calendars', async (req, res) => {
const auth = await requireAuth(req, server, 'write')
const providerId = await getProviderIdForUser(server, auth.userId)
if (!providerId) return json(res, { error: 'No provider profile' }, 403)
const accessToken = await gcal.getValidToken(server.db, providerId)
if (!accessToken) return json(res, { error: 'Not connected to Google Calendar' }, 400)
try {
const calendars = await gcal.fetchCalendarList(accessToken)
json(res, calendars)
} catch (err) {
json(res, { error: err.message }, 500)
}
})
// Update selected calendars
router.put('/api/gcal/calendars', async (req, res) => {
const auth = await requireAuth(req, server, 'write')
const providerId = await getProviderIdForUser(server, auth.userId)
if (!providerId) return json(res, { error: 'No provider profile' }, 403)
const body = await readJson(req)
const { calendarIds } = body
if (!Array.isArray(calendarIds) || calendarIds.length === 0) {
return json(res, { error: 'calendarIds array required' }, 400)
}
const entry = await server.db.get(`gcal:provider:${providerId}`)
if (!entry || !entry.value.connected) {
return json(res, { error: 'Not connected' }, 400)
}
entry.value.calendarIds = calendarIds
await server.db.put(`gcal:provider:${providerId}`, entry.value)
// Clear cache (calendar selection changed)
const delKeys = []
for await (const e of server.db.createReadStream({
gte: `gcal:cache:${providerId}:`,
lte: `gcal:cache:${providerId}:~`
})) {
delKeys.push(e.key)
}
for (const key of delKeys) await server.db.del(key)
json(res, { ok: true, calendarIds })
})
// Disconnect Google Calendar
router.delete('/api/gcal/disconnect', async (req, res) => {
const auth = await requireAuth(req, server, 'write')
const providerId = await getProviderIdForUser(server, auth.userId)
if (!providerId) return json(res, { error: 'No provider profile' }, 403)
await gcal.disconnect(server.db, providerId)
json(res, { ok: true })
})
}
async function getProviderIdForUser (server, userId) {
const indexed = await server.db.get(`provider-by-user:${userId}`)
if (indexed) return indexed.value
for await (const entry of server.db.createReadStream({
gte: 'provider:',
lte: 'provider:~'
})) {
if (entry.value.userId === userId) {
await server.db.put(`provider-by-user:${userId}`, entry.value.id)
return entry.value.id
}
}
return null
}