From 854da073d4664e8057e01b517a1decf1e47cc029 Mon Sep 17 00:00:00 2001 From: Mark Rapson Date: Thu, 17 Sep 2026 18:54:22 +0100 Subject: [PATCH] Initial Sheet Relay application --- .dockerignore | 6 + .gitea/workflows/publish-image.yaml | 27 + .gitignore | 3 + Dockerfile | 13 + README.md | 85 ++ compose.yaml | 9 + lib/config-store.js | 100 ++ lib/google-sheets.js | 100 ++ lib/request-log.js | 30 + package-lock.json | 1734 +++++++++++++++++++++++++++ package.json | 15 + public/app.js | 163 +++ public/index.html | 64 + public/styles.css | 26 + server.js | 510 ++++++++ 15 files changed, 2885 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitea/workflows/publish-image.yaml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 compose.yaml create mode 100644 lib/config-store.js create mode 100644 lib/google-sheets.js create mode 100644 lib/request-log.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/styles.css create mode 100644 server.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..34c0f98 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +data +.git +.gitignore +.env +npm-debug.log \ No newline at end of file diff --git a/.gitea/workflows/publish-image.yaml b/.gitea/workflows/publish-image.yaml new file mode 100644 index 0000000..388926d --- /dev/null +++ b/.gitea/workflows/publish-image.yaml @@ -0,0 +1,27 @@ +name: Publish container image + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + env: + REGISTRY: git.lzstealth.com + IMAGE: git.lzstealth.com/lzstealth/google-sheets-to-api + steps: + - name: Check out source + uses: actions/checkout@v4 + - name: Log in to Gitea container registry + env: + REGISTRY_USERNAME: ${{ secrets.PACKAGE_REGISTRY_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.PACKAGE_REGISTRY_PASSWORD }} + run: echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" --username "$REGISTRY_USERNAME" --password-stdin + - name: Build and publish image + run: | + docker build --tag "$IMAGE:$GITEA_SHA" --tag "$IMAGE:latest" . + docker push "$IMAGE:$GITEA_SHA" + docker push "$IMAGE:latest" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..477ae0c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +data/ +.env \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4059e18 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev + +COPY --chown=node:node . . +USER node + +EXPOSE 3000 + +CMD ["node", "server.js"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..510f930 --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# Sheet Relay + +A local Node.js service that exposes selected Google Sheets tabs as bearer-token-protected JSON endpoints. + +## Run with Docker + +```powershell +docker login git.lzstealth.com +docker compose pull +docker compose up -d +docker compose logs -f +``` + +Open `http://localhost:3000`. The service is bound to localhost by default and pulls `git.lzstealth.com/lzstealth/google-sheets-to-api:latest`. Its configuration is persisted in `data/config.json` beside the Compose file, so it survives image updates and container replacements. + +Pushes to `main` publish both the immutable commit tag and `latest` through Gitea Actions. The workflow uses the global `PACKAGE_REGISTRY_USERNAME` and `PACKAGE_REGISTRY_PASSWORD` secrets; grant that account permission to publish packages for the `lzstealth` organization. + +Stop it with: + +```powershell +docker compose down +``` + +## Reverse proxy deployment + +The Compose service listens only at `127.0.0.1:3000`. Put your existing reverse proxy in front of it for HTTPS, public routing, access logs, and any network policy. Configure that proxy's upstream as `http://127.0.0.1:3000` and forward the original `Host`, client address, and scheme headers. + +Check container health and logs with: + +```powershell +docker compose ps +docker compose logs -f sheet-relay +``` + +## Run with Node + +```powershell +npm install +npm start +``` + +Open `http://localhost:3000`. The first launch creates `data/config.json` and prints a one-time admin token. Keep this token private; use it to unlock the configuration console. + +## Configure + +1. Choose **API key** for public/readable sheets, or **Service account** for private sheets. +2. With a service account, share each spreadsheet with the `client_email` in its JSON credential as a Viewer. +3. Keep the default 45 requests per minute to leave a buffer below a 50 RPM allowance, or set the quota that applies to your Google project. +4. Load a spreadsheet ID, check the tabs to expose, then publish the endpoint. Sheet Relay generates its bearer token automatically; copy it from the endpoint controls. + +## Consume + +```powershell +curl http://localhost:3000/api/v1/YOUR_PUBLICATION_ID -H "Authorization: Bearer YOUR_ENDPOINT_TOKEN" +``` + +Only the selected tabs are returned. Responses use a configurable short cache, reducing Google API calls and respecting the shared rate limiter. + +Each endpoint can return either raw sheet rows or objects keyed from the first row's headers. Object output requires non-empty, unique headers. It can also limit reads with an A1 range such as `A1:H500`, cap rows per sheet, and omit blank rows. Editing any of these options clears that endpoint's cached response immediately. + +Google `429` and `5xx` responses retry up to two times. Retries honor Google's `Retry-After` header when present, otherwise use 1-second then 2-second backoff, with every retry passing through the same configured credential rate limiter. + +## Operations + +The **Operations** page provides service uptime, endpoint health, endpoint and tab-cache counts, and the latest 50 API request outcomes. It retains 500 entries, supports endpoint/status/result filters, and can clear the retained log. Request logs record the endpoint ID, status, cache/stale state, duration, and client address; they never store bearer tokens. + +Use **Refresh** to update an endpoint cache immediately, **Disable** to return `503` without deleting its setup, or **Rotate token** to generate and copy a replacement token. Rotation invalidates the previous token immediately. Background cache refresh can be enabled per endpoint. + +Use **Export redacted config** to create a transferable configuration without Google secrets, endpoint tokens, or the admin token. Importing replaces the current configuration; credentials have no secrets, endpoints receive new tokens, and endpoints remain disabled until you complete credential setup and explicitly enable them. + +The public health check is available at: + +```text +GET /health +``` + +Prometheus-compatible operational counters are available at `GET /metrics`. This endpoint requires the `x-admin-token` header. + +Browser-origin requests are denied by default. Use `*` in the **Operations** page to allow all origins, or enter exact origins such as `https://app.example.com`, one per line, to allow selected browser clients. Server-to-server API clients are unaffected because they do not send an `Origin` header. + +When the service is behind a reverse proxy, set **Trusted reverse proxy hops** to the number of proxies directly in front of it, typically `1`. This allows the request audit log to use the original client address from forwarded headers. Leave it at `0` unless the proxy path is trusted. + +## Security notes + +`data/config.json` contains Google credentials and endpoint tokens. It is intentionally ignored by Git; protect the machine and back up the file securely. Put this service behind HTTPS when it is reachable beyond localhost. \ No newline at end of file diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..1874576 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,9 @@ +services: + sheet-relay: + image: git.lzstealth.com/lzstealth/google-sheets-to-api:latest + container_name: sheet-relay + ports: + - "127.0.0.1:3000:3000" + volumes: + - ./data:/app/data + restart: unless-stopped diff --git a/lib/config-store.js b/lib/config-store.js new file mode 100644 index 0000000..16d6a42 --- /dev/null +++ b/lib/config-store.js @@ -0,0 +1,100 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { randomBytes } from 'node:crypto'; +import path from 'node:path'; + +const dataDirectory = path.join(process.cwd(), 'data'); +const configPath = path.join(dataDirectory, 'config.json'); + +const defaultConfig = () => ({ + adminToken: randomBytes(24).toString('base64url'), + settings: { + corsOrigins: [], + trustedProxyHops: 0 + }, + credentials: [], + documents: [], + publications: [] +}); + +function migrateConfig(config) { + if (config.credentials) { + return { + ...config, + settings: { + ...config.settings, + corsOrigins: config.settings?.corsOrigins || [], + trustedProxyHops: Number(config.settings?.trustedProxyHops) || 0 + } + }; + } + const legacyCredential = config.google?.apiKey || config.google?.serviceAccountJson ? { + id: crypto.randomUUID(), + name: 'Imported Google connection', + mode: config.google.mode, + apiKey: config.google.apiKey, + serviceAccountJson: config.google.serviceAccountJson, + requestsPerMinute: config.google.requestsPerMinute || 45 + } : null; + const documents = (config.publications || []).map((publication) => ({ + id: crypto.randomUUID(), + name: publication.name, + spreadsheetId: publication.spreadsheetId, + credentialId: legacyCredential?.id || '', + sheets: publication.sheets || [] + })); + return { + adminToken: config.adminToken, + settings: { corsOrigins: [], trustedProxyHops: 0 }, + credentials: legacyCredential ? [legacyCredential] : [], + documents, + publications: (config.publications || []).map((publication, index) => ({ + id: publication.id, + name: publication.name, + documentId: documents[index].id, + sheets: publication.sheets || [], + apiToken: publication.apiToken, + cacheSeconds: config.google?.cacheSeconds || 30 + })) + }; +} + +export async function loadConfig() { + try { + const config = migrateConfig(JSON.parse(await readFile(configPath, 'utf8'))); + await saveConfig(config); + return config; + } catch (error) { + if (error.code !== 'ENOENT') throw error; + const config = defaultConfig(); + await saveConfig(config); + console.log(`Created admin token: ${config.adminToken}`); + return config; + } +} + +export async function saveConfig(config) { + await mkdir(dataDirectory, { recursive: true }); + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); +} + +export function publicConfig(config) { + return { + settings: config.settings, + credentials: config.credentials.map(({ apiKey, serviceAccountJson, ...credential }) => ({ + ...credential, + configured: credential.mode === 'apiKey' ? Boolean(apiKey) : Boolean(serviceAccountJson) + })), + documents: config.documents, + publications: config.publications.map(({ apiToken, ...publication }) => ({ + ...publication, + dataFormat: publication.dataFormat || 'rows', + range: publication.range || '', + omitBlankRows: Boolean(publication.omitBlankRows), + maxRows: Math.max(1, Math.min(100_000, Number(publication.maxRows) || 10_000)), + consumerRequestsPerMinute: Math.max(1, Number(publication.consumerRequestsPerMinute) || 120), + scheduledRefresh: Boolean(publication.scheduledRefresh), + enabled: publication.enabled !== false, + tokenConfigured: Boolean(apiToken) + })) + }; +} \ No newline at end of file diff --git a/lib/google-sheets.js b/lib/google-sheets.js new file mode 100644 index 0000000..caf1109 --- /dev/null +++ b/lib/google-sheets.js @@ -0,0 +1,100 @@ +import { google } from 'googleapis'; + +export class RateLimiter { + constructor() { + this.nextAllowedAt = 0; + } + + async run(requestsPerMinute, operation) { + for (let attempt = 0; ; attempt += 1) { + const interval = Math.ceil(60_000 / Math.max(1, Number(requestsPerMinute) || 45)); + const scheduledAt = Math.max(Date.now(), this.nextAllowedAt); + this.nextAllowedAt = scheduledAt + interval; + const wait = scheduledAt - Date.now(); + if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait)); + try { + return await operation(); + } catch (error) { + const status = error.code || error.response?.status; + if (attempt >= 2 || (status !== 429 && (status < 500 || status > 599))) throw error; + const retryAfter = retryAfterMilliseconds(error); + const backoff = retryAfter ?? 1_000 * 2 ** attempt; + this.nextAllowedAt = Math.max(this.nextAllowedAt, Date.now() + backoff); + } + } + } +} + +function retryAfterMilliseconds(error) { + const value = error.response?.headers?.get?.('retry-after') || error.response?.headers?.['retry-after']; + if (!value) return null; + const seconds = Number(value); + if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000); + const date = Date.parse(value); + return Number.isNaN(date) ? null : Math.max(0, date - Date.now()); +} + +export function createSheetsClient(googleConfig) { + if (googleConfig.mode === 'serviceAccount') { + const credentials = JSON.parse(googleConfig.serviceAccountJson); + const auth = new google.auth.GoogleAuth({ + credentials, + scopes: ['https://www.googleapis.com/auth/spreadsheets.readonly'] + }); + return google.sheets({ version: 'v4', auth }); + } + + if (!googleConfig.apiKey) throw new Error('A Google API key is required.'); + return google.sheets({ version: 'v4', auth: googleConfig.apiKey }); +} + +export async function listTabs(client, spreadsheetId, limiter, requestsPerMinute) { + const response = await limiter.run(requestsPerMinute, () => client.spreadsheets.get({ + spreadsheetId, + fields: 'properties.title,sheets.properties(sheetId,title)' + })); + return { + title: response.data.properties?.title ?? spreadsheetId, + sheets: (response.data.sheets ?? []).map(({ properties }) => ({ + id: properties.sheetId, + title: properties.title + })) + }; +} + +export async function readTabs(client, publication, limiter, requestsPerMinute) { + const maxRows = Math.max(1, Math.min(100_000, Number(publication.maxRows) || 10_000)); + const ranges = publication.sheets.map((sheet) => { + const title = `'${sheet.title.replaceAll("'", "''")}'`; + return publication.range?.trim() ? `${title}!${publication.range.trim()}` : `${title}!1:${maxRows}`; + }); + const response = await limiter.run(requestsPerMinute, () => client.spreadsheets.values.batchGet({ + spreadsheetId: publication.spreadsheetId, + ranges, + majorDimension: 'ROWS' + })); + + return (response.data.valueRanges ?? []).map((valueRange, index) => formatSheet( + publication.sheets[index].title, + valueRange.values ?? [], + publication + )); +} + +function formatSheet(sheet, values, publication) { + const maxRows = Math.max(1, Math.min(100_000, Number(publication.maxRows) || 10_000)); + const limitedValues = values.slice(0, maxRows); + const rows = publication.omitBlankRows + ? limitedValues.filter((row) => row.some((cell) => String(cell).trim() !== '')) + : limitedValues; + if (publication.dataFormat !== 'objects') return { sheet, values: rows }; + const [headers = [], ...records] = rows; + const normalizedHeaders = headers.map((header) => String(header).trim()); + if (normalizedHeaders.some((header) => !header) || new Set(normalizedHeaders).size !== normalizedHeaders.length) { + throw new Error(`Object output for "${sheet}" requires non-empty, unique header names.`); + } + return { + sheet, + rows: records.map((row) => Object.fromEntries(normalizedHeaders.map((header, index) => [header, row[index] ?? '']))) + }; +} \ No newline at end of file diff --git a/lib/request-log.js b/lib/request-log.js new file mode 100644 index 0000000..7ff73dd --- /dev/null +++ b/lib/request-log.js @@ -0,0 +1,30 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +const logPath = path.join(process.cwd(), 'data', 'request-log.json'); +const maximumEntries = 500; + +async function loadEntries() { + try { + return JSON.parse(await readFile(logPath, 'utf8')); + } catch (error) { + if (error.code === 'ENOENT') return []; + throw error; + } +} + +export async function recordRequest(entry) { + const entries = await loadEntries(); + entries.push(entry); + await mkdir(path.dirname(logPath), { recursive: true }); + await writeFile(logPath, `${JSON.stringify(entries.slice(-maximumEntries), null, 2)}\n`, { mode: 0o600 }); +} + +export async function recentRequests(limit = 50) { + return (await loadEntries()).slice(-limit).reverse(); +} + +export async function clearRequests() { + await mkdir(path.dirname(logPath), { recursive: true }); + await writeFile(logPath, '[]\n', { mode: 0o600 }); +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..349e1fa --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1734 @@ +{ + "name": "google-sheets-to-api", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "google-sheets-to-api", + "version": "0.1.0", + "dependencies": { + "express": "^5.1.0", + "googleapis": "^155.0.0", + "lucide": "^1.47.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "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" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/googleapis": { + "version": "155.0.1", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-155.0.1.tgz", + "integrity": "sha512-8FRHMufEkXqoLeqxF6CTezxIupvHuAGWHfBz3KzRgBrha6R8nf1ed1TPmk3/N8GYkCxueRmUziLPiqpshBlSxQ==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.2.0", + "googleapis-common": "^8.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-8.0.3.tgz", + "integrity": "sha512-7g1yzQKx0mmNTjiK0H9dJ8eqKqDBveES9vLHeg5neb3BMQy/d1oQefIMhIpOVT8a+f+LOcixMEdRbFIW/cQUJw==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "gaxios": "7.1.3", + "google-auth-library": "10.5.0", + "google-logging-utils": "1.1.3", + "qs": "^6.7.0", + "url-template": "^2.0.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common/node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common/node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", + "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" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/lucide": { + "version": "1.47.0", + "resolved": "https://registry.npmjs.org/lucide/-/lucide-1.47.0.tgz", + "integrity": "sha512-gJoVJM5fvpBDGZTvhpJS9IEurz85HbD+wgOlCkXCIw35tHrqTxSdIYUN9sXvs7Aw3NCl67A+nlotNyoU4u7zOQ==", + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", + "license": "BSD" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b6a9b4f --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "google-sheets-to-api", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js" + }, + "dependencies": { + "express": "^5.1.0", + "googleapis": "^155.0.0", + "lucide": "^1.47.0" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..a5f0b7b --- /dev/null +++ b/public/app.js @@ -0,0 +1,163 @@ +const state = { token: sessionStorage.getItem('adminToken') || '', config: null, discoveredSheets: [], credentialId: '', documentId: '', publicationId: '' }; +const secretMask = '............'; +const element = (selector) => document.querySelector(selector); +const status = (message, isError = false) => { element('#status').textContent = message; element('#status').style.color = isError ? '#a83129' : '#d8562b'; }; + +function applyTheme(isDark) { + document.documentElement.dataset.theme = isDark ? 'dark' : ''; + element('#dark-mode').checked = isDark; +} + +function applyCredentialMode() { + const isApiKey = element('input[name="mode"]:checked').value === 'apiKey'; + element('#api-key-field').hidden = !isApiKey; + element('#service-account-field').hidden = isApiKey; + element('#service-account-upload').hidden = isApiKey; +} + +window.lucide?.createIcons(); + +async function adminFetch(path, options = {}) { + const response = await fetch(path, { ...options, headers: { 'content-type': 'application/json', 'x-admin-token': state.token, ...options.headers } }); + if (!response.ok) throw new Error((await response.json()).error || 'Request failed.'); + return response.status === 204 ? null : response.json(); +} + +function escapeHtml(value) { return String(value).replace(/[&<>'"]/g, (character) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[character]); } +function selectedTitles(selector) { return [...document.querySelectorAll(`${selector} input:checked`)].map((input) => input.value); } +function renderPicker(selector, sheets, selected = sheets.map((sheet) => sheet.title)) { element(selector).innerHTML = sheets.map((sheet) => ``).join(''); } +function recordButtons(type, id) { return `
`; } + +function renderCredentials() { + const credentials = state.config.credentials; + element('#credentials-list').innerHTML = credentials.length ? credentials.map((credential) => `
${escapeHtml(credential.name)}
${credential.mode === 'apiKey' ? 'API key' : 'Service account'} | ${credential.requestsPerMinute} RPM
${recordButtons('credential', credential.id)}
`).join('') : '

No credentials added yet.

'; + element('#document-credential').innerHTML = credentials.length + ? '' + credentials.map((credential) => ``).join('') + : ''; +} + +function renderDocuments() { + const documents = state.config.documents; + element('#documents-list').innerHTML = documents.length ? documents.map((item) => { + const credential = state.config.credentials.find((entry) => entry.id === item.credentialId); + return `
${escapeHtml(item.name)}
${escapeHtml(credential?.name || 'Missing credential')} | ${item.sheets.map((sheet) => escapeHtml(sheet.title)).join(', ')}
${recordButtons('document', item.id)}
`; + }).join('') : '

No documents added yet.

'; + element('#publication-document').innerHTML = documents.length + ? '' + documents.map((item) => ``).join('') + : ''; + renderEndpointPicker(); +} + +function renderEndpointPicker(selected = []) { + const selectedDocument = state.config?.documents.find((item) => item.id === element('#publication-document').value); + element('#endpoint-sheet-picker').innerHTML = selectedDocument ? (() => { renderPicker('#endpoint-sheet-picker', selectedDocument.sheets, selected.length ? selected : selectedDocument.sheets.map((sheet) => sheet.title)); return element('#endpoint-sheet-picker').innerHTML; })() : '

Select a document to choose its tabs.

'; +} + +function renderPublications() { + const publications = state.config.publications; + element('#publications').innerHTML = publications.length ? publications.map((publication) => { + const url = `${location.origin}/api/v1/${publication.id}`; + return `
${escapeHtml(publication.name)}
${escapeHtml(url)}
${publication.sheets.map((sheet) => escapeHtml(sheet.title)).join(', ')} | ${publication.consumerRequestsPerMinute} consumer RPM${publication.scheduledRefresh ? ' | background refresh' : ''}${publication.enabled ? '' : ' | disabled'}
`; + }).join('') : '

No endpoints published yet.

'; +} + +function renderRequestFilters() { + const current = element('#request-endpoint').value; + element('#request-endpoint').innerHTML = '' + state.config.publications.map((publication) => ``).join(''); + element('#request-endpoint').value = current; +} +function renderRequestLog(entries) { element('#request-log').innerHTML = entries.length ? entries.map((entry) => `
${entry.status}${escapeHtml(entry.publicationId)}${entry.cached ? 'cache' : 'refresh'}${entry.stale ? ', stale' : ''}${entry.durationMs} ms
`).join('') : '

No matching API requests.

'; } +async function loadConfig() { state.config = await adminFetch('/admin/config'); element('#cors-origins').value = state.config.settings.corsOrigins.join('\n'); element('#trusted-proxy-hops').value = state.config.settings.trustedProxyHops; element('#metrics-url').textContent = `${location.origin}/metrics`; renderCredentials(); renderDocuments(); renderPublications(); renderRequestFilters(); bindRecordActions(); } +async function loadRequests() { const query = new URLSearchParams(); if (element('#request-endpoint').value) query.set('publicationId', element('#request-endpoint').value); if (element('#request-status').value) query.set('status', element('#request-status').value); if (element('#request-result').value) query.set('result', element('#request-result').value); renderRequestLog(await adminFetch(`/admin/requests?${query}`)); } +async function loadStatus() { + const service = await adminFetch('/admin/status'); + element('#service-status').textContent = `Healthy | ${service.uptimeSeconds}s uptime | ${service.caches.endpointEntries} endpoint cache entries | ${service.caches.tabEntries} tab cache entries`; + element('#endpoint-health').innerHTML = service.endpoints.length ? service.endpoints.map((endpoint) => `
${escapeHtml(endpoint.name)}${endpoint.cacheState}${endpoint.lastSuccessAt ? `Last refresh: ${new Date(endpoint.lastSuccessAt).toLocaleString()}` : 'Not refreshed yet'}${endpoint.lastError ? `${escapeHtml(endpoint.lastError)}` : ''}
`).join('') : '

No endpoints published yet.

'; + await loadRequests(); +} +function showPage(page) { document.querySelectorAll('.page').forEach((section) => { section.hidden = section.id !== page; }); document.querySelectorAll('.tab').forEach((button) => button.classList.toggle('active', button.dataset.page === page)); if (page === 'operations') loadStatus().catch((error) => { element('#service-status').textContent = error.message; }); } +function resetCredential() { state.credentialId = ''; element('#credential-name').value = ''; element('#api-key').value = ''; element('#service-account').value = ''; element('#api-key').placeholder = ''; element('#service-account').placeholder = ''; element('#rpm').value = 45; element('#save-credential').textContent = 'Add credential profile'; element('#cancel-credential').hidden = true; } +function resetDocument() { state.documentId = ''; state.discoveredSheets = []; element('#document-name').value = ''; element('#document-url').value = ''; element('#sheet-picker').innerHTML = ''; element('#save-document').textContent = 'Add document'; element('#cancel-document').hidden = true; } +function resetPublication() { state.publicationId = ''; element('#publication-name').value = ''; element('#cache').value = 30; element('#max-rows').value = 10000; element('#consumer-rpm').value = 120; element('#scheduled-refresh').checked = false; element('#data-format').value = 'rows'; element('#sheet-range').value = ''; element('#omit-blank-rows').checked = false; element('#publish').textContent = 'Publish endpoint'; element('#cancel-publication').hidden = true; renderEndpointPicker(); } + +function bindRecordActions() { + document.querySelectorAll('[data-edit-credential]').forEach((button) => button.addEventListener('click', () => { + const credential = state.config.credentials.find((item) => item.id === button.dataset.editCredential); state.credentialId = credential.id; element('#credential-name').value = credential.name; element('#rpm').value = credential.requestsPerMinute; element(`input[name="mode"][value="${credential.mode}"]`).checked = true; applyCredentialMode(); element('#api-key').value = credential.mode === 'apiKey' ? secretMask : ''; element('#service-account').value = credential.mode === 'serviceAccount' ? secretMask : ''; element('#save-credential').textContent = 'Save credential changes'; element('#cancel-credential').hidden = false; showPage('credentials'); status('Credential loaded. Replace the dots only to change its saved secret.'); + })); + document.querySelectorAll('[data-edit-document]').forEach((button) => button.addEventListener('click', () => { + const item = state.config.documents.find((document) => document.id === button.dataset.editDocument); state.documentId = item.id; state.discoveredSheets = item.sheets; element('#document-name').value = item.name; element('#document-url').value = `https://docs.google.com/spreadsheets/d/${item.spreadsheetId}/edit`; element('#document-credential').value = item.credentialId; renderPicker('#sheet-picker', item.sheets); element('#save-document').textContent = 'Save document changes'; element('#cancel-document').hidden = false; showPage('documents'); status('Document loaded. Load sheet tabs again to refresh the available names.'); + })); + document.querySelectorAll('[data-edit-publication]').forEach((button) => button.addEventListener('click', () => { + const item = state.config.publications.find((publication) => publication.id === button.dataset.editPublication); state.publicationId = item.id; element('#publication-name').value = item.name; element('#publication-document').value = item.documentId; element('#cache').value = item.cacheSeconds; element('#max-rows').value = item.maxRows; element('#consumer-rpm').value = item.consumerRequestsPerMinute; element('#scheduled-refresh').checked = Boolean(item.scheduledRefresh); element('#data-format').value = item.dataFormat || 'rows'; element('#sheet-range').value = item.range || ''; element('#omit-blank-rows').checked = Boolean(item.omitBlankRows); renderEndpointPicker(item.sheets.map((sheet) => sheet.title)); element('#publish').textContent = 'Save endpoint changes'; element('#cancel-publication').hidden = false; showPage('endpoints'); status('Endpoint loaded.'); + })); + document.querySelectorAll('[data-copy-token]').forEach((button) => button.addEventListener('click', async () => { + try { + const secret = await adminFetch(`/admin/publications/${button.dataset.copyToken}/token`); + await navigator.clipboard.writeText(secret.token); + button.textContent = 'Copied'; + setTimeout(() => { button.textContent = 'Copy token'; }, 1_500); + } catch (error) { status(error.message, true); } + })); + document.querySelectorAll('[data-rotate-token]').forEach((button) => button.addEventListener('click', async () => { + if (!confirm('Rotate this token? Existing clients will stop working immediately.')) return; + try { + const secret = await adminFetch(`/admin/publications/${button.dataset.rotateToken}/rotate-token`, { method: 'POST' }); + await navigator.clipboard.writeText(secret.token); + button.textContent = 'New token copied'; + setTimeout(() => { button.textContent = 'Rotate token'; }, 1_500); + status('Token rotated and copied.'); + } catch (error) { status(error.message, true); } + })); + document.querySelectorAll('[data-refresh-publication]').forEach((button) => button.addEventListener('click', async () => { + try { await adminFetch(`/admin/publications/${button.dataset.refreshPublication}/refresh`, { method: 'POST' }); button.textContent = 'Refreshed'; setTimeout(() => { button.textContent = 'Refresh'; }, 1_500); } catch (error) { status(error.message, true); } + })); + document.querySelectorAll('[data-toggle-publication]').forEach((button) => button.addEventListener('click', async () => { + try { const publication = state.config.publications.find((item) => item.id === button.dataset.togglePublication); await adminFetch(`/admin/publications/${publication.id}/enabled`, { method: 'POST', body: JSON.stringify({ enabled: !publication.enabled }) }); await loadConfig(); } catch (error) { status(error.message, true); } + })); + ['credential', 'document', 'publication'].forEach((type) => document.querySelectorAll(`[data-delete-${type}]`).forEach((button) => button.addEventListener('click', async () => { if (!confirm(`Delete this ${type}? This cannot be undone.`)) return; try { await adminFetch(`/admin/${type === 'publication' ? 'publications' : `${type}s`}/${button.dataset[`delete${type[0].toUpperCase()}${type.slice(1)}`]}`, { method: 'DELETE' }); await loadConfig(); status(`${type[0].toUpperCase()}${type.slice(1)} removed.`); } catch (error) { status(error.message, true); } }))); +} + +element('#connect').addEventListener('click', async () => { state.token = element('#admin-token').value.trim(); try { await loadConfig(); sessionStorage.setItem('adminToken', state.token); element('#access-panel').hidden = true; element('#console').hidden = false; status('Connected.'); } catch (error) { status(error.message, true); } }); +document.querySelectorAll('.tab').forEach((button) => button.addEventListener('click', () => showPage(button.dataset.page))); +applyTheme(localStorage.getItem('theme') === 'dark'); +applyCredentialMode(); +document.querySelectorAll('input[name="mode"]').forEach((input) => input.addEventListener('change', applyCredentialMode)); +element('#dark-mode').addEventListener('change', (event) => { + const isDark = event.target.checked; + localStorage.setItem('theme', isDark ? 'dark' : 'light'); + applyTheme(isDark); +}); +element('#save-settings').addEventListener('click', async () => { + try { + const corsOrigins = element('#cors-origins').value.split(/\r?\n/); + await adminFetch('/admin/settings', { method: 'PUT', body: JSON.stringify({ corsOrigins, trustedProxyHops: element('#trusted-proxy-hops').value }) }); + await loadConfig(); + status('CORS settings saved.'); + } catch (error) { status(error.message, true); } +}); +element('#service-account-file').addEventListener('change', async (event) => { const [file] = event.target.files; if (file) element('#service-account').value = await file.text(); }); +element('#save-credential').addEventListener('click', async () => { try { await adminFetch('/admin/credentials', { method: 'POST', body: JSON.stringify({ id: state.credentialId || undefined, name: element('#credential-name').value, mode: element('input[name="mode"]:checked').value, apiKey: element('#api-key').value === secretMask ? '' : element('#api-key').value, serviceAccountJson: element('#service-account').value === secretMask ? '' : element('#service-account').value, requestsPerMinute: element('#rpm').value }) }); await loadConfig(); resetCredential(); applyCredentialMode(); status('Credential saved.'); } catch (error) { status(error.message, true); } }); +element('#cancel-credential').addEventListener('click', resetCredential); +element('#discover').addEventListener('click', async () => { try { const result = await adminFetch('/admin/documents/discover', { method: 'POST', body: JSON.stringify({ credentialId: element('#document-credential').value, url: element('#document-url').value }) }); state.discoveredSheets = result.sheets; renderPicker('#sheet-picker', result.sheets); status(`Loaded ${result.sheets.length} tab(s) from ${result.title}.`); } catch (error) { status(error.message, true); } }); +element('#save-document').addEventListener('click', async () => { try { const sheets = state.discoveredSheets.filter((sheet) => selectedTitles('#sheet-picker').includes(sheet.title)); await adminFetch('/admin/documents', { method: 'POST', body: JSON.stringify({ id: state.documentId || undefined, name: element('#document-name').value, url: element('#document-url').value, credentialId: element('#document-credential').value, sheets }) }); await loadConfig(); resetDocument(); status('Document saved.'); } catch (error) { status(error.message, true); } }); +element('#cancel-document').addEventListener('click', resetDocument); +element('#publication-document').addEventListener('change', () => renderEndpointPicker()); +element('#publish').addEventListener('click', async () => { try { const selectedDocument = state.config.documents.find((item) => item.id === element('#publication-document').value); if (!selectedDocument) throw new Error('Select a document first.'); const titles = selectedTitles('#endpoint-sheet-picker'); const sheets = selectedDocument.sheets.filter((sheet) => titles.includes(sheet.title)); await adminFetch('/admin/publications', { method: 'POST', body: JSON.stringify({ id: state.publicationId || undefined, name: element('#publication-name').value, documentId: selectedDocument.id, sheets, cacheSeconds: element('#cache').value, maxRows: element('#max-rows').value, consumerRequestsPerMinute: element('#consumer-rpm').value, scheduledRefresh: element('#scheduled-refresh').checked, dataFormat: element('#data-format').value, range: element('#sheet-range').value, omitBlankRows: element('#omit-blank-rows').checked }) }); await loadConfig(); resetPublication(); status('Endpoint saved.'); } catch (error) { status(error.message, true); } }); +['#request-endpoint', '#request-status', '#request-result'].forEach((selector) => element(selector).addEventListener('change', () => loadRequests().catch((error) => { element('#request-log').textContent = error.message; }))); +element('#clear-request-log').addEventListener('click', async () => { if (!confirm('Clear all retained API request logs?')) return; try { await adminFetch('/admin/requests', { method: 'DELETE' }); await loadRequests(); } catch (error) { status(error.message, true); } }); +element('#rotate-admin-token').addEventListener('click', async () => { if (!confirm('Rotate the admin token? Other admin sessions will stop working.')) return; try { const result = await adminFetch('/admin/token/rotate', { method: 'POST' }); state.token = result.token; sessionStorage.setItem('adminToken', state.token); await navigator.clipboard.writeText(result.token); status('Admin token rotated and copied.'); } catch (error) { status(error.message, true); } }); +element('#export-config').addEventListener('click', async () => { try { const exported = await adminFetch('/admin/config/export'); const url = URL.createObjectURL(new Blob([JSON.stringify(exported, null, 2)], { type: 'application/json' })); const link = document.createElement('a'); link.href = url; link.download = 'sheet-relay-config.json'; link.click(); URL.revokeObjectURL(url); } catch (error) { status(error.message, true); } }); +element('#import-config').addEventListener('click', () => element('#config-file').click()); +element('#config-file').addEventListener('change', async (event) => { const [file] = event.target.files; if (!file) return; try { const imported = JSON.parse(await file.text()); if (!confirm('Replace all existing configuration with this import?')) return; await adminFetch('/admin/config/import', { method: 'POST', body: JSON.stringify(imported) }); await loadConfig(); await loadStatus(); status('Configuration imported. Add credential secrets, then enable endpoints.'); } catch (error) { status(error.message, true); } finally { event.target.value = ''; } }); +element('#cancel-publication').addEventListener('click', resetPublication); + +if (state.token) { + loadConfig().then(() => { + element('#admin-token').value = state.token; + element('#access-panel').hidden = true; + element('#console').hidden = false; + }).catch(() => { + sessionStorage.removeItem('adminToken'); + state.token = ''; + }); +} \ No newline at end of file diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..695a019 --- /dev/null +++ b/public/index.html @@ -0,0 +1,64 @@ + + + + + + Sheet Relay + + + +
+

GOOGLE SHEETS TO API

Sheet Relay

Choose exactly which spreadsheet tabs become authenticated JSON endpoints.

+
+

Admin access

Enter the token printed when the server first starts.

+
+ +
+ + + + \ No newline at end of file diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..89fd0e0 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,26 @@ +@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Fraunces:opsz,wght@9..144,600;9..144,700&display=swap'); + +:root { --ink: #15231f; --muted: #60706a; --paper: #f6f3ea; --rule: #e8e5da; --panel: #fffdf7; --field: #fffefa; --line: #d8ddd2; --field-line: #abb8b0; --accent: #d8562b; --dark: #173c35; --button-ink: #fff; --shadow: #dfe5d8; --soft: #eaf0e7; --secondary: #b7c7bd; --secondary-hover: #91a99a; } +:root[data-theme="dark"] { --ink: #e9eee7; --muted: #b4c1b9; --paper: #17201d; --rule: #29332f; --panel: #202a26; --field: #18221e; --line: #405149; --field-line: #61766b; --accent: #ef855d; --dark: #9ec9b4; --button-ink: #15231f; --shadow: #0d1311; --soft: #293a33; --secondary: #596f64; --secondary-hover: #708a7b; } +* { box-sizing: border-box; } +body { margin: 0; color: var(--ink); background: repeating-linear-gradient(90deg, transparent 0 39px, var(--rule) 40px), var(--paper); font-family: "DM Mono", monospace; font-size: 14px; } +main { width: min(880px, calc(100% - 32px)); margin: 0 auto; padding: 64px 0; } +header { border-left: 8px solid var(--accent); padding-left: 20px; margin-bottom: 36px; }.header-row { display: flex; align-items: start; justify-content: space-between; gap: 16px; } +.eyebrow, .hint { color: var(--muted); font-size: 12px; line-height: 1.6; } +h1, h2 { font-family: Fraunces, serif; letter-spacing: 0; } h1 { font-size: 48px; margin: 3px 0 10px; } h2 { margin: 0 0 20px; font-size: 25px; } +.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 28px; margin: 18px 0; box-shadow: 5px 5px 0 var(--shadow); } +label { display: grid; gap: 8px; margin: 14px 0; } [hidden] { display: none !important; } input, select, textarea { width: 100%; min-height: 42px; border: 1px solid var(--field-line); border-radius: 3px; background: var(--field); padding: 10px 11px; font: inherit; color: var(--ink); } select { appearance: auto; } textarea { resize: vertical; } +button { border: 0; border-radius: 3px; background: var(--dark); color: var(--button-ink); font: 500 13px "DM Mono", monospace; padding: 11px 16px; cursor: pointer; } button:hover { background: var(--accent); color: #fff; } button.danger { background: #a83129; color: #fff; } button.secondary { background: var(--secondary); color: var(--ink); } button.secondary:hover { background: var(--secondary-hover); }.secret-input { display: flex; gap: 8px; }.secret-input input { min-width: 0; }.secret-input button { flex: 0 0 auto; }.icon-button { padding: 8px 10px; } + .panel > button + button { margin-left: 8px; } +.tabs { display: flex; gap: 1px; margin: 28px 0 -18px; }.tab { border-radius: 4px 4px 0 0; background: var(--secondary); color: var(--ink); }.tab.active { background: var(--dark); color: var(--button-ink); }#console .page { border-radius: 0 6px 6px 6px; } +.inline, .two-up, .mode { display: flex; gap: 14px; align-items: end; } .two-up > label { flex: 1; } .mode { align-items: center; } .mode label { display: flex; margin: 0; align-items: center; } .mode input { width: auto; } +.status { min-height: 20px; color: var(--accent); } .sheet-picker { display: grid; grid-template-columns: repeat(auto-fit, minmax(145px, 1fr)); gap: 8px; margin: 18px 0; }.sheet-picker label { display: flex; align-items: center; border: 1px solid var(--line); padding: 10px; margin: 0; gap: 8px; }.sheet-picker input { width: auto; } +.check-option { display: flex; align-items: center; gap: 8px; }.check-option input { width: auto; min-height: auto; } +input[type="checkbox"] { appearance: none; display: grid; place-content: center; width: 18px; min-width: 18px; height: 18px; min-height: 18px; margin: 0; padding: 0; border: 1px solid var(--field-line); border-radius: 3px; background: var(--field); cursor: pointer; } input[type="checkbox"]::after { content: ""; width: 7px; height: 4px; border: solid var(--button-ink); border-width: 0 0 2px 2px; transform: rotate(-45deg) scale(0); transform-origin: center; } input[type="checkbox"]:checked { border-color: var(--dark); background: var(--dark); } input[type="checkbox"]:checked::after { transform: rotate(-45deg) scale(1); } +.records { margin-top: 24px; }.record, .endpoint { border-top: 1px solid var(--line); padding: 16px 0; display: flex; justify-content: space-between; gap: 14px; align-items: start; }.record:first-child, .endpoint:first-child { border-top: 0; padding-top: 0; }.endpoint { flex-wrap: wrap; }.endpoint code { display: inline-block; max-width: 100%; color: var(--muted); overflow-wrap: anywhere; font-size: 11px; margin: 5px 0; }.endpoint pre { width: 100%; max-width: 620px; overflow: auto; white-space: pre-wrap; background: var(--soft); border-left: 3px solid var(--accent); padding: 12px; font-size: 11px; line-height: 1.5; }.actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; }.actions button { padding: 8px 10px; } +.response-format { margin: 18px 0; color: var(--muted); }.response-format summary { cursor: pointer; }.response-format pre { max-width: 620px; overflow: auto; background: var(--soft); border-left: 3px solid var(--accent); padding: 12px; color: var(--ink); font-size: 11px; line-height: 1.5; } +.status-card { border-left: 3px solid var(--dark); background: var(--soft); padding: 12px; line-height: 1.5; }.request-log { margin-top: 12px; }.log-entry { display: grid; grid-template-columns: 52px minmax(0, 1fr) 90px 64px 170px; gap: 10px; align-items: center; border-top: 1px solid var(--line); padding: 10px 0; font-size: 11px; }.log-entry strong { color: var(--accent); }.log-entry span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.log-entry time { color: var(--muted); }.theme-toggle { display: grid; position: relative; width: 32px; height: 32px; place-items: center; margin: 4px 0; border: 1px solid var(--field-line); border-radius: 50%; background: var(--field); cursor: pointer; }.theme-toggle input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; }.theme-icon { display: grid; place-items: center; color: var(--accent); }.theme-icon svg { width: 18px; height: 18px; stroke-width: 2; }.theme-icon.moon { display: none; }:root[data-theme="dark"] .theme-icon.sun { display: none; }:root[data-theme="dark"] .theme-icon.moon { display: grid; } +.health-entry { display: grid; grid-template-columns: minmax(120px, 1fr) 68px minmax(170px, 1.5fr); gap: 10px; align-items: center; border-top: 1px solid var(--line); padding: 10px 0; font-size: 11px; }.health-entry span { color: var(--muted); }.health-entry .health-error { color: #a83129; grid-column: 1 / -1; overflow-wrap: anywhere; } +.request-filters, .operations-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: end; margin: 12px 0; }.request-filters select { width: auto; min-width: 130px; min-height: 36px; padding: 6px 8px; }.request-filters button { padding: 9px 12px; }.operations-actions { justify-content: flex-start; } +@media (max-width: 600px) { .log-entry { grid-template-columns: 45px minmax(0, 1fr); }.log-entry time { grid-column: 1 / -1; } } +@media (max-width: 600px) { main { padding: 34px 0; } h1 { font-size: 38px; }.panel { padding: 20px; }.two-up, .inline { display: grid; }.inline button { width: 100%; } } \ No newline at end of file diff --git a/server.js b/server.js new file mode 100644 index 0000000..dc80023 --- /dev/null +++ b/server.js @@ -0,0 +1,510 @@ +import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; +import express from 'express'; +import { loadConfig, publicConfig, saveConfig } from './lib/config-store.js'; +import { RateLimiter, createSheetsClient, listTabs, readTabs } from './lib/google-sheets.js'; +import { clearRequests, recentRequests, recordRequest } from './lib/request-log.js'; + +const app = express(); +const config = await loadConfig(); +const limiters = new Map(); +const consumerLimiters = new Map(); +const cache = new Map(); +const endpointHealth = new Map(); +const refreshingPublications = new Map(); +const tabCache = new Map(); +const tabCacheMilliseconds = 5 * 60 * 1000; +const refreshIntervalMilliseconds = 5_000; +const startedAt = Date.now(); +const metrics = { requests: 0, errors: 0, staleResponses: 0, refreshes: 0, refreshErrors: 0, refreshDurationMilliseconds: 0 }; + +app.set('trust proxy', config.settings.trustedProxyHops); + +app.use(express.json({ limit: '1mb' })); +app.use('/api', (request, response, next) => { + const origin = request.get('origin'); + if (!origin) return next(); + if (!config.settings.corsOrigins.includes('*') && !config.settings.corsOrigins.includes(origin)) return response.status(403).json({ error: 'Origin is not permitted.' }); + response.set('Access-Control-Allow-Origin', origin); + response.set('Vary', 'Origin'); + response.set('Access-Control-Allow-Headers', 'Authorization, Content-Type'); + response.set('Access-Control-Allow-Methods', 'GET, OPTIONS'); + if (request.method === 'OPTIONS') return response.status(204).end(); + next(); +}); +app.use((request, response, next) => { + request.startedAt = Date.now(); + next(); +}); +app.use('/vendor/lucide', express.static('node_modules/lucide/dist/umd')); +app.use(express.static('public')); + +function sameSecret(received, expected) { + if (!received || !expected) return false; + const receivedBuffer = Buffer.from(received); + const expectedBuffer = Buffer.from(expected); + return receivedBuffer.length === expectedBuffer.length && timingSafeEqual(receivedBuffer, expectedBuffer); +} + +function requireAdmin(request, response, next) { + if (!sameSecret(request.get('x-admin-token'), config.adminToken)) { + return response.status(401).json({ error: 'Admin authentication required.' }); + } + next(); +} + +function requiredText(value, label) { + if (!value?.trim()) throw new Error(`${label} is required.`); + return value.trim(); +} + +function spreadsheetIdFrom(value) { + const input = requiredText(value, 'Google Sheets URL'); + const match = input.match(/\/spreadsheets\/d\/([a-zA-Z0-9_-]+)/); + if (match) return match[1]; + if (/^[a-zA-Z0-9_-]{20,}$/.test(input)) return input; + throw new Error('Enter a valid Google Sheets URL.'); +} + +function credentialFor(id) { + const credential = config.credentials.find((item) => item.id === id); + if (!credential) throw new Error('The selected credential profile no longer exists.'); + return credential; +} + +function documentFor(id) { + const document = config.documents.find((item) => item.id === id); + if (!document) throw new Error('The selected document no longer exists.'); + return document; +} + +function limiterFor(credential) { + const identity = credential.mode === 'serviceAccount' + ? JSON.parse(credential.serviceAccountJson).client_email + : credential.apiKey; + const key = createHash('sha256').update(`${credential.mode}:${identity}`).digest('hex'); + if (!limiters.has(key)) limiters.set(key, new RateLimiter()); + return limiters.get(key); +} + +function consumerIsLimited(publication) { + const now = Date.now(); + const windowMilliseconds = 60_000; + const limit = Math.max(1, Number(publication.consumerRequestsPerMinute) || 120); + const current = consumerLimiters.get(publication.id) || { startedAt: now, count: 0 }; + if (now - current.startedAt >= windowMilliseconds) { + current.startedAt = now; + current.count = 0; + } + if (current.count >= limit) return true; + current.count += 1; + consumerLimiters.set(publication.id, current); + return false; +} + +async function refreshPublication(publication) { + if (refreshingPublications.has(publication.id)) return refreshingPublications.get(publication.id); + const refresh = (async () => { + const health = endpointHealth.get(publication.id) || {}; + health.lastAttemptAt = new Date().toISOString(); + try { + const refreshStartedAt = Date.now(); + const document = documentFor(publication.documentId); + const credential = credentialFor(document.credentialId); + const payload = { + publication: publication.name, + document: document.name, + spreadsheetId: document.spreadsheetId, + updatedAt: new Date().toISOString(), + sheets: await readTabs(createSheetsClient(credential), { + spreadsheetId: document.spreadsheetId, + sheets: publication.sheets, + dataFormat: publication.dataFormat, + range: publication.range, + omitBlankRows: publication.omitBlankRows + }, limiterFor(credential), credential.requestsPerMinute) + }; + cache.set(publication.id, { payload, expiresAt: Date.now() + publication.cacheSeconds * 1000 }); + endpointHealth.set(publication.id, { ...health, lastSuccessAt: new Date().toISOString(), lastError: '' }); + metrics.refreshes += 1; + metrics.refreshDurationMilliseconds += Date.now() - refreshStartedAt; + return payload; + } catch (error) { + endpointHealth.set(publication.id, { ...health, lastError: error.message }); + metrics.refreshErrors += 1; + throw error; + } finally { + refreshingPublications.delete(publication.id); + } + })(); + refreshingPublications.set(publication.id, refresh); + return refresh; +} + +function endpointStatus(publication) { + const cached = cache.get(publication.id); + const health = endpointHealth.get(publication.id) || {}; + return { + id: publication.id, + name: publication.name, + enabled: publication.enabled !== false, + cacheState: !cached ? 'empty' : cached.expiresAt > Date.now() ? 'fresh' : 'stale', + cacheExpiresAt: cached ? new Date(cached.expiresAt).toISOString() : null, + ...health + }; +} + +async function tabsFor(credential, spreadsheetId) { + const identity = credential.mode === 'serviceAccount' + ? JSON.parse(credential.serviceAccountJson).client_email + : credential.apiKey; + const key = createHash('sha256').update(`${credential.mode}:${identity}:${spreadsheetId}`).digest('hex'); + const existing = tabCache.get(key); + if (existing?.expiresAt > Date.now()) return existing.result; + + const result = listTabs(createSheetsClient(credential), spreadsheetId, limiterFor(credential), credential.requestsPerMinute); + tabCache.set(key, { expiresAt: Date.now() + tabCacheMilliseconds, result }); + try { + return await result; + } catch (error) { + tabCache.delete(key); + throw error; + } +} + +function auditEndpointRequest(request, publicationId, status, details = {}) { + metrics.requests += 1; + if (status >= 400) metrics.errors += 1; + if (details.stale) metrics.staleResponses += 1; + recordRequest({ + at: new Date().toISOString(), + publicationId, + status, + durationMs: Date.now() - request.startedAt, + remoteAddress: request.ip, + ...details + }).catch((error) => console.error('Unable to write request log:', error)); +} + +app.get('/health', (request, response) => response.json({ + status: 'ok', + uptimeSeconds: Math.floor((Date.now() - startedAt) / 1000), + publications: config.publications.length +})); + +app.get('/metrics', requireAdmin, (request, response) => { + const averageRefreshSeconds = metrics.refreshes ? metrics.refreshDurationMilliseconds / metrics.refreshes / 1_000 : 0; + response.type('text/plain').send([ + '# TYPE sheet_relay_requests_total counter', + `sheet_relay_requests_total ${metrics.requests}`, + '# TYPE sheet_relay_errors_total counter', + `sheet_relay_errors_total ${metrics.errors}`, + '# TYPE sheet_relay_stale_responses_total counter', + `sheet_relay_stale_responses_total ${metrics.staleResponses}`, + '# TYPE sheet_relay_refreshes_total counter', + `sheet_relay_refreshes_total ${metrics.refreshes}`, + '# TYPE sheet_relay_refresh_errors_total counter', + `sheet_relay_refresh_errors_total ${metrics.refreshErrors}`, + '# TYPE sheet_relay_refresh_duration_seconds gauge', + `sheet_relay_refresh_duration_seconds ${averageRefreshSeconds}` + ].join('\n') + '\n'); +}); + +app.get('/admin/config', requireAdmin, (request, response) => response.json(publicConfig(config))); + +app.post('/admin/token/rotate', requireAdmin, async (request, response, next) => { + try { + config.adminToken = randomBytes(24).toString('base64url'); + await saveConfig(config); + response.json({ token: config.adminToken }); + } catch (error) { next(error); } +}); + +app.put('/admin/settings', requireAdmin, async (request, response, next) => { + try { + const corsOrigins = Array.isArray(request.body.corsOrigins) + ? request.body.corsOrigins.map((origin) => origin.trim()).filter(Boolean) + : []; + const trustedProxyHops = Math.max(0, Math.min(10, Number(request.body.trustedProxyHops) || 0)); + if (corsOrigins.some((origin) => origin !== '*' && !/^https?:\/\/[^/]+$/i.test(origin))) throw new Error('CORS origins must be * or an http or https origin without a path.'); + config.settings.corsOrigins = [...new Set(corsOrigins)]; + config.settings.trustedProxyHops = trustedProxyHops; + app.set('trust proxy', trustedProxyHops); + await saveConfig(config); + response.json(config.settings); + } catch (error) { next(error); } +}); + +app.get('/admin/status', requireAdmin, async (request, response, next) => { + try { + response.json({ + status: 'ok', + uptimeSeconds: Math.floor((Date.now() - startedAt) / 1000), + caches: { endpointEntries: cache.size, tabEntries: tabCache.size }, + endpoints: config.publications.map(endpointStatus), + recentRequests: await recentRequests() + }); + } catch (error) { next(error); } +}); + +app.get('/admin/requests', requireAdmin, async (request, response, next) => { + try { + const requests = await recentRequests(500); + const filtered = requests.filter((entry) => { + if (request.query.publicationId && entry.publicationId !== request.query.publicationId) return false; + if (request.query.status && String(entry.status) !== String(request.query.status)) return false; + if (request.query.result === 'cached' && !entry.cached) return false; + if (request.query.result === 'refresh' && entry.cached) return false; + if (request.query.result === 'stale' && !entry.stale) return false; + return true; + }); + response.json(filtered); + } catch (error) { next(error); } +}); + +app.delete('/admin/requests', requireAdmin, async (request, response, next) => { + try { + await clearRequests(); + response.status(204).end(); + } catch (error) { next(error); } +}); + +app.get('/admin/config/export', requireAdmin, (request, response) => { + const exported = publicConfig(config); + response.json({ + version: 1, + settings: exported.settings, + credentials: exported.credentials.map(({ configured, ...credential }) => credential), + documents: exported.documents, + publications: exported.publications.map(({ tokenConfigured, ...publication }) => ({ ...publication, enabled: false })) + }); +}); + +app.post('/admin/config/import', requireAdmin, async (request, response, next) => { + try { + const imported = request.body; + if (!imported || !Array.isArray(imported.credentials) || !Array.isArray(imported.documents) || !Array.isArray(imported.publications)) throw new Error('Choose a valid exported configuration file.'); + const credentialIds = new Map(); + const credentials = imported.credentials.map((credential) => { + if (!['apiKey', 'serviceAccount'].includes(credential.mode)) throw new Error('Imported credential has an invalid authentication type.'); + const id = randomUUID(); + credentialIds.set(credential.id, id); + return { id, name: requiredText(credential.name, 'Imported credential name'), mode: credential.mode, apiKey: '', serviceAccountJson: '', requestsPerMinute: Math.max(1, Math.min(10_000, Number(credential.requestsPerMinute) || 45)) }; + }); + const documentIds = new Map(); + const documents = imported.documents.map((document) => { + const credentialId = credentialIds.get(document.credentialId); + if (!credentialId || !Array.isArray(document.sheets) || !document.sheets.length) throw new Error('Imported documents must reference an included credential and have at least one sheet.'); + const id = randomUUID(); + documentIds.set(document.id, id); + return { id, name: requiredText(document.name, 'Imported document name'), spreadsheetId: requiredText(document.spreadsheetId, 'Imported spreadsheet ID'), credentialId, sheets: document.sheets }; + }); + const publications = imported.publications.map((publication) => { + const documentId = documentIds.get(publication.documentId); + if (!documentId || !Array.isArray(publication.sheets) || !publication.sheets.length) throw new Error('Imported endpoints must reference an included document and have at least one sheet.'); + return { id: randomUUID(), name: requiredText(publication.name, 'Imported endpoint name'), documentId, sheets: publication.sheets, apiToken: randomBytes(24).toString('base64url'), cacheSeconds: Math.max(0, Math.min(86_400, Number(publication.cacheSeconds) || 0)), dataFormat: publication.dataFormat === 'objects' ? 'objects' : 'rows', range: String(publication.range || '').trim(), omitBlankRows: Boolean(publication.omitBlankRows), maxRows: Math.max(1, Math.min(100_000, Number(publication.maxRows) || 10_000)), consumerRequestsPerMinute: Math.max(1, Math.min(100_000, Number(publication.consumerRequestsPerMinute) || 120)), scheduledRefresh: Boolean(publication.scheduledRefresh), enabled: false }; + }); + config.settings = { corsOrigins: Array.isArray(imported.settings?.corsOrigins) ? imported.settings.corsOrigins : [], trustedProxyHops: Math.max(0, Math.min(10, Number(imported.settings?.trustedProxyHops) || 0)) }; + config.credentials = credentials; + config.documents = documents; + config.publications = publications; + cache.clear(); + endpointHealth.clear(); + app.set('trust proxy', config.settings.trustedProxyHops); + await saveConfig(config); + response.json(publicConfig(config)); + } catch (error) { next(error); } +}); + +app.post('/admin/credentials', requireAdmin, async (request, response, next) => { + try { + const { id, name, mode, apiKey, serviceAccountJson, requestsPerMinute } = request.body; + if (!['apiKey', 'serviceAccount'].includes(mode)) throw new Error('Choose an authentication type.'); + const existing = config.credentials.find((item) => item.id === id); + const suppliedSecret = mode === 'apiKey' ? apiKey?.trim() : serviceAccountJson?.trim(); + const secret = suppliedSecret || (existing?.mode === mode ? (mode === 'apiKey' ? existing.apiKey : existing.serviceAccountJson) : ''); + requiredText(secret, mode === 'apiKey' ? 'Google API key' : 'Service account JSON'); + if (mode === 'serviceAccount') JSON.parse(secret); + const credential = { + id: id || randomUUID(), + name: requiredText(name, 'Profile name'), + mode, + apiKey: mode === 'apiKey' ? secret : '', + serviceAccountJson: mode === 'serviceAccount' ? secret : '', + requestsPerMinute: Math.max(1, Math.min(10_000, Number(requestsPerMinute) || 45)) + }; + const index = config.credentials.findIndex((item) => item.id === credential.id); + if (index >= 0) config.credentials[index] = credential; + else config.credentials.push(credential); + await saveConfig(config); + response.status(201).json(publicConfig(config).credentials); + } catch (error) { next(error); } +}); + +app.delete('/admin/credentials/:id', requireAdmin, async (request, response, next) => { + try { + if (config.documents.some((document) => document.credentialId === request.params.id)) throw new Error('Remove or reassign documents using this profile first.'); + config.credentials = config.credentials.filter((credential) => credential.id !== request.params.id); + limiters.delete(request.params.id); + await saveConfig(config); + response.status(204).end(); + } catch (error) { next(error); } +}); + +app.post('/admin/documents/discover', requireAdmin, async (request, response, next) => { + try { + const credential = credentialFor(request.body.credentialId); + const spreadsheetId = spreadsheetIdFrom(request.body.url); + const result = await tabsFor(credential, spreadsheetId); + response.json({ ...result, spreadsheetId }); + } catch (error) { next(error); } +}); + +app.post('/admin/documents', requireAdmin, async (request, response, next) => { + try { + const { id, name, url, credentialId, sheets } = request.body; + credentialFor(credentialId); + if (!Array.isArray(sheets) || !sheets.length) throw new Error('Select at least one sheet tab.'); + const document = { id: id || randomUUID(), name: requiredText(name, 'Document name'), spreadsheetId: spreadsheetIdFrom(url), credentialId, sheets }; + const index = config.documents.findIndex((item) => item.id === document.id); + if (index >= 0) config.documents[index] = document; + else config.documents.push(document); + await saveConfig(config); + response.status(201).json(publicConfig(config).documents); + } catch (error) { next(error); } +}); + +app.delete('/admin/documents/:id', requireAdmin, async (request, response, next) => { + try { + if (config.publications.some((publication) => publication.documentId === request.params.id)) throw new Error('Remove endpoints using this document first.'); + config.documents = config.documents.filter((document) => document.id !== request.params.id); + await saveConfig(config); + response.status(204).end(); + } catch (error) { next(error); } +}); + +app.post('/admin/publications', requireAdmin, async (request, response, next) => { + try { + const { id, name, documentId, sheets, cacheSeconds, dataFormat, range, omitBlankRows, maxRows, consumerRequestsPerMinute, scheduledRefresh } = request.body; + const document = documentFor(documentId); + const existing = config.publications.find((item) => item.id === id); + const selectedSheets = Array.isArray(sheets) && sheets.length ? sheets : document.sheets; + if (!selectedSheets.every((sheet) => document.sheets.some((available) => available.title === sheet.title))) throw new Error('Endpoint sheets must belong to the selected document.'); + if (!['rows', 'objects'].includes(dataFormat || 'rows')) throw new Error('Choose a valid data format.'); + const publication = { id: id || randomUUID(), name: requiredText(name, 'Endpoint name'), documentId, sheets: selectedSheets, apiToken: existing?.apiToken || randomBytes(24).toString('base64url'), cacheSeconds: Math.max(0, Math.min(86_400, Number(cacheSeconds) || 0)), dataFormat: dataFormat || 'rows', range: String(range || '').trim(), omitBlankRows: Boolean(omitBlankRows), maxRows: Math.max(1, Math.min(100_000, Number(maxRows) || 10_000)), consumerRequestsPerMinute: Math.max(1, Math.min(100_000, Number(consumerRequestsPerMinute) || 120)), scheduledRefresh: Boolean(scheduledRefresh), enabled: existing?.enabled !== false }; + const index = config.publications.findIndex((item) => item.id === publication.id); + if (index >= 0) config.publications[index] = publication; + else config.publications.push(publication); + cache.delete(publication.id); + endpointHealth.delete(publication.id); + await saveConfig(config); + response.status(201).json(publicConfig(config).publications); + } catch (error) { next(error); } +}); + +app.delete('/admin/publications/:id', requireAdmin, async (request, response) => { + config.publications = config.publications.filter((publication) => publication.id !== request.params.id); + cache.delete(request.params.id); + consumerLimiters.delete(request.params.id); + endpointHealth.delete(request.params.id); + await saveConfig(config); + response.status(204).end(); +}); + +app.post('/admin/publications/:id/rotate-token', requireAdmin, async (request, response, next) => { + try { + const publication = config.publications.find((item) => item.id === request.params.id); + if (!publication) throw new Error('The endpoint no longer exists.'); + publication.apiToken = randomBytes(24).toString('base64url'); + cache.delete(publication.id); + await saveConfig(config); + response.json({ token: publication.apiToken }); + } catch (error) { next(error); } +}); + +app.post('/admin/publications/:id/refresh', requireAdmin, async (request, response, next) => { + try { + const publication = config.publications.find((item) => item.id === request.params.id); + if (!publication) throw new Error('The endpoint no longer exists.'); + await refreshPublication(publication); + response.json(endpointStatus(publication)); + } catch (error) { next(error); } +}); + +app.post('/admin/publications/:id/enabled', requireAdmin, async (request, response, next) => { + try { + const publication = config.publications.find((item) => item.id === request.params.id); + if (!publication) throw new Error('The endpoint no longer exists.'); + publication.enabled = Boolean(request.body.enabled); + await saveConfig(config); + response.json({ enabled: publication.enabled }); + } catch (error) { next(error); } +}); + +app.get('/api/v1/:publicationId', async (request, response, next) => { + try { + const publication = config.publications.find((item) => item.id === request.params.publicationId); + const token = request.get('authorization')?.replace(/^Bearer\s+/i, ''); + if (!publication || !sameSecret(token, publication.apiToken)) { + auditEndpointRequest(request, request.params.publicationId, 401); + return response.status(401).json({ error: 'Invalid API credentials.' }); + } + if (publication.enabled === false) { + auditEndpointRequest(request, publication.id, 503, { error: 'Endpoint is disabled.' }); + return response.status(503).json({ error: 'This endpoint is temporarily disabled.' }); + } + if (consumerIsLimited(publication)) { + auditEndpointRequest(request, publication.id, 429, { error: 'Consumer rate limit exceeded.' }); + response.set('Retry-After', '60'); + return response.status(429).json({ error: 'Consumer rate limit exceeded. Try again in a minute.' }); + } + const cached = cache.get(publication.id); + if (cached && cached.expiresAt > Date.now()) { + auditEndpointRequest(request, publication.id, 200, { cached: true, stale: false }); + return response.json({ ...cached.payload, cached: true, stale: false }); + } + try { + const payload = await refreshPublication(publication); + auditEndpointRequest(request, publication.id, 200, { cached: false, stale: false }); + response.json({ ...payload, cached: false, stale: false }); + } catch (error) { + if (cached) { + auditEndpointRequest(request, publication.id, 200, { cached: true, stale: true, error: error.message }); + return response.json({ ...cached.payload, cached: true, stale: true, staleAt: new Date(cached.expiresAt).toISOString() }); + } + throw error; + } + } catch (error) { + auditEndpointRequest(request, request.params.publicationId, 502, { error: error.message }); + response.status(502).json({ error: error.message || 'Unable to refresh endpoint data.' }); + } +}); + +app.use((error, request, response, next) => { + console.error(error); + response.status(400).json({ error: error.message || 'Request failed.' }); +}); + +const port = Number(process.env.PORT) || 3000; +app.listen(port, () => { + console.log(`Google Sheets API publisher listening on http://localhost:${port}`); + console.log('Admin token: data/config.json -> adminToken'); +}); + +app.get('/admin/publications/:id/token', requireAdmin, (request, response, next) => { + try { + const publication = config.publications.find((item) => item.id === request.params.id); + if (!publication) throw new Error('The endpoint no longer exists.'); + response.json({ token: publication.apiToken }); + } catch (error) { next(error); } +}); + +setInterval(() => { + for (const publication of config.publications) { + if (!publication.scheduledRefresh || publication.cacheSeconds <= 0) continue; + const cached = cache.get(publication.id); + const refreshLeadMilliseconds = Math.min(10_000, publication.cacheSeconds * 200); + if (!cached || cached.expiresAt - Date.now() <= refreshLeadMilliseconds) { + refreshPublication(publication).catch((error) => console.error(`Scheduled refresh failed for ${publication.name}:`, error.message)); + } + } +}, refreshIntervalMilliseconds).unref(); \ No newline at end of file