← back to Dw Activation Calendar
google-cal.js
240 lines
'use strict';
/*
* google-cal.js — Google Calendar OAuth + Calendar v3 layer for the DW
* Activation Calendar. fetch-only, NO googleapis/SDK dependency (so it
* needs zero npm installs and is immune to the symlinked node_modules).
*
* Pattern copied from ventura-corridor appointment-agent (oauth.ts +
* calendar.ts), simplified to a SINGLE shared DW calendar rather than a
* per-owner token table:
* - one OAuth connection (a single Google account owns/has-access-to the
* shared DW calendar), tokens persisted to a local JSON file;
* - read events (events.list) for a month;
* - create (events.insert), update (events.update / events.patch),
* delete (events.delete).
*
* GATED: this module never *initiates* the OAuth grant on its own. The
* client-id/secret come from env (routed by the secrets skill — Steve-gated);
* the consent click is Steve's. With no credential present, every read/write
* returns a clean "not connected" signal so the UI degrades gracefully and
* NEVER 500s.
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
const AUTH_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth';
const REVOKE_ENDPOINT = 'https://oauth2.googleapis.com/revoke';
const CAL_BASE = 'https://www.googleapis.com/calendar/v3';
const SCOPES = [
'openid', 'email', 'profile',
'https://www.googleapis.com/auth/calendar.events',
'https://www.googleapis.com/auth/calendar.readonly',
];
// Which calendar the modal edits. Default = the connected account's "primary".
// Override with GOOGLE_DW_CALENDAR_ID to point at a dedicated shared calendar.
const CALENDAR_ID = process.env.GOOGLE_DW_CALENDAR_ID || 'primary';
// Token + state stored outside git (see .gitignore: *.local.json).
const TOKEN_FILE = process.env.GOOGLE_CAL_TOKEN_FILE
|| path.join(__dirname, '.google-tokens.local.json');
// In-memory single-use state nonces for the OAuth handshake.
const _states = new Map(); // state -> createdAt
function getOAuthConfig() {
const clientId = process.env.GOOGLE_OAUTH_CLIENT_ID || process.env.GOOGLE_CLIENT_ID || '';
const clientSecret = process.env.GOOGLE_OAUTH_CLIENT_SECRET || process.env.GOOGLE_CLIENT_SECRET || '';
const redirectUri = process.env.GOOGLE_OAUTH_REDIRECT_URI
|| `http://127.0.0.1:${process.env.PORT || 9765}/api/oauth/callback`;
return { clientId, clientSecret, redirectUri };
}
function isOAuthConfigured() {
const { clientId, clientSecret } = getOAuthConfig();
return Boolean(clientId && clientSecret);
}
// --- token persistence ------------------------------------------------------
function _readTokens() {
try { return JSON.parse(fs.readFileSync(TOKEN_FILE, 'utf8')); }
catch { return null; }
}
function _writeTokens(t) {
fs.writeFileSync(TOKEN_FILE, JSON.stringify(t, null, 2), { mode: 0o600 });
}
function isConnected() {
const t = _readTokens();
return Boolean(t && (t.access_token || t.refresh_token));
}
// --- OAuth handshake --------------------------------------------------------
function buildAuthUrl() {
const { clientId, redirectUri } = getOAuthConfig();
const state = crypto.randomBytes(16).toString('hex');
_states.set(state, Date.now());
// prune nonces older than 10 min
for (const [s, at] of _states) if (Date.now() - at > 600_000) _states.delete(s);
const params = new URLSearchParams({
client_id: clientId, redirect_uri: redirectUri, response_type: 'code',
scope: SCOPES.join(' '), access_type: 'offline', prompt: 'consent', state,
});
return `${AUTH_ENDPOINT}?${params.toString()}`;
}
async function exchangeCodeAndStore(code, state) {
if (!_states.has(state)) throw new Error('Unknown or expired OAuth state');
_states.delete(state);
const { clientId, clientSecret, redirectUri } = getOAuthConfig();
const body = new URLSearchParams({
code, client_id: clientId, client_secret: clientSecret,
redirect_uri: redirectUri, grant_type: 'authorization_code',
});
const tok = await fetch(TOKEN_ENDPOINT, {
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!tok.ok) throw new Error(`Google token exchange failed: ${tok.status} ${await tok.text()}`);
const j = await tok.json();
_writeTokens({
access_token: j.access_token,
refresh_token: j.refresh_token || (_readTokens() || {}).refresh_token || null,
expires_at: Date.now() + (Number(j.expires_in || 3600) * 1000),
scope: j.scope || null,
token_type: j.token_type || 'Bearer',
});
return true;
}
/** Valid access token, refreshing if needed; null if not connected. */
async function getValidAccessToken() {
const t = _readTokens();
if (!t) return null;
if (t.access_token && t.expires_at - Date.now() > 120_000) return t.access_token;
if (!t.refresh_token) return t.access_token || null;
const { clientId, clientSecret } = getOAuthConfig();
const body = new URLSearchParams({
client_id: clientId, client_secret: clientSecret,
grant_type: 'refresh_token', refresh_token: t.refresh_token,
});
const tok = await fetch(TOKEN_ENDPOINT, {
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!tok.ok) { console.error('[google-cal] refresh failed', tok.status, await tok.text()); return null; }
const j = await tok.json();
_writeTokens({ ...t, access_token: j.access_token, expires_at: Date.now() + (Number(j.expires_in || 3600) * 1000) });
return j.access_token;
}
async function revoke() {
const tok = await getValidAccessToken();
if (tok) {
try { await fetch(`${REVOKE_ENDPOINT}?token=${encodeURIComponent(tok)}`, { method: 'POST' }); }
catch (e) { console.error('[google-cal] revoke error (continuing):', e); }
}
try { fs.unlinkSync(TOKEN_FILE); } catch {}
}
// --- Calendar v3 CRUD -------------------------------------------------------
function _calUrl(suffix = '') {
return `${CAL_BASE}/calendars/${encodeURIComponent(CALENDAR_ID)}${suffix}`;
}
/** Status object the UI uses to render the "connected / not connected" banner. */
function status() {
return {
oauth_configured: isOAuthConfigured(),
connected: isConnected(),
calendar_id: CALENDAR_ID,
};
}
/** events.list for a window. Returns [] (never throws) when not connected. */
async function listEvents(timeMinISO, timeMaxISO) {
const token = await getValidAccessToken();
if (!token) return { connected: false, events: [] };
const qs = new URLSearchParams({
timeMin: timeMinISO, timeMax: timeMaxISO,
singleEvents: 'true', orderBy: 'startTime', maxResults: '2500',
});
const r = await fetch(`${_calUrl('/events')}?${qs}`, {
headers: { 'Authorization': `Bearer ${token}` },
});
if (!r.ok) { console.error('[google-cal] events.list failed', r.status, await r.text()); return { connected: true, events: [], error: r.status }; }
const j = await r.json();
return { connected: true, events: (j.items || []).map(_normalize) };
}
function _normalize(e) {
return {
id: e.id,
summary: e.summary || '(no title)',
description: e.description || '',
location: e.location || '',
start: e.start?.dateTime || e.start?.date || null,
end: e.end?.dateTime || e.end?.date || null,
allDay: Boolean(e.start?.date && !e.start?.dateTime),
htmlLink: e.htmlLink || null,
created: e.created || null,
updated: e.updated || null,
colorId: e.colorId || null,
};
}
function _toGoogleBody(p) {
const body = { summary: p.summary, description: p.description || '', location: p.location || '' };
if (p.allDay) {
body.start = { date: (p.start || '').slice(0, 10) };
body.end = { date: (p.end || p.start || '').slice(0, 10) };
} else {
body.start = { dateTime: p.start };
body.end = { dateTime: p.end };
}
if (p.colorId) body.colorId = String(p.colorId);
return body;
}
async function insertEvent(p) {
const token = await getValidAccessToken();
if (!token) return { ok: false, reason: 'not_connected' };
const r = await fetch(`${_calUrl('/events')}?sendUpdates=none`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(_toGoogleBody(p)),
});
if (!r.ok) return { ok: false, reason: 'google_error', status: r.status, detail: await r.text() };
return { ok: true, event: _normalize(await r.json()) };
}
async function updateEvent(eventId, p) {
const token = await getValidAccessToken();
if (!token) return { ok: false, reason: 'not_connected' };
// patch (partial) is safer than full update — only send changed fields.
const r = await fetch(`${_calUrl('/events/' + encodeURIComponent(eventId))}?sendUpdates=none`, {
method: 'PATCH',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(_toGoogleBody(p)),
});
if (!r.ok) return { ok: false, reason: 'google_error', status: r.status, detail: await r.text() };
return { ok: true, event: _normalize(await r.json()) };
}
async function deleteEvent(eventId) {
const token = await getValidAccessToken();
if (!token) return { ok: false, reason: 'not_connected' };
const r = await fetch(`${_calUrl('/events/' + encodeURIComponent(eventId))}?sendUpdates=none`, {
method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` },
});
return { ok: r.ok || r.status === 410 }; // 410 = already gone
}
module.exports = {
SCOPES, CALENDAR_ID,
getOAuthConfig, isOAuthConfigured, isConnected, status,
buildAuthUrl, exchangeCodeAndStore, getValidAccessToken, revoke,
listEvents, insertEvent, updateEvent, deleteEvent,
};