diff --git a/apps/server/src/api-data/sheets/DeviceAuthProvider.ts b/apps/server/src/api-data/sheets/DeviceAuthProvider.ts new file mode 100644 index 0000000000..2637be5ae1 --- /dev/null +++ b/apps/server/src/api-data/sheets/DeviceAuthProvider.ts @@ -0,0 +1,179 @@ +import { Credentials, OAuth2Client } from 'google-auth-library'; +import { logger } from '../../classes/Logger.js'; +import { LogOrigin, MaybeString, AuthenticationStatus } from 'ontime-types'; +import { consoleSubdued } from '../../utils/console.js'; +import { type ClientSecret } from './sheets.utils.js'; + +const codesUrl = 'https://oauth2.googleapis.com/device/code'; +const tokenUrl = 'https://oauth2.googleapis.com/token'; +const grantType = 'urn:ietf:params:oauth:grant-type:device_code'; +const sheetScope = 'https://www.googleapis.com/auth/spreadsheets'; + +type CodesResponse = { + device_code: string; + expires_in: number; + interval: number; + user_code: string; + verification_url: string; +}; + +export class DeviceAuthProvider { + private currentAuthClient: OAuth2Client | null = null; + private currentAuthUrl: MaybeString = null; + private currentAuthCode: MaybeString = null; + private pollInterval: NodeJS.Timeout | null = null; + private cleanupTimeout: NodeJS.Timeout | null = null; + + constructor() {} + + getStatus(): AuthenticationStatus { + if (this.cleanupTimeout) { + return 'pending'; + } + return this.currentAuthClient ? 'authenticated' : 'not_authenticated'; + } + + getAuthClient(): OAuth2Client | null { + return this.currentAuthClient; + } + + getPendingData() { + return { + verification_url: this.currentAuthUrl, + user_code: this.currentAuthCode, + }; + } + + revoke() { + this.reset(); + } + + private reset() { + this.currentAuthClient = null; + this.currentAuthUrl = null; + this.currentAuthCode = null; + + if (this.pollInterval) { + clearInterval(this.pollInterval); + this.pollInterval = null; + } + if (this.cleanupTimeout) { + clearTimeout(this.cleanupTimeout); + this.cleanupTimeout = null; + } + } + + async authenticate(clientSecret: ClientSecret, onAuthenticated: (client: OAuth2Client) => Promise) { + const { device_code, expires_in, interval, user_code, verification_url } = await this.getDeviceCodes(clientSecret); + this.currentAuthUrl = verification_url; + this.currentAuthCode = user_code; + + this.verifyConnection(clientSecret, device_code, interval, expires_in, onAuthenticated); + + return { verification_url, user_code }; + } + + private async getDeviceCodes(clientSecret: ClientSecret): Promise { + const response = await fetch(codesUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + client_id: clientSecret.installed.client_id, + scope: sheetScope, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to fetch device codes: ${response.status} ${response.statusText} - ${errorText}`); + } + + const deviceCodes: CodesResponse = await response.json(); + return deviceCodes; + } + + private verifyConnection( + clientSecret: ClientSecret, + device_code: string, + interval: number, + expires_in: number, + onAuthenticated: (client: OAuth2Client) => Promise, + ) { + logger.info(LogOrigin.Server, 'Start polling for auth...'); + + this.pollInterval = setInterval(() => this.pollForAuth(clientSecret, device_code, onAuthenticated), interval * 1000); + + if (this.cleanupTimeout) { + clearTimeout(this.cleanupTimeout); + } + this.cleanupTimeout = setTimeout(() => { + if (this.pollInterval) { + clearInterval(this.pollInterval); + this.pollInterval = null; + } + this.cleanupTimeout = null; + }, expires_in * 1000); + } + + private async pollForAuth( + clientSecret: ClientSecret, + device_code: string, + onAuthenticated: (client: OAuth2Client) => Promise, + ) { + try { + const response = await fetch(tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_id: clientSecret.installed.client_id, + client_secret: clientSecret.installed.client_secret, + device_code, + grant_type: grantType, + }), + }); + + if (response.status === 428) { + consoleSubdued('User not auth yet'); + return; + } + + if (!response.ok) { + logger.error(LogOrigin.Server, `Authentication poll failed with code: ${response.status}`); + return; + } + + const auth: Credentials = await response.json(); + + logger.info(LogOrigin.Server, 'Successfully Authenticated'); + const client = new OAuth2Client({ + clientId: clientSecret.installed.client_id, + clientSecret: clientSecret.installed.client_secret, + }); + + client.setCredentials({ + refresh_token: auth.refresh_token, + access_token: auth.access_token, + scope: auth.scope, + token_type: auth.token_type, + }); + + this.currentAuthClient = client; + + if (this.cleanupTimeout) { + clearTimeout(this.cleanupTimeout); + this.cleanupTimeout = null; + } + + if (this.pollInterval) { + clearInterval(this.pollInterval); + this.pollInterval = null; + } + + await onAuthenticated(client); + } catch (error) { + logger.error(LogOrigin.Server, `Authentication poll error: ${(error as Error).message}`); + } + } +} diff --git a/apps/server/src/api-data/sheets/GoogleSheetsClient.ts b/apps/server/src/api-data/sheets/GoogleSheetsClient.ts new file mode 100644 index 0000000000..7472e1b9ec --- /dev/null +++ b/apps/server/src/api-data/sheets/GoogleSheetsClient.ts @@ -0,0 +1,41 @@ +import { sheets, type sheets_v4 } from '@googleapis/sheets'; +import { OAuth2Client } from 'google-auth-library'; + +export class GoogleSheetsClient { + private sheets: sheets_v4.Sheets; + + constructor(auth: OAuth2Client) { + this.sheets = sheets({ version: 'v4', auth }); + } + + async getSpreadsheet(spreadsheetId: string) { + return this.sheets.spreadsheets.get({ + spreadsheetId, + includeGridData: false, + }); + } + + async getValues(spreadsheetId: string, range: string) { + return this.sheets.spreadsheets.values.get({ + spreadsheetId, + valueRenderOption: 'FORMATTED_VALUE', + majorDimension: 'ROWS', + range, + }); + } + + async batchUpdate( + spreadsheetId: string, + requests: sheets_v4.Schema$Request[], + responseRanges?: string[], + ) { + return this.sheets.spreadsheets.batchUpdate({ + spreadsheetId, + requestBody: { + includeSpreadsheetInResponse: false, + responseRanges, + requests, + }, + }); + } +} diff --git a/apps/server/src/api-data/sheets/__tests__/DeviceAuthProvider.test.ts b/apps/server/src/api-data/sheets/__tests__/DeviceAuthProvider.test.ts new file mode 100644 index 0000000000..cb50414d76 --- /dev/null +++ b/apps/server/src/api-data/sheets/__tests__/DeviceAuthProvider.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { DeviceAuthProvider } from '../DeviceAuthProvider.js'; + +describe('DeviceAuthProvider', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('should start with not_authenticated status', () => { + const provider = new DeviceAuthProvider(); + expect(provider.getStatus()).toBe('not_authenticated'); + }); + + it('should initiate authentication and poll', async () => { + const provider = new DeviceAuthProvider(); + const mockClientSecret = { + installed: { + client_id: 'id', + client_secret: 'secret', + auth_uri: '', + token_uri: '', + auth_provider_x509_cert_url: '', + }, + }; + + (fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + device_code: 'dc', + expires_in: 60, + interval: 5, + user_code: 'uc', + verification_url: 'vurl', + }), + }); + + const onAuth = vi.fn(); + const result = await provider.authenticate(mockClientSecret, onAuth); + + expect(result).toEqual({ + verification_url: 'vurl', + user_code: 'uc', + }); + expect(provider.getStatus()).toBe('pending'); + expect(fetch).toHaveBeenCalledTimes(1); + + // Poll 1: not yet authorized + (fetch as any).mockResolvedValueOnce({ + status: 428, + ok: false, + }); + + vi.advanceTimersByTime(5000); + // Poll happens in background + await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); + expect(provider.getStatus()).toBe('pending'); + + // Poll 2: success + (fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + access_token: 'at', + refresh_token: 'rt', + scope: 'scope', + token_type: 'Bearer', + }), + }); + + vi.advanceTimersByTime(5000); + await vi.waitFor(() => expect(onAuth).toHaveBeenCalled()); + expect(provider.getStatus()).toBe('authenticated'); + }); + + it('should stop polling after expiration', async () => { + const provider = new DeviceAuthProvider(); + const mockClientSecret = { + installed: { + client_id: 'id', + client_secret: 'secret', + auth_uri: '', + token_uri: '', + auth_provider_x509_cert_url: '', + }, + }; + + (fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + device_code: 'dc', + expires_in: 10, + interval: 5, + user_code: 'uc', + verification_url: 'vurl', + }), + }); + + await provider.authenticate(mockClientSecret, vi.fn()); + expect(provider.getStatus()).toBe('pending'); + + vi.advanceTimersByTime(11000); + expect(provider.getStatus()).toBe('not_authenticated'); + }); + + it('should reset on revoke', async () => { + const provider = new DeviceAuthProvider(); + const mockClientSecret = { + installed: { + client_id: 'id', + client_secret: 'secret', + auth_uri: '', + token_uri: '', + auth_provider_x509_cert_url: '', + }, + }; + + (fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + device_code: 'dc', + expires_in: 60, + interval: 5, + user_code: 'uc', + verification_url: 'vurl', + }), + }); + + await provider.authenticate(mockClientSecret, vi.fn()); + provider.revoke(); + expect(provider.getStatus()).toBe('not_authenticated'); + expect(provider.getAuthClient()).toBeNull(); + }); +}); diff --git a/apps/server/src/api-data/sheets/__tests__/GoogleSheetsClient.test.ts b/apps/server/src/api-data/sheets/__tests__/GoogleSheetsClient.test.ts new file mode 100644 index 0000000000..ec276eb526 --- /dev/null +++ b/apps/server/src/api-data/sheets/__tests__/GoogleSheetsClient.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { sheets } from '@googleapis/sheets'; +import { GoogleSheetsClient } from '../GoogleSheetsClient.js'; + +vi.mock('@googleapis/sheets', () => ({ + sheets: vi.fn(), +})); + +describe('GoogleSheetsClient', () => { + let mockSheets: any; + + beforeEach(() => { + mockSheets = { + spreadsheets: { + get: vi.fn(), + values: { + get: vi.fn(), + }, + batchUpdate: vi.fn(), + }, + }; + (sheets as any).mockReturnValue(mockSheets); + }); + + it('should call spreadsheets.get', async () => { + const client = new GoogleSheetsClient({} as any); + mockSheets.spreadsheets.get.mockResolvedValue({ data: {} }); + await client.getSpreadsheet('id'); + expect(mockSheets.spreadsheets.get).toHaveBeenCalledWith({ + spreadsheetId: 'id', + includeGridData: false, + }); + }); + + it('should call spreadsheets.values.get', async () => { + const client = new GoogleSheetsClient({} as any); + mockSheets.spreadsheets.values.get.mockResolvedValue({ data: {} }); + await client.getValues('id', 'range'); + expect(mockSheets.spreadsheets.values.get).toHaveBeenCalledWith({ + spreadsheetId: 'id', + valueRenderOption: 'FORMATTED_VALUE', + majorDimension: 'ROWS', + range: 'range', + }); + }); + + it('should call spreadsheets.batchUpdate', async () => { + const client = new GoogleSheetsClient({} as any); + mockSheets.spreadsheets.batchUpdate.mockResolvedValue({ data: {} }); + await client.batchUpdate('id', [{ updateCells: {} }], ['range']); + expect(mockSheets.spreadsheets.batchUpdate).toHaveBeenCalledWith({ + spreadsheetId: 'id', + requestBody: { + includeSpreadsheetInResponse: false, + responseRanges: ['range'], + requests: [{ updateCells: {} }], + }, + }); + }); +}); diff --git a/apps/server/src/api-data/sheets/sheets.service.ts b/apps/server/src/api-data/sheets/sheets.service.ts index aa16725d5a..1e374dbb36 100644 --- a/apps/server/src/api-data/sheets/sheets.service.ts +++ b/apps/server/src/api-data/sheets/sheets.service.ts @@ -20,8 +20,8 @@ import { } from 'ontime-types'; import { ImportMap, getErrorMessage } from 'ontime-utils'; -import { sheets, type sheets_v4 } from '@googleapis/sheets'; -import { Credentials, OAuth2Client } from 'google-auth-library'; +import { type sheets_v4 } from '@googleapis/sheets'; +import { OAuth2Client } from 'google-auth-library'; import { logger } from '../../classes/Logger.js'; import { parseRundowns } from '../rundown/rundown.parser.js'; @@ -29,42 +29,18 @@ import { parseRundowns } from '../rundown/rundown.parser.js'; import { getCurrentRundown, getProjectCustomFields, processRundown } from '../rundown/rundown.dao.js'; import { parseExcel } from '../excel/excel.parser.js'; import { parseCustomFields } from '../custom-fields/customFields.parser.js'; -import { consoleSubdued } from '../../utils/console.js'; import { cellRequestFromEvent, type ClientSecret, getA1Notation, isClientSecret } from './sheets.utils.js'; import { catchCommonImportXlsxError } from './googleApi.utils.js'; +import { DeviceAuthProvider } from './DeviceAuthProvider.js'; +import { GoogleSheetsClient } from './GoogleSheetsClient.js'; -const sheetScope = 'https://www.googleapis.com/auth/spreadsheets'; -const codesUrl = 'https://oauth2.googleapis.com/device/code'; -const tokenUrl = 'https://oauth2.googleapis.com/token'; -const grantType = 'urn:ietf:params:oauth:grant-type:device_code'; - -let currentAuthClient: OAuth2Client | null = null; -let currentClientSecret: ClientSecret | null = null; -let currentAuthUrl: MaybeString = null; -let currentAuthCode: MaybeString = null; - +const authProvider = new DeviceAuthProvider(); let currentSheetId: MaybeString = null; -let pollInterval: NodeJS.Timeout | null = null; -let cleanupTimeout: NodeJS.Timeout | null = null; - function reset() { - currentAuthClient = null; - currentClientSecret = null; - currentAuthUrl = null; - currentAuthCode = null; - + authProvider.revoke(); currentSheetId = null; - - if (pollInterval) { - clearInterval(pollInterval); - pollInterval = null; - } - if (cleanupTimeout) { - clearTimeout(cleanupTimeout); - cleanupTimeout = null; - } } /** @@ -95,148 +71,20 @@ export function handleClientSecret(clientSecret: string): ClientSecret { return clientSecretObject; } -// https://developers.google.com/identity/protocols/oauth2/limited-input-device#success-response -type CodesResponse = { - device_code: string; - expires_in: number; - interval: number; - user_code: string; - verification_url: string; -}; - -/** - * Establishes connection with Google Auth server and retrieves device codes - */ -async function getDeviceCodes(clientSecret: ClientSecret): Promise { - const response = await fetch(codesUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - client_id: clientSecret.installed.client_id, - scope: sheetScope, - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to fetch device codes: ${response.status} ${response.statusText} - ${errorText}`); - } - - const deviceCodes: CodesResponse = await response.json(); - return deviceCodes; -} - -/** - * Gets credentials from Google Auth server - */ -function verifyConnection( - clientSecret: ClientSecret, - device_code: string, - interval: number, - expires_in: number, - postAction: () => Promise, -) { - logger.info(LogOrigin.Server, 'Start polling for auth...'); - - // create poller to check for auth - pollInterval = setInterval(pollForAuth, interval * 1000); - - // schedule to clear the poller when we know the token is no longer valid - if (cleanupTimeout) { - clearTimeout(cleanupTimeout); - cleanupTimeout = null; - } - cleanupTimeout = setTimeout(() => { - if (pollInterval) { - clearInterval(pollInterval); - pollInterval = null; - } - }, expires_in * 1000); - - async function pollForAuth() { - try { - const response = await fetch(tokenUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - client_id: clientSecret.installed.client_id, - client_secret: clientSecret.installed.client_secret, - device_code, - grant_type: grantType, - }), - }); - - // server returns 428 if user hasnt yet completed the auth process - if (response.status === 428) { - consoleSubdued('User not auth yet'); - return; - } - - if (!response.ok) { - logger.error(LogOrigin.Server, `Authentication poll failed with code: ${response.status}`); - return; - } - - const auth: Credentials = await response.json(); - - logger.info(LogOrigin.Server, 'Successfully Authenticated'); - const client = new OAuth2Client({ - clientId: clientSecret.installed.client_id, - clientSecret: clientSecret.installed.client_secret, - }); - - client.setCredentials({ - refresh_token: auth.refresh_token, - access_token: auth.access_token, - scope: auth.scope, - token_type: auth.token_type, - }); - - // save client and cancel tasks - currentAuthClient = client; - - if (cleanupTimeout) { - clearTimeout(cleanupTimeout); - cleanupTimeout = null; - } - - if (pollInterval) { - clearInterval(pollInterval); - pollInterval = null; - } - - await postAction(); - } catch (error) { - logger.error(LogOrigin.Server, `Authentication poll error: ${(error as Error).message}`); - } - } -} - export function hasAuth(): { authenticated: AuthenticationStatus; sheetId: string } { if (!currentSheetId) { throw new Error('No sheet ID'); } - if (cleanupTimeout) { - return { authenticated: 'pending', sheetId: currentSheetId }; - } - return { authenticated: currentAuthClient ? 'authenticated' : 'not_authenticated', sheetId: currentSheetId }; + return { authenticated: authProvider.getStatus(), sheetId: currentSheetId }; } async function verifySheet( - sheetId = currentSheetId, - authClient = currentAuthClient, + sheetId: string, + authClient: OAuth2Client, ): Promise<{ worksheetOptions: string[] }> { - if (!sheetId || !authClient) { - throw new Error('Missing sheet ID or authentication'); - } - try { - const spreadsheets = await sheets({ version: 'v4', auth: authClient }).spreadsheets.get({ - spreadsheetId: sheetId, - includeGridData: false, - }); + const client = new GoogleSheetsClient(authClient); + const spreadsheets = await client.getSpreadsheet(sheetId); const worksheets: string[] = []; spreadsheets.data.sheets?.forEach((sheet) => { @@ -261,26 +109,17 @@ export async function handleInitialConnection( clientSecret: ClientSecret, sheetId: string, ): Promise<{ verification_url: string; user_code: string }> { - currentClientSecret = clientSecret; + currentSheetId = sheetId; - // we know there is an ongoing process if there is a timeout for cleanup - // if there is an ongoing process, we return its data - if (cleanupTimeout) { - if (!currentAuthUrl || !currentAuthCode) { + if (authProvider.getStatus() === 'pending') { + const pendingData = authProvider.getPendingData(); + if (!pendingData.verification_url || !pendingData.user_code) { throw new Error('No ongoing connection'); } - return { verification_url: currentAuthUrl, user_code: currentAuthCode }; + return pendingData as { verification_url: string; user_code: string }; } - const { device_code, expires_in, interval, user_code, verification_url } = await getDeviceCodes(currentClientSecret); - currentAuthUrl = verification_url; - currentAuthCode = user_code; - currentSheetId = sheetId; - - // schedule verifying token and the existence of the sheetID - verifyConnection(currentClientSecret, device_code, interval, expires_in, verifySheet); - - return { verification_url, user_code }; + return authProvider.authenticate(clientSecret, (client) => verifySheet(sheetId, client).then(() => {})); } /** @@ -288,22 +127,21 @@ export async function handleInitialConnection( * @returns */ export async function getWorksheetOptions(sheetId: string): ReturnType { - if (!currentAuthClient) { + const authClient = authProvider.getAuthClient(); + if (!authClient) { throw new Error('Not authenticated'); } currentSheetId = sheetId; - return verifySheet(sheetId); + return verifySheet(sheetId, authClient); } -async function verifyWorksheet(sheetId: string, worksheet: string): Promise<{ worksheetId: number; range: string }> { - if (!currentAuthClient) { - throw new Error('Not authenticated'); - } - - const spreadsheets = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.get({ - spreadsheetId: sheetId, - }); +async function verifyWorksheet( + client: GoogleSheetsClient, + sheetId: string, + worksheet: string, +): Promise<{ worksheetId: number; range: string }> { + const spreadsheets = await client.getSpreadsheet(sheetId); if (spreadsheets.status !== 200) { throw new Error(`Request failed: ${spreadsheets.status} ${spreadsheets.statusText}`); @@ -320,13 +158,7 @@ async function verifyWorksheet(sheetId: string, worksheet: string): Promise<{ wo if (!selectedWorksheet) { throw new Error('Could not find worksheet'); } - /* - The first spreadsheet provided by google sheet has an id = 0, - so !0 returns true, the only other number that returns true in this setup is NaN, - so if x !== 0 && x !== NaN, then !x returns false, we indeed want !NaN to return true, - but we would like !0 to return false, reason why is also checked that the id is not 0, - because if it is 0, then I should not enter the condition. - */ + if ( !selectedWorksheet.properties || (!selectedWorksheet.properties.sheetId && selectedWorksheet.properties.sheetId !== 0) @@ -342,18 +174,15 @@ async function verifyWorksheet(sheetId: string, worksheet: string): Promise<{ wo } export async function upload(sheetId: string, options: ImportMap) { - if (!currentAuthClient) { + const authClient = authProvider.getAuthClient(); + if (!authClient) { throw new Error('Not authenticated'); } + const client = new GoogleSheetsClient(authClient); - const { worksheetId, range } = await verifyWorksheet(sheetId, options.worksheet); + const { worksheetId, range } = await verifyWorksheet(client, sheetId, options.worksheet); - const readResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.values.get({ - spreadsheetId: sheetId, - valueRenderOption: 'FORMATTED_VALUE', - majorDimension: 'ROWS', - range, - }); + const readResponse = await client.getValues(sheetId, range); if (readResponse.status !== 200 || !readResponse.data.values) { throw new Error(`Sheet read failed: ${readResponse.statusText}`); @@ -429,14 +258,7 @@ export async function upload(sheetId: string, options: ImportMap) { throw new Error(`Sheet write failed to correctly parse rundown: ${e}`); } - const writeResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.batchUpdate({ - spreadsheetId: sheetId, - requestBody: { - includeSpreadsheetInResponse: false, - responseRanges: [range], - requests: updateRundown, - }, - }); + const writeResponse = await client.batchUpdate(sheetId, updateRundown, [range]); if (writeResponse.status === 200) { logger.info(LogOrigin.Server, `Sheet write ${writeResponse.statusText}`); @@ -459,18 +281,15 @@ export async function download( customFields: CustomFields; summary: RundownSummary; }> { - if (!currentAuthClient) { + const authClient = authProvider.getAuthClient(); + if (!authClient) { throw new Error('Not authenticated'); } + const client = new GoogleSheetsClient(authClient); - const { range } = await verifyWorksheet(sheetId, options.worksheet); + const { range } = await verifyWorksheet(client, sheetId, options.worksheet); - const googleResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.values.get({ - spreadsheetId: sheetId, - valueRenderOption: 'FORMATTED_VALUE', - majorDimension: 'ROWS', - range, - }); + const googleResponse = await client.getValues(sheetId, range); if (googleResponse.status !== 200) { throw new Error(`Sheet read failed: ${googleResponse.statusText}`);