From 8dc753749e270a6c21396e58a6651d26cd1fbe35 Mon Sep 17 00:00:00 2001 From: Alec Kriebel Date: Sun, 1 Feb 2026 01:38:29 -0800 Subject: [PATCH] feat: Implement Google Calendar OAuth connection flow - Add CalendarConnection model to store OAuth tokens securely - Create encryption utilities for token storage (AES-GCM via Web Crypto API) - Use random salt per encryption for stronger security - Add OAuth initiation route (/api/calendar/authorize) with CSRF nonce - Add OAuth callback route (/api/calendar/callback) - Add connection management route (/api/calendar/connection) - Include calendarFetchError flag for better UX - Implement automatic token refresh with locking to prevent race conditions - Add decryption error handling to auto-cleanup corrupted connections - Add safe JSON parsing throughout - Add GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, TOKEN_ENCRYPTION_SECRET env vars - Generate database migration for calendar_connections table Scopes requested: calendar.readonly, calendar.events.readonly, userinfo.email Co-Authored-By: Claude Opus 4.5 --- .../db/migrations/0003_remarkable_thor.sql | 13 + .../src/db/migrations/meta/0003_snapshot.json | 400 ++++++++++++++++++ .../src/db/migrations/meta/_journal.json | 7 + packages/shared/src/db/schema.ts | 37 ++ packages/shared/src/encryption.ts | 100 +++++ packages/shared/src/types/index.ts | 5 + .../web/app/api/calendar/authorize/route.ts | 26 ++ .../web/app/api/calendar/callback/route.ts | 125 ++++++ .../web/app/api/calendar/connection/route.ts | 98 +++++ .../web/lib/calendar/calendar-connection.ts | 228 ++++++++++ packages/web/lib/calendar/google-oauth.ts | 87 ++++ packages/web/lib/calendar/index.ts | 2 + packages/web/package.json | 1 + packages/web/web-env.ts | 8 + yarn.lock | 143 ++++++- 15 files changed, 1278 insertions(+), 2 deletions(-) create mode 100644 packages/shared/src/db/migrations/0003_remarkable_thor.sql create mode 100644 packages/shared/src/db/migrations/meta/0003_snapshot.json create mode 100644 packages/shared/src/encryption.ts create mode 100644 packages/web/app/api/calendar/authorize/route.ts create mode 100644 packages/web/app/api/calendar/callback/route.ts create mode 100644 packages/web/app/api/calendar/connection/route.ts create mode 100644 packages/web/lib/calendar/calendar-connection.ts create mode 100644 packages/web/lib/calendar/google-oauth.ts create mode 100644 packages/web/lib/calendar/index.ts diff --git a/packages/shared/src/db/migrations/0003_remarkable_thor.sql b/packages/shared/src/db/migrations/0003_remarkable_thor.sql new file mode 100644 index 0000000..4c6c633 --- /dev/null +++ b/packages/shared/src/db/migrations/0003_remarkable_thor.sql @@ -0,0 +1,13 @@ +CREATE TABLE `calendar_connections` ( + `id` text(191) PRIMARY KEY NOT NULL, + `user_id` text(191) NOT NULL, + `access_token_encrypted` text NOT NULL, + `refresh_token_encrypted` text NOT NULL, + `token_expiry` integer NOT NULL, + `calendar_ids` text DEFAULT '[]' NOT NULL, + `google_email` text(191), + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + `updated_at` integer DEFAULT (unixepoch()) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `calendar_connections_user_id_idx` ON `calendar_connections` (`user_id`); \ No newline at end of file diff --git a/packages/shared/src/db/migrations/meta/0003_snapshot.json b/packages/shared/src/db/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..c541c78 --- /dev/null +++ b/packages/shared/src/db/migrations/meta/0003_snapshot.json @@ -0,0 +1,400 @@ +{ + "version": "5", + "dialect": "sqlite", + "id": "c3a93c2a-8a45-4522-90f8-c1eb17ce94e3", + "prevId": "781d35e2-75fe-4cf4-83fe-67431c7c067f", + "tables": { + "calendar_connections": { + "name": "calendar_connections", + "columns": { + "id": { + "name": "id", + "type": "text(191)", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text(191)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token_encrypted": { + "name": "access_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_encrypted": { + "name": "refresh_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_expiry": { + "name": "token_expiry", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "calendar_ids": { + "name": "calendar_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "google_email": { + "name": "google_email", + "type": "text(191)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "calendar_connections_user_id_idx": { + "name": "calendar_connections_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "org_invites": { + "name": "org_invites", + "columns": { + "id": { + "name": "id", + "type": "text(191)", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text(191)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text(191)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_user_id": { + "name": "sender_user_id", + "type": "text(191)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text(191)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'read'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "token_org_invites_id_key": { + "name": "token_org_invites_id_key", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "org_users": { + "name": "org_users", + "columns": { + "id": { + "name": "id", + "type": "text(191)", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'read'" + }, + "org_id": { + "name": "org_id", + "type": "text(191)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text(191)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "orgs": { + "name": "orgs", + "columns": { + "id": { + "name": "id", + "type": "text(191)", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text(191)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'free'" + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text(191)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text(191)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_current_period_end": { + "name": "stripe_current_period_end", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "orgs_stripe_customer_id_key": { + "name": "orgs_stripe_customer_id_key", + "columns": [ + "stripe_customer_id" + ], + "isUnique": true + }, + "orgs_stripe_subscription_id_key": { + "name": "orgs_stripe_subscription_id_key", + "columns": [ + "stripe_subscription_id" + ], + "isUnique": true + }, + "orgs_name_id_key": { + "name": "orgs_name_id_key", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text(191)", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text(191)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text(191)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text(191)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_current_period_end": { + "name": "stripe_current_period_end", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "users_stripe_customer_id_key": { + "name": "users_stripe_customer_id_key", + "columns": [ + "stripe_customer_id" + ], + "isUnique": true + }, + "users_stripe_subscription_id_key": { + "name": "users_stripe_subscription_id_key", + "columns": [ + "stripe_subscription_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + } +} \ No newline at end of file diff --git a/packages/shared/src/db/migrations/meta/_journal.json b/packages/shared/src/db/migrations/meta/_journal.json index 809f3d0..a852ce9 100644 --- a/packages/shared/src/db/migrations/meta/_journal.json +++ b/packages/shared/src/db/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1769615790664, "tag": "0002_confused_lionheart", "breakpoints": true + }, + { + "idx": 3, + "version": "5", + "when": 1769938654146, + "tag": "0003_remarkable_thor", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/shared/src/db/schema.ts b/packages/shared/src/db/schema.ts index 0311667..ae11d53 100644 --- a/packages/shared/src/db/schema.ts +++ b/packages/shared/src/db/schema.ts @@ -132,8 +132,45 @@ export const orgUsers = sqliteTable("org_users", { .$onUpdate(() => new Date()), }); +// Calendar connection for Google Calendar OAuth +export const calendarConnections = sqliteTable( + "calendar_connections", + { + id: text("id", { length: 191 }).primaryKey().notNull(), + userId: text("user_id", { length: 191 }).notNull(), + // Encrypted tokens (encrypted with AES-GCM) + accessTokenEncrypted: text("access_token_encrypted").notNull(), + refreshTokenEncrypted: text("refresh_token_encrypted").notNull(), + // Token expiry as timestamp in milliseconds + tokenExpiry: integer("token_expiry", { mode: "timestamp_ms" }).notNull(), + // JSON array of selected calendar IDs + calendarIds: text("calendar_ids").notNull().default("[]"), + // Google account email for display purposes + googleEmail: text("google_email", { length: 191 }), + createdAt: integer("created_at", { + mode: "timestamp", + }) + .default(sql`(unixepoch())`) + .notNull(), + updatedAt: integer("updated_at", { + mode: "timestamp", + }) + .default(sql`(unixepoch())`) + .notNull() + .$onUpdate(() => new Date()), + }, + (table) => { + return { + userIdIdx: uniqueIndex("calendar_connections_user_id_idx").on( + table.userId + ), + }; + } +); + export type User = InferSelectModel; export type Org = InferSelectModel; export type OrgInvite = InferSelectModel; export type OrgUser = InferSelectModel; export type OrgUserWithDetail = OrgUser & UserDetail; +export type CalendarConnection = InferSelectModel; diff --git a/packages/shared/src/encryption.ts b/packages/shared/src/encryption.ts new file mode 100644 index 0000000..3d31189 --- /dev/null +++ b/packages/shared/src/encryption.ts @@ -0,0 +1,100 @@ +/** + * Token encryption utilities using Web Crypto API (AES-GCM) + * Compatible with Cloudflare Workers edge runtime + * + * Format: base64(salt + iv + ciphertext) + * - salt: 16 bytes (random per encryption, used for PBKDF2 key derivation) + * - iv: 12 bytes (random per encryption, used for AES-GCM) + * - ciphertext: variable length + */ + +const ALGORITHM = "AES-GCM"; +const SALT_LENGTH = 16; // 128 bits for PBKDF2 salt +const IV_LENGTH = 12; // 96 bits for AES-GCM + +/** + * Derives a CryptoKey from the encryption secret and salt + */ +async function getKey(secret: string, salt: Uint8Array): Promise { + const encoder = new TextEncoder(); + const keyMaterial = await crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "PBKDF2" }, + false, + ["deriveBits", "deriveKey"] + ); + + return crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt, + iterations: 100000, + hash: "SHA-256", + }, + keyMaterial, + { name: ALGORITHM, length: 256 }, + false, + ["encrypt", "decrypt"] + ); +} + +/** + * Encrypts a plaintext string using AES-GCM + * Returns a base64-encoded string containing salt + IV + ciphertext + */ +export async function encryptToken( + plaintext: string, + encryptionSecret: string +): Promise { + const encoder = new TextEncoder(); + const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH)); + const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)); + + const key = await getKey(encryptionSecret, salt); + + const ciphertext = await crypto.subtle.encrypt( + { name: ALGORITHM, iv }, + key, + encoder.encode(plaintext) + ); + + // Combine salt + IV + ciphertext + const combined = new Uint8Array( + salt.length + iv.length + ciphertext.byteLength + ); + combined.set(salt, 0); + combined.set(iv, salt.length); + combined.set(new Uint8Array(ciphertext), salt.length + iv.length); + + // Encode as base64 + return btoa(String.fromCharCode(...combined)); +} + +/** + * Decrypts a base64-encoded encrypted string + * Expects salt + IV + ciphertext format from encryptToken + */ +export async function decryptToken( + encrypted: string, + encryptionSecret: string +): Promise { + // Decode from base64 + const combined = Uint8Array.from(atob(encrypted), (c) => c.charCodeAt(0)); + + // Extract salt, IV, and ciphertext + const salt = combined.slice(0, SALT_LENGTH); + const iv = combined.slice(SALT_LENGTH, SALT_LENGTH + IV_LENGTH); + const ciphertext = combined.slice(SALT_LENGTH + IV_LENGTH); + + const key = await getKey(encryptionSecret, salt); + + const decrypted = await crypto.subtle.decrypt( + { name: ALGORITHM, iv }, + key, + ciphertext + ); + + const decoder = new TextDecoder(); + return decoder.decode(decrypted); +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 7fdfe81..37e0dad 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -23,6 +23,11 @@ export interface Env { AWS_ACCESS_KEY_ID: string; AWS_SECRET_ACCESS_KEY: string; NEXT_PUBLIC_ENVIRONMENT: Environment; + // Google Calendar OAuth + GOOGLE_CLIENT_ID: string; + GOOGLE_CLIENT_SECRET: string; + // Encryption key for token storage (should be 32+ characters) + TOKEN_ENCRYPTION_SECRET: string; } export interface DBEnv { diff --git a/packages/web/app/api/calendar/authorize/route.ts b/packages/web/app/api/calendar/authorize/route.ts new file mode 100644 index 0000000..f011473 --- /dev/null +++ b/packages/web/app/api/calendar/authorize/route.ts @@ -0,0 +1,26 @@ +import { getAuthorizationUrl } from "@/lib/calendar"; +import { routeHandler } from "@/lib/route"; +import { nanoid } from "nanoid"; +import { NextResponse } from "next/server"; + +/** + * GET /api/calendar/authorize + * + * Initiates the Google Calendar OAuth flow by redirecting to Google's consent screen. + * The user ID is encoded in the state parameter to verify on callback. + */ +export const GET = routeHandler(async (req, user) => { + // Create state with user ID and nonce for CSRF protection + // The nonce adds randomness to make state unguessable + const state = Buffer.from( + JSON.stringify({ + userId: user.uid, + timestamp: Date.now(), + nonce: nanoid(), + }) + ).toString("base64url"); + + const authUrl = getAuthorizationUrl(state); + + return NextResponse.redirect(authUrl); +}); diff --git a/packages/web/app/api/calendar/callback/route.ts b/packages/web/app/api/calendar/callback/route.ts new file mode 100644 index 0000000..e7fe654 --- /dev/null +++ b/packages/web/app/api/calendar/callback/route.ts @@ -0,0 +1,125 @@ +import { + exchangeCodeForTokens, + getGoogleUserEmail, + upsertCalendarConnection, +} from "@/lib/calendar"; +import { routeHandler } from "@/lib/route"; +import { env } from "@/web-env"; +import { NextResponse } from "next/server"; + +interface OAuthState { + userId: string; + timestamp: number; +} + +// Maximum age for state parameter (10 minutes) +const STATE_MAX_AGE_MS = 10 * 60 * 1000; + +/** + * GET /api/calendar/callback + * + * Handles the OAuth callback from Google after user grants calendar access. + * Exchanges the authorization code for tokens and stores them securely. + */ +export const GET = routeHandler( + async (req, user) => { + const url = new URL(req.url); + const code = url.searchParams.get("code"); + const stateParam = url.searchParams.get("state"); + const error = url.searchParams.get("error"); + + // Handle error from Google (e.g., user denied access) + if (error) { + req.log.warn("Google OAuth error", { error }); + return NextResponse.redirect( + `${env.NEXT_PUBLIC_APP_URL}/settings/calendar?error=access_denied` + ); + } + + // Validate code is present + if (!code) { + req.log.warn("Missing authorization code"); + return NextResponse.redirect( + `${env.NEXT_PUBLIC_APP_URL}/settings/calendar?error=missing_code` + ); + } + + // Validate and parse state + if (!stateParam) { + req.log.warn("Missing state parameter"); + return NextResponse.redirect( + `${env.NEXT_PUBLIC_APP_URL}/settings/calendar?error=invalid_state` + ); + } + + let state: OAuthState; + try { + state = JSON.parse(Buffer.from(stateParam, "base64url").toString()); + } catch { + req.log.warn("Invalid state format"); + return NextResponse.redirect( + `${env.NEXT_PUBLIC_APP_URL}/settings/calendar?error=invalid_state` + ); + } + + // Verify state is not expired + if (Date.now() - state.timestamp > STATE_MAX_AGE_MS) { + req.log.warn("State expired"); + return NextResponse.redirect( + `${env.NEXT_PUBLIC_APP_URL}/settings/calendar?error=state_expired` + ); + } + + // Verify user matches the one who initiated the flow + if (state.userId !== user.uid) { + req.log.warn("User ID mismatch", { + stateUserId: state.userId, + currentUserId: user.uid, + }); + return NextResponse.redirect( + `${env.NEXT_PUBLIC_APP_URL}/settings/calendar?error=user_mismatch` + ); + } + + try { + // Exchange authorization code for tokens + const tokens = await exchangeCodeForTokens(code); + + if ( + !tokens.access_token || + !tokens.refresh_token || + !tokens.expiry_date + ) { + req.log.error("Missing required tokens from Google", { tokens }); + return NextResponse.redirect( + `${env.NEXT_PUBLIC_APP_URL}/settings/calendar?error=token_error` + ); + } + + // Get user's Google email + const googleEmail = await getGoogleUserEmail(tokens.access_token); + + // Store the connection + await upsertCalendarConnection(user.uid, { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiryDate: tokens.expiry_date, + googleEmail, + calendarIds: [], // User will select calendars later + }); + + req.log.info("Calendar connection created", { googleEmail }); + + // Redirect to calendar settings page on success + return NextResponse.redirect( + `${env.NEXT_PUBLIC_APP_URL}/settings/calendar?success=connected` + ); + } catch (err) { + req.log.error("Failed to exchange code for tokens", { error: err }); + return NextResponse.redirect( + `${env.NEXT_PUBLIC_APP_URL}/settings/calendar?error=exchange_failed` + ); + } + }, + true // Enable redirect on unauthenticated +); diff --git a/packages/web/app/api/calendar/connection/route.ts b/packages/web/app/api/calendar/connection/route.ts new file mode 100644 index 0000000..40420d2 --- /dev/null +++ b/packages/web/app/api/calendar/connection/route.ts @@ -0,0 +1,98 @@ +import { + deleteCalendarConnection, + getCalendarConnection, + getCalendarTokens, + listCalendars, + updateCalendarIds, +} from "@/lib/calendar"; +import { routeHandler } from "@/lib/route"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +/** + * GET /api/calendar/connection + * + * Gets the current user's calendar connection status and available calendars. + */ +export const GET = routeHandler(async (req, user) => { + const connection = await getCalendarConnection(user.uid); + + if (!connection) { + return NextResponse.json({ connected: false }); + } + + // Get tokens (will auto-refresh if expired) + const tokens = await getCalendarTokens(user.uid); + + if (!tokens) { + return NextResponse.json({ connected: false }); + } + + // Fetch available calendars from Google + let calendars; + let calendarFetchError = false; + try { + calendars = await listCalendars(tokens.accessToken); + } catch (err) { + req.log.error("Failed to list calendars", { error: err }); + calendars = []; + calendarFetchError = true; + } + + return NextResponse.json({ + connected: true, + googleEmail: tokens.googleEmail, + selectedCalendarIds: tokens.calendarIds, + availableCalendars: calendars.map((cal) => ({ + id: cal.id, + summary: cal.summary, + primary: cal.primary, + backgroundColor: cal.backgroundColor, + })), + // Indicates if calendar list fetch failed so UI can show appropriate message + calendarFetchError, + }); +}); + +const updateCalendarsSchema = z.object({ + calendarIds: z.array(z.string()), +}); + +/** + * PATCH /api/calendar/connection + * + * Updates the selected calendar IDs for the current user's connection. + */ +export const PATCH = routeHandler(async (req, user) => { + const body = await req.json(); + + const parsed = updateCalendarsSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request body" }, + { status: 400 } + ); + } + + const connection = await getCalendarConnection(user.uid); + if (!connection) { + return NextResponse.json( + { error: "No calendar connection found" }, + { status: 404 } + ); + } + + await updateCalendarIds(user.uid, parsed.data.calendarIds); + + return NextResponse.json({ success: true }); +}); + +/** + * DELETE /api/calendar/connection + * + * Disconnects the current user's Google Calendar. + */ +export const DELETE = routeHandler(async (req, user) => { + await deleteCalendarConnection(user.uid); + return NextResponse.json({ success: true }); +}); diff --git a/packages/web/lib/calendar/calendar-connection.ts b/packages/web/lib/calendar/calendar-connection.ts new file mode 100644 index 0000000..1b80acc --- /dev/null +++ b/packages/web/lib/calendar/calendar-connection.ts @@ -0,0 +1,228 @@ +/** + * Calendar connection service for managing Google Calendar OAuth tokens + */ +import { db } from "@/db"; +import { env } from "@/web-env"; +import { eq } from "drizzle-orm"; +import { nanoid } from "nanoid"; +import { calendarConnections } from "shared/src/db/schema"; +import { decryptToken, encryptToken } from "shared/src/encryption"; + +import { refreshAccessToken } from "./google-oauth"; + +export interface CalendarConnectionData { + accessToken: string; + refreshToken: string; + expiryDate: number; + googleEmail?: string; + calendarIds?: string[]; +} + +// In-memory lock to prevent concurrent token refreshes per user +const refreshLocks = new Map>(); + +/** + * Safely parses JSON with a fallback value + */ +function safeJsonParse(json: string, fallback: T): T { + try { + return JSON.parse(json); + } catch { + return fallback; + } +} + +/** + * Creates or updates a calendar connection for a user + */ +export async function upsertCalendarConnection( + userId: string, + data: CalendarConnectionData +) { + const encryptionSecret = env.TOKEN_ENCRYPTION_SECRET; + + const [accessTokenEncrypted, refreshTokenEncrypted] = await Promise.all([ + encryptToken(data.accessToken, encryptionSecret), + encryptToken(data.refreshToken, encryptionSecret), + ]); + + const existing = await db() + .select() + .from(calendarConnections) + .where(eq(calendarConnections.userId, userId)) + .get(); + + if (existing) { + // Update existing connection + await db() + .update(calendarConnections) + .set({ + accessTokenEncrypted, + refreshTokenEncrypted, + tokenExpiry: new Date(data.expiryDate), + googleEmail: data.googleEmail, + calendarIds: JSON.stringify(data.calendarIds || []), + }) + .where(eq(calendarConnections.userId, userId)); + + return existing.id; + } else { + // Create new connection + const id = nanoid(); + await db() + .insert(calendarConnections) + .values({ + id, + userId, + accessTokenEncrypted, + refreshTokenEncrypted, + tokenExpiry: new Date(data.expiryDate), + googleEmail: data.googleEmail, + calendarIds: JSON.stringify(data.calendarIds || []), + }); + + return id; + } +} + +/** + * Gets a calendar connection for a user + */ +export async function getCalendarConnection(userId: string) { + const connection = await db() + .select() + .from(calendarConnections) + .where(eq(calendarConnections.userId, userId)) + .get(); + + return connection; +} + +/** + * Gets decrypted tokens for a user's calendar connection + * Automatically refreshes the access token if expired + * Returns null if connection doesn't exist or tokens are corrupted + */ +export async function getCalendarTokens(userId: string): Promise<{ + accessToken: string; + refreshToken: string; + calendarIds: string[]; + googleEmail: string | null; +} | null> { + const connection = await getCalendarConnection(userId); + if (!connection) { + return null; + } + + const encryptionSecret = env.TOKEN_ENCRYPTION_SECRET; + + // Decrypt tokens with error handling for corrupted data + let accessToken: string; + let refreshToken: string; + try { + [accessToken, refreshToken] = await Promise.all([ + decryptToken(connection.accessTokenEncrypted, encryptionSecret), + decryptToken(connection.refreshTokenEncrypted, encryptionSecret), + ]); + } catch (err) { + // Token decryption failed - likely corrupted data or changed encryption key + // Delete the corrupted connection so user can re-authenticate + console.error("Token decryption failed for user", userId, err); + await deleteCalendarConnection(userId); + return null; + } + + // Check if token is expired (with 5 minute buffer) + const isExpired = + connection.tokenExpiry.getTime() < Date.now() + 5 * 60 * 1000; + + if (isExpired) { + // Use lock to prevent concurrent refresh attempts for the same user + const existingLock = refreshLocks.get(userId); + if (existingLock) { + // Wait for existing refresh to complete, then re-fetch tokens + await existingLock; + return getCalendarTokens(userId); + } + + // Create a new lock for this refresh operation + let resolveLock: () => void; + const lockPromise = new Promise((resolve) => { + resolveLock = resolve; + }); + refreshLocks.set(userId, lockPromise); + + try { + // Re-check expiry after acquiring lock (another request may have refreshed) + const freshConnection = await getCalendarConnection(userId); + if ( + freshConnection && + freshConnection.tokenExpiry.getTime() >= Date.now() + 5 * 60 * 1000 + ) { + // Token was refreshed by another request, decrypt and return + const freshAccessToken = await decryptToken( + freshConnection.accessTokenEncrypted, + encryptionSecret + ); + return { + accessToken: freshAccessToken, + refreshToken, + calendarIds: safeJsonParse(freshConnection.calendarIds, []), + googleEmail: freshConnection.googleEmail, + }; + } + + // Refresh the token + const newCredentials = await refreshAccessToken(refreshToken); + + if (!newCredentials.access_token || !newCredentials.expiry_date) { + throw new Error( + "Failed to refresh access token: incomplete credentials returned" + ); + } + + accessToken = newCredentials.access_token; + + // Update stored tokens + await upsertCalendarConnection(userId, { + accessToken, + refreshToken: newCredentials.refresh_token || refreshToken, + expiryDate: newCredentials.expiry_date, + googleEmail: connection.googleEmail || undefined, + calendarIds: safeJsonParse(connection.calendarIds, []), + }); + } finally { + // Release lock + refreshLocks.delete(userId); + resolveLock!(); + } + } + + return { + accessToken, + refreshToken, + calendarIds: safeJsonParse(connection.calendarIds, []), + googleEmail: connection.googleEmail, + }; +} + +/** + * Updates the selected calendar IDs for a user's connection + */ +export async function updateCalendarIds(userId: string, calendarIds: string[]) { + await db() + .update(calendarConnections) + .set({ + calendarIds: JSON.stringify(calendarIds), + }) + .where(eq(calendarConnections.userId, userId)); +} + +/** + * Deletes a user's calendar connection + */ +export async function deleteCalendarConnection(userId: string) { + await db() + .delete(calendarConnections) + .where(eq(calendarConnections.userId, userId)); +} diff --git a/packages/web/lib/calendar/google-oauth.ts b/packages/web/lib/calendar/google-oauth.ts new file mode 100644 index 0000000..4bebf64 --- /dev/null +++ b/packages/web/lib/calendar/google-oauth.ts @@ -0,0 +1,87 @@ +/** + * Google Calendar OAuth utilities + */ +import { env } from "@/web-env"; +import { google } from "googleapis"; + +// OAuth2 scopes for Google Calendar +export const GOOGLE_CALENDAR_SCOPES = [ + "https://www.googleapis.com/auth/calendar.readonly", + "https://www.googleapis.com/auth/calendar.events.readonly", + "https://www.googleapis.com/auth/userinfo.email", +]; + +/** + * Creates a Google OAuth2 client + */ +export function createOAuth2Client(redirectUri?: string) { + return new google.auth.OAuth2( + env.GOOGLE_CLIENT_ID, + env.GOOGLE_CLIENT_SECRET, + redirectUri || `${env.NEXT_PUBLIC_APP_URL}/api/calendar/callback` + ); +} + +/** + * Generates the Google OAuth authorization URL + */ +export function getAuthorizationUrl(state?: string): string { + const oauth2Client = createOAuth2Client(); + + return oauth2Client.generateAuthUrl({ + access_type: "offline", // Get refresh token + scope: GOOGLE_CALENDAR_SCOPES, + prompt: "consent", // Always show consent screen to ensure refresh token + state, + }); +} + +/** + * Exchanges an authorization code for tokens + */ +export async function exchangeCodeForTokens(code: string) { + const oauth2Client = createOAuth2Client(); + const { tokens } = await oauth2Client.getToken(code); + return tokens; +} + +/** + * Refreshes an access token using a refresh token + */ +export async function refreshAccessToken(refreshToken: string) { + const oauth2Client = createOAuth2Client(); + oauth2Client.setCredentials({ refresh_token: refreshToken }); + + const { credentials } = await oauth2Client.refreshAccessToken(); + return credentials; +} + +/** + * Gets the user's email from Google using the access token + */ +export async function getGoogleUserEmail(accessToken: string): Promise { + const oauth2Client = createOAuth2Client(); + oauth2Client.setCredentials({ access_token: accessToken }); + + const oauth2 = google.oauth2({ version: "v2", auth: oauth2Client }); + const { data } = await oauth2.userinfo.get(); + + if (!data.email) { + throw new Error("Could not get user email from Google"); + } + + return data.email; +} + +/** + * Lists the user's calendars + */ +export async function listCalendars(accessToken: string) { + const oauth2Client = createOAuth2Client(); + oauth2Client.setCredentials({ access_token: accessToken }); + + const calendar = google.calendar({ version: "v3", auth: oauth2Client }); + const { data } = await calendar.calendarList.list(); + + return data.items || []; +} diff --git a/packages/web/lib/calendar/index.ts b/packages/web/lib/calendar/index.ts new file mode 100644 index 0000000..faa897c --- /dev/null +++ b/packages/web/lib/calendar/index.ts @@ -0,0 +1,2 @@ +export * from "./calendar-connection"; +export * from "./google-oauth"; diff --git a/packages/web/package.json b/packages/web/package.json index 04ff441..a763ad6 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -28,6 +28,7 @@ "date-fns": "^2.30.0", "drizzle-orm": "^0.30.8", "firebase": "^9.23.0", + "googleapis": "^170.1.0", "jiti": "^1.21.6", "jose": "^6.1.0", "lucide-react": "^0.258.0", diff --git a/packages/web/web-env.ts b/packages/web/web-env.ts index c3bd85a..8c6abe8 100644 --- a/packages/web/web-env.ts +++ b/packages/web/web-env.ts @@ -22,6 +22,10 @@ export const env = createEnv({ AWS_ACCESS_KEY_ID: z.string().min(1), AWS_SECRET_ACCESS_KEY: z.string().min(1), NEXT_PUBLIC_ENVIRONMENT: z.nativeEnum(Environment), + // Google Calendar OAuth + GOOGLE_CLIENT_ID: z.string().min(1), + GOOGLE_CLIENT_SECRET: z.string().min(1), + TOKEN_ENCRYPTION_SECRET: z.string().min(32), }, client: { NEXT_PUBLIC_APP_URL: z.string().min(1), @@ -61,5 +65,9 @@ export const env = createEnv({ AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY, NEXT_PUBLIC_ENVIRONMENT: process.env.NEXT_PUBLIC_ENVIRONMENT, + // Google Calendar OAuth + GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, + GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET, + TOKEN_ENCRYPTION_SECRET: process.env.TOKEN_ENCRYPTION_SECRET, }, }); diff --git a/yarn.lock b/yarn.lock index 1d1dbb2..68699a6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7076,6 +7076,11 @@ agent-base@6: dependencies: debug "4" +agent-base@^7.1.2: + version "7.1.4" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.4.tgz#e3cd76d4c548ee895d3c3fd8dc1f6c5b9032e7a8" + integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== + agentkeepalive@^4.2.1: version "4.6.0" resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-4.6.0.tgz#35f73e94b3f40bf65f105219c623ad19c136ea6a" @@ -8882,6 +8887,11 @@ data-uri-to-buffer@^2.0.0: resolved "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz" integrity sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA== +data-uri-to-buffer@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz#d8feb2b2881e6a4f58c2e08acfd0e2834e26222e" + integrity sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A== + data-urls@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-2.0.0.tgz#156485a72963a970f5d5821aaf642bef2bf2db9b" @@ -10513,6 +10523,14 @@ fdir@^6.2.0: resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== +fetch-blob@^3.1.2, fetch-blob@^3.1.4: + version "3.2.0" + resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.2.0.tgz#f09b8d4bbd45adc6f0c20b7e787e793e309dcce9" + integrity sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ== + dependencies: + node-domexception "^1.0.0" + web-streams-polyfill "^3.0.3" + figures@^3.0.0, figures@^3.1.0: version "3.2.0" resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af" @@ -10712,6 +10730,13 @@ formdata-node@^4.3.2: node-domexception "1.0.0" web-streams-polyfill "4.0.0-beta.3" +formdata-polyfill@^4.0.10: + version "4.0.10" + resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423" + integrity sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g== + dependencies: + fetch-blob "^3.1.2" + forwarded@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" @@ -10865,6 +10890,16 @@ gaxios@^4.0.0, gaxios@^4.2.1: is-stream "^2.0.0" node-fetch "^2.6.7" +gaxios@^7.0.0, gaxios@^7.0.0-rc.4: + version "7.1.3" + resolved "https://registry.yarnpkg.com/gaxios/-/gaxios-7.1.3.tgz#c5312f4254abc1b8ab53aef30c22c5229b80b1e1" + integrity sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ== + dependencies: + extend "^3.0.2" + https-proxy-agent "^7.0.1" + node-fetch "^3.3.2" + rimraf "^5.0.1" + gcp-metadata@^4.2.0: version "4.3.1" resolved "https://registry.yarnpkg.com/gcp-metadata/-/gcp-metadata-4.3.1.tgz#fb205fe6a90fef2fd9c85e6ba06e5559ee1eefa9" @@ -10873,6 +10908,15 @@ gcp-metadata@^4.2.0: gaxios "^4.0.0" json-bigint "^1.0.0" +gcp-metadata@^8.0.0: + version "8.1.2" + resolved "https://registry.yarnpkg.com/gcp-metadata/-/gcp-metadata-8.1.2.tgz#e62e3373ddf41fc727ccc31c55c687b798bee898" + integrity sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg== + dependencies: + gaxios "^7.0.0" + google-logging-utils "^1.0.0" + json-bigint "^1.0.0" + generic-pool@3.4.2: version "3.4.2" resolved "https://registry.npmjs.org/generic-pool/-/generic-pool-3.4.2.tgz" @@ -11064,6 +11108,18 @@ glob@9.3.5: minipass "^4.2.4" path-scurry "^1.6.1" +glob@^10.3.7: + version "10.5.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c" + integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg== + dependencies: + foreground-child "^3.1.0" + jackspeak "^3.1.2" + minimatch "^9.0.4" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^1.11.1" + glob@^11.0.0: version "11.0.3" resolved "https://registry.yarnpkg.com/glob/-/glob-11.0.3.tgz#9d8087e6d72ddb3c4707b1d2778f80ea3eaefcd6" @@ -11165,6 +11221,19 @@ globby@^6.1.0: pify "^2.0.0" pinkie-promise "^2.0.0" +google-auth-library@^10.1.0, google-auth-library@^10.2.0: + version "10.5.0" + resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-10.5.0.tgz#3f0ebd47173496b91d2868f572bb8a8180c4b561" + integrity sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w== + dependencies: + base64-js "^1.3.0" + ecdsa-sig-formatter "^1.0.11" + gaxios "^7.0.0" + gcp-metadata "^8.0.0" + google-logging-utils "^1.0.0" + gtoken "^8.0.0" + jws "^4.0.0" + google-auth-library@^7.0.2, google-auth-library@^7.14.0, google-auth-library@^7.6.2: version "7.14.1" resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-7.14.1.tgz#e3483034162f24cc71b95c8a55a210008826213c" @@ -11180,6 +11249,11 @@ google-auth-library@^7.0.2, google-auth-library@^7.14.0, google-auth-library@^7. jws "^4.0.0" lru-cache "^6.0.0" +google-logging-utils@^1.0.0: + version "1.1.3" + resolved "https://registry.yarnpkg.com/google-logging-utils/-/google-logging-utils-1.1.3.tgz#17b71f1f95d266d2ddd356b8f00178433f041b17" + integrity sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA== + google-p12-pem@^3.1.3: version "3.1.4" resolved "https://registry.yarnpkg.com/google-p12-pem/-/google-p12-pem-3.1.4.tgz#123f7b40da204de4ed1fbf2fd5be12c047fc8b3b" @@ -11199,6 +11273,25 @@ googleapis-common@^5.0.2: url-template "^2.0.8" uuid "^8.0.0" +googleapis-common@^8.0.0: + version "8.0.1" + resolved "https://registry.yarnpkg.com/googleapis-common/-/googleapis-common-8.0.1.tgz#5dc9042ec095d75c841e0e95cbeb17a88c010015" + integrity sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A== + dependencies: + extend "^3.0.2" + gaxios "^7.0.0-rc.4" + google-auth-library "^10.1.0" + qs "^6.7.0" + url-template "^2.0.8" + +googleapis@^170.1.0: + version "170.1.0" + resolved "https://registry.yarnpkg.com/googleapis/-/googleapis-170.1.0.tgz#0d7c7a5bbadaac55850b76416fd52f863e6f28a0" + integrity sha512-RLbc7yG6qzZqvAmGcgjvNIoZ7wpcCFxtc+HN+46etxDrlO4a8l5Cb7NxNQGhV91oRmL7mt56VoRoypAtEQEIKg== + dependencies: + google-auth-library "^10.2.0" + googleapis-common "^8.0.0" + googleapis@^84.0.0: version "84.0.0" resolved "https://registry.yarnpkg.com/googleapis/-/googleapis-84.0.0.tgz#55b534b234c2df1af0b0f33c0394f31ac23a20d0" @@ -11260,6 +11353,14 @@ gtoken@^5.0.4: google-p12-pem "^3.1.3" jws "^4.0.0" +gtoken@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/gtoken/-/gtoken-8.0.0.tgz#d67a0e346dd441bfb54ad14040ddc3b632886575" + integrity sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw== + dependencies: + gaxios "^7.0.0" + jws "^4.0.0" + gzip-size@6.0.0, gzip-size@^6.0.0: version "6.0.0" resolved "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz" @@ -11632,6 +11733,14 @@ https-proxy-agent@^5.0.0: agent-base "6" debug "4" +https-proxy-agent@^7.0.1: + version "7.0.6" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz#da8dfeac7da130b05c2ba4b59c9b6cd66611a6b9" + integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw== + dependencies: + agent-base "^7.1.2" + debug "4" + human-signals@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz" @@ -12417,6 +12526,15 @@ jackspeak@^2.3.5: optionalDependencies: "@pkgjs/parseargs" "^0.11.0" +jackspeak@^3.1.2: + version "3.4.3" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" + integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + jackspeak@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-4.1.1.tgz#96876030f450502047fc7e8c7fcf8ce8124e43ae" @@ -14556,7 +14674,7 @@ node-addon-api@^5.0.0: resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz" integrity sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA== -node-domexception@1.0.0: +node-domexception@1.0.0, node-domexception@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== @@ -14582,6 +14700,15 @@ node-fetch@^2.6.7: dependencies: whatwg-url "^5.0.0" +node-fetch@^3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.3.2.tgz#d1e889bacdf733b4ff3b2b243eb7a12866a0b78b" + integrity sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA== + dependencies: + data-uri-to-buffer "^4.0.0" + fetch-blob "^3.1.4" + formdata-polyfill "^4.0.10" + node-forge@^1, node-forge@^1.3.1: version "1.3.1" resolved "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz" @@ -15160,7 +15287,7 @@ path-scurry@^1.10.1: lru-cache "^9.1.1 || ^10.0.0" minipass "^5.0.0 || ^6.0.2 || ^7.0.0" -path-scurry@^1.6.1: +path-scurry@^1.11.1, path-scurry@^1.6.1: version "1.11.1" resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== @@ -16283,6 +16410,13 @@ rimraf@^3.0.0, rimraf@^3.0.2: dependencies: glob "^7.1.3" +rimraf@^5.0.1: + version "5.0.10" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-5.0.10.tgz#23b9843d3dc92db71f96e1a2ce92e39fd2a8221c" + integrity sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ== + dependencies: + glob "^10.3.7" + rollup-plugin-inject@^3.0.0: version "3.0.2" resolved "https://registry.npmjs.org/rollup-plugin-inject/-/rollup-plugin-inject-3.0.2.tgz" @@ -18369,6 +18503,11 @@ web-streams-polyfill@4.0.0-beta.3: resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz#2898486b74f5156095e473efe989dcf185047a38" integrity sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug== +web-streams-polyfill@^3.0.3: + version "3.3.3" + resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b" + integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw== + web-vitals@0.2.4: version "0.2.4" resolved "https://registry.npmjs.org/web-vitals/-/web-vitals-0.2.4.tgz"