Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions .github/workflows/zapier.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions packages/zapier/src/authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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),
};
};

Expand All @@ -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),
};
};

Expand Down
39 changes: 38 additions & 1 deletion packages/zapier/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions packages/zapier/test/unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -40,6 +43,63 @@
});
});

// 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<string, unknown> },
) => HttpRequestOptionsWithUrl;

const teableUrl = `${apiBase()}/table/tbl1/record`;
const run = (url: string, authData?: Record<string, unknown>) =>
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({
Expand All @@ -65,7 +125,7 @@
{ createdTime: '2026-03-01T00:00:00.000Z' },
{ createdTime: '2026-02-01T00:00:00.000Z' },
] as FlatRecord[];
const sorted = [...rows].sort(byTimeDesc('createdTime'));

Check warning on line 128 in packages/zapier/test/unit.test.ts

View workflow job for this annotation

GitHub Actions / lint

unicorn(no-array-sort)

Use `Array#toSorted()` instead of `Array#sort()`.
expect(sorted.map((r) => r.createdTime)).toEqual([
'2026-03-01T00:00:00.000Z',
'2026-02-01T00:00:00.000Z',
Expand Down
Loading