From 010893a0d03336f3798b1a7576568b7b54d7440d Mon Sep 17 00:00:00 2001 From: caoxing Date: Wed, 8 Jul 2026 17:17:02 +0800 Subject: [PATCH 1/2] fix(zapier): refresh the OAuth token preemptively to stop per-poll 401s Co-authored-by: Cursor --- packages/zapier/src/authentication.ts | 11 +++++ packages/zapier/src/index.ts | 39 ++++++++++++++++- packages/zapier/test/unit.test.ts | 60 +++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 1 deletion(-) diff --git a/packages/zapier/src/authentication.ts b/packages/zapier/src/authentication.ts index 72d91fb..fefa141 100644 --- a/packages/zapier/src/authentication.ts +++ b/packages/zapier/src/authentication.ts @@ -9,6 +9,15 @@ interface TokenResponse { [key: string]: unknown; } +// Teable's default access-token TTL, used when the token response omits +// expires_in for any reason. +const DEFAULT_EXPIRES_IN_SECONDS = 600; + +// Absolute expiry (epoch ms) persisted into authData so beforeRequest can +// refresh proactively instead of burning a 401 on every expired token. +const expiresAt = (expiresIn: number | undefined): number => + Date.now() + (Number(expiresIn) > 0 ? Number(expiresIn) : DEFAULT_EXPIRES_IN_SECONDS) * 1000; + // Scopes requested from Teable (format: resource|action). Must be a subset of // what the OAuth App was granted in Teable → Settings → OAuth Apps. const SCOPES = [ @@ -41,6 +50,7 @@ const getAccessToken = async (z: ZObject, bundle: Bundle) => { return { access_token: response.data.access_token, refresh_token: response.data.refresh_token, + expires_at: expiresAt(response.data.expires_in), }; }; @@ -62,6 +72,7 @@ const refreshAccessToken = async (z: ZObject, bundle: Bundle) => { return { access_token: response.data.access_token, refresh_token: response.data.refresh_token, + expires_at: expiresAt(response.data.expires_in), }; }; diff --git a/packages/zapier/src/index.ts b/packages/zapier/src/index.ts index f9c9ada..2842b67 100644 --- a/packages/zapier/src/index.ts +++ b/packages/zapier/src/index.ts @@ -27,6 +27,43 @@ import findRecordById from './searches/get_record'; // eslint-disable-next-line @typescript-eslint/no-var-requires const { version } = require('../package.json'); +// Refresh this many ms BEFORE the token actually expires, to absorb clock skew +// and request latency. +const REFRESH_SAFETY_MARGIN_MS = 60 * 1000; + +// Zapier never refreshes proactively on its own — it only refreshes after a +// request fails with RefreshAuthError. With Teable's ~10 min token TTL and +// ~10 min polling cadence, that reactive model means nearly every poll first +// burns a 401 against the API (which shows up as 4xx noise in the integration's +// Monitoring). So: token exchanges store an absolute `expires_at` in authData, +// and this middleware throws RefreshAuthError BEFORE sending a request with a +// stale token — Zapier then refreshes and retries without the API ever seeing +// the expired token. +const preemptiveTokenRefresh = ( + request: HttpRequestOptionsWithUrl, + z: ZObject, + bundle: { authData?: { access_token?: string; expires_at?: number | string } }, +): HttpRequestOptionsWithUrl => { + const url = typeof request.url === 'string' ? request.url : ''; + const toTeable = url.startsWith(rawInstance()); + // The token endpoint must be exempt: the refresh request itself always runs + // with an expired (or absent) access token. + const isTokenEndpoint = url.includes('/oauth/access_token'); + // Older connections have no expires_at; they keep the reactive 401 path. + const expiresAt = Number(bundle.authData?.expires_at); + if ( + toTeable && + !isTokenEndpoint && + bundle.authData?.access_token && + Number.isFinite(expiresAt) && + expiresAt > 0 && + Date.now() >= expiresAt - REFRESH_SAFETY_MARGIN_MS + ) { + throw new z.errors.RefreshAuthError('Teable access token is about to expire; refreshing.'); + } + return request; +}; + // Attach the OAuth access token as a Bearer token — but ONLY on requests to the // Teable instance. Attachment uploads first download the file from an arbitrary // external URL; we must not leak the Teable token to that third-party host. @@ -70,7 +107,7 @@ const App = { authentication, - beforeRequest: [includeBearerToken], + beforeRequest: [preemptiveTokenRefresh, includeBearerToken], afterResponse: [handleErrors], // Keyed by each operation's `key`. We use string literals (equal to the diff --git a/packages/zapier/test/unit.test.ts b/packages/zapier/test/unit.test.ts index 24b5b05..7945dac 100644 --- a/packages/zapier/test/unit.test.ts +++ b/packages/zapier/test/unit.test.ts @@ -2,6 +2,9 @@ // so they always run (and are safe in CI). Logic-only coverage of the bits most // likely to break: URL building, record flattening, field collection. +import type { ZObject, HttpRequestOptionsWithUrl } from 'zapier-platform-core'; + +import App from '../src'; import { apiBase, apiUrl } from '../src/lib/client'; import { flatten, byTimeDesc } from '../src/lib/records'; import type { FlatRecord } from '../src/lib/records'; @@ -40,6 +43,63 @@ describe('lib/client apiBase (driven by TEABLE_INSTANCE_URL)', () => { }); }); +// The preemptive-refresh middleware is the first beforeRequest hook. It must +// throw RefreshAuthError for a stale token BEFORE the request goes out (so the +// API never logs a 401), and must stay out of the way everywhere else. +describe('beforeRequest preemptive token refresh', () => { + class RefreshAuthError extends Error {} + const z = { errors: { RefreshAuthError } } as unknown as ZObject; + const middleware = App.beforeRequest[0] as ( + request: HttpRequestOptionsWithUrl, + z: ZObject, + bundle: { authData?: Record }, + ) => HttpRequestOptionsWithUrl; + + const teableUrl = `${apiBase()}/table/tbl1/record`; + const run = (url: string, authData?: Record) => + middleware({ url }, z, { authData }); + + it('throws RefreshAuthError when the token is past expiry', () => { + expect(() => run(teableUrl, { access_token: 't', expires_at: Date.now() - 1000 })).toThrow( + RefreshAuthError, + ); + }); + + it('throws within the safety margin (about to expire)', () => { + expect(() => run(teableUrl, { access_token: 't', expires_at: Date.now() + 30 * 1000 })).toThrow( + RefreshAuthError, + ); + }); + + it('passes through when the token is still fresh', () => { + const req = run(teableUrl, { access_token: 't', expires_at: Date.now() + 10 * 60 * 1000 }); + expect(req.url).toBe(teableUrl); + }); + + it('handles expires_at stored as a string (authData round-trip)', () => { + expect(() => + run(teableUrl, { access_token: 't', expires_at: String(Date.now() - 1000) }), + ).toThrow(RefreshAuthError); + }); + + it('skips legacy connections without expires_at (falls back to the 401 path)', () => { + const req = run(teableUrl, { access_token: 't' }); + expect(req.url).toBe(teableUrl); + }); + + it('never blocks the token endpoint itself (refresh must not dead-lock)', () => { + const tokenUrl = `${apiBase()}/oauth/access_token`; + const req = run(tokenUrl, { access_token: 't', expires_at: Date.now() - 1000 }); + expect(req.url).toBe(tokenUrl); + }); + + it('ignores requests to third-party hosts (attachment downloads)', () => { + const external = 'https://files.example.com/a.png'; + const req = run(external, { access_token: 't', expires_at: Date.now() - 1000 }); + expect(req.url).toBe(external); + }); +}); + describe('lib/records flatten', () => { it('spreads fields up while keeping id/timestamps and raw fields', () => { const flat = flatten({ From e1f2f49ca41f6ddb22da04b570d485ec2826e096 Mon Sep 17 00:00:00 2001 From: caoxing Date: Wed, 8 Jul 2026 17:21:34 +0800 Subject: [PATCH 2/2] ci(zapier): treat draft versions as overwritable in the deploy guard Co-authored-by: Cursor --- .github/workflows/zapier.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/zapier.yml b/.github/workflows/zapier.yml index d0ea95a..8fbd133 100644 --- a/.github/workflows/zapier.yml +++ b/.github/workflows/zapier.yml @@ -83,12 +83,13 @@ jobs: STATE=$(echo "$ROW" | jq -r '.State // "private"') USERS=$(echo "$ROW" | jq -r '(.["Zap Users"] // "0") | tonumber') echo "Version $VERSION — state: $STATE, Zap users: $USERS." - # While the version is still private (pre-publish beta), overwrite - # freely — the only live Zaps are your own test users, which you must - # turn on to satisfy Zapier's publishing requirements. Once the version - # is promoted (no longer private) and has real users, refuse to - # overwrite: bump the version and promote instead. - if [ "$STATE" != "private" ] && [ "${USERS:-0}" -gt 0 ]; then + # While the version is unpublished — Zapier reports "private" for + # never-submitted versions and "draft" while the app is pending + # review — overwrite freely: the only live Zaps are your own test + # users, which you must turn on to satisfy Zapier's publishing + # requirements. Once the version is promoted (public) and has real + # users, refuse to overwrite: bump the version and promote instead. + if [ "$STATE" != "private" ] && [ "$STATE" != "draft" ] && [ "${USERS:-0}" -gt 0 ]; then echo "::error::Version $VERSION is $STATE and has $USERS user(s). Bump the version in packages/zapier/package.json, then promote." exit 1 fi