From 7e8048e748c56f6b0c9aeb4aa2d3000fb8d9566e Mon Sep 17 00:00:00 2001 From: caoxing Date: Sat, 15 Aug 2026 01:03:01 +0800 Subject: [PATCH 1/3] fix(zapier): request base|read_all so the Base dropdown loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Base dropdown is powered by GET /api/base/access/all, which the backend guards with `base|read_all`. We only ever requested `base|read`. This worked until 2026-07-21. Before then the backend unconditionally concatenated `base|read_all` onto every OAuth client's scopes: scopes: scopes.concat('base|read_all'), teable cce429cb4 removed that implicit grant as a security fix for GHSA-c57x (OAuth scope escalation — third-party apps were getting broader read access than the user approved). Correct fix on their side; it just exposed that we were relying on the escalation without knowing it. Since then GET /api/base/access/all returns 403 {"message":"Forbidden resource","code":"restricted_resource"} so the Base dropdown renders empty and no new Zap can be configured. Already-configured Zaps keep running — they only touch table/record endpoints, whose scopes we request explicitly — which is why this surfaced as user reports rather than as broken Zaps. The connection itself still tests fine (authentication.test hits GET /api/auth/user, which only needs `user|email_read`), so the integration looks healthy while being unusable, and reconnecting never helps because it re-requests the same insufficient scope. Adds a unit test pinning every scope to the endpoint that needs it. All other endpoints were audited against their controller `@Permissions` and are correctly covered — the removed implicit grant covered `base|read_all` only, so the blast radius is exactly this one dropdown. Requires a matching change on the Teable side BEFORE release: the OAuth App (client id cltmh2wegs4wq0xoq4j on app.teable.ai) must be granted `base|read_all`, otherwise authorize rejects the request with "Invalid scopes" (oauth-server.service.ts:137). Every existing user must reconnect once, since scopes are fixed at grant time. Co-Authored-By: Claude Opus 5 (1M context) --- packages/zapier/src/authentication.ts | 7 +++++++ packages/zapier/test/unit.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/packages/zapier/src/authentication.ts b/packages/zapier/src/authentication.ts index fefa141..8a60f45 100644 --- a/packages/zapier/src/authentication.ts +++ b/packages/zapier/src/authentication.ts @@ -20,8 +20,15 @@ const expiresAt = (expiresIn: number | undefined): number => // Scopes requested from Teable (format: resource|action). Must be a subset of // what the OAuth App was granted in Teable → Settings → OAuth Apps. +// +// NOTE on `base|read_all`: the Base dropdown calls GET /api/base/access/all, +// which the backend guards with `base|read_all` — NOT `base|read`. Requesting +// only `base|read` makes that endpoint return 403 restricted_resource, so the +// dropdown comes back empty and no Zap can be configured. `base|read` is kept +// for the per-base endpoints (GET /api/base/:baseId and friends). const SCOPES = [ 'base|read', + 'base|read_all', 'table|read', 'field|read', 'view|read', diff --git a/packages/zapier/test/unit.test.ts b/packages/zapier/test/unit.test.ts index 7945dac..83117f6 100644 --- a/packages/zapier/test/unit.test.ts +++ b/packages/zapier/test/unit.test.ts @@ -43,6 +43,29 @@ describe('lib/client apiBase (driven by TEABLE_INSTANCE_URL)', () => { }); }); +// Every dropdown is powered by an endpoint the backend guards with a specific +// permission, and a missing scope fails silently: the request 403s and the +// dropdown just renders empty. `base|read_all` is the one that bit us — the Base +// dropdown calls GET /api/base/access/all, which is guarded by `base|read_all`, +// not by `base|read`. Pin the whole set so a scope can't be dropped again. +describe('authentication OAuth scopes', () => { + const scopes = App.authentication.oauth2Config.authorizeUrl.params.scope.split(' '); + + it.each([ + ['base|read_all', 'GET /api/base/access/all — the Base dropdown'], + ['table|read', 'GET /api/base/:baseId/table — the Table dropdown'], + ['view|read', 'GET /api/table/:tableId/view — the View dropdown'], + ['field|read', 'GET /api/table/:tableId/field — the field inputs'], + ['record|read', 'GET /api/table/:tableId/record — triggers and searches'], + ['record|create', 'POST /api/table/:tableId/record'], + ['record|update', 'PATCH /api/table/:tableId/record/:recordId + attachment upload'], + ['record|delete', 'DELETE /api/table/:tableId/record/:recordId'], + ['user|email_read', 'GET /api/auth/user — the connection label'], + ])('requests %s (%s)', (scope) => { + expect(scopes).toContain(scope); + }); +}); + // 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. From 06f901f20649f1637ea0947f3eef475b564a197d Mon Sep 17 00:00:00 2001 From: caoxing Date: Mon, 17 Aug 2026 10:26:28 +0800 Subject: [PATCH 2/3] feat(zapier): ask users to reconnect when the Base list is forbidden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding `base|read_all` fixes new authorizations, but it cannot repair the connections that already exist: a token's scopes are fixed at the moment the user grants them, so every connection created before this ships stays broken until its owner reconnects. Zapier will not prompt them on its own. authentication.test calls GET /api/auth/user, which only needs `user|email_read` and keeps returning 200, so the connection looks healthy and no reconnect flow is ever offered. Meanwhile the Base dropdown just renders empty, which reads as "this account has no bases" — the reporter of the original bug spent days with Zapier support chasing that before it reached us. So translate the 403 where it happens: bases.perform now throws ExpiredAuthError, which is Zapier's signal to mark the connection as needing attention and walk the user through reconnecting. Each affected user gets told, at the moment they hit the problem, by the product rather than by an email from us — and the same applies to any future scope change, which is what makes this scale past a handful of users. Deliberately scoped to the Base dropdown. Throwing from authentication.test or from a polling trigger would flag every not-yet-reconnected connection as broken and stop Zaps that are running perfectly well — they only touch table/record endpoints, whose scopes we have always requested explicitly. The dropdown is the one place the missing scope actually bites, and it is only reached while someone is editing a Zap. statusOf() reads the status back out of z.errors.Error, which JSON-stringifies { message, code, status } into the Error's message. Co-Authored-By: Claude Opus 5 (1M context) --- packages/zapier/src/lib/errors.ts | 17 ++++++ packages/zapier/src/triggers/bases.ts | 23 +++++++- packages/zapier/test/unit.test.ts | 76 ++++++++++++++++++++++++++- 3 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 packages/zapier/src/lib/errors.ts diff --git a/packages/zapier/src/lib/errors.ts b/packages/zapier/src/lib/errors.ts new file mode 100644 index 0000000..e741824 --- /dev/null +++ b/packages/zapier/src/lib/errors.ts @@ -0,0 +1,17 @@ +// handleErrors (see src/index.ts) turns a failed Teable response into +// z.errors.Error, whose constructor JSON-stringifies { message, code, status } +// into the Error's own message. This reads that status back out so a caller can +// react to one specific failure without repeating the request. +const statusOf = (error: unknown): number | undefined => { + if (!(error instanceof Error)) return undefined; + try { + const parsed = JSON.parse(error.message) as { status?: unknown }; + return typeof parsed.status === 'number' ? parsed.status : undefined; + } catch { + // Anything not produced by z.errors.Error — a network failure, a bug in our + // own code — has a plain-text message and simply has no status. + return undefined; + } +}; + +export { statusOf }; diff --git a/packages/zapier/src/triggers/bases.ts b/packages/zapier/src/triggers/bases.ts index 4d7a091..4588e5a 100644 --- a/packages/zapier/src/triggers/bases.ts +++ b/packages/zapier/src/triggers/bases.ts @@ -1,11 +1,32 @@ import type { ZObject, Bundle } from 'zapier-platform-core'; import { apiUrl } from '../lib/client'; +import { statusOf } from '../lib/errors'; import type { TeableBase, DropdownItem } from '../lib/types'; // Hidden trigger that powers the "Base" dropdown. GET /api/base/access/all // returns every base the token can see, across spaces. const perform = async (z: ZObject, bundle: Bundle): Promise => { - const response = await z.request({ url: apiUrl(bundle, '/base/access/all') }); + let response; + try { + response = await z.request({ url: apiUrl(bundle, '/base/access/all') }); + } catch (error) { + // A 403 here means the connection was authorized before we requested the + // `base|read_all` scope (see SCOPES in src/authentication.ts). Scopes are + // fixed at the moment the user authorizes, so neither a retry nor a token + // refresh can recover — the account has to be reconnected. + // + // ExpiredAuthError is what gets that across: Zapier marks the connection as + // needing attention and walks the user through reconnecting. Letting the + // raw 403 through instead leaves the dropdown silently empty, which reads + // as "Teable has no bases" and sends people hunting through their Teable + // permissions or Zapier support for something neither one can fix. + if (statusOf(error) === 403) { + throw new z.errors.ExpiredAuthError( + 'Your Teable connection is missing the "view all bases" permission. Please reconnect your Teable account to continue.', + ); + } + throw error; + } return (response.data || []).map((base) => ({ id: base.id, name: base.name, diff --git a/packages/zapier/test/unit.test.ts b/packages/zapier/test/unit.test.ts index 83117f6..e83ea1d 100644 --- a/packages/zapier/test/unit.test.ts +++ b/packages/zapier/test/unit.test.ts @@ -2,10 +2,12 @@ // 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 type { ZObject, Bundle, HttpRequestOptionsWithUrl } from 'zapier-platform-core'; import App from '../src'; +import bases from '../src/triggers/bases'; import { apiBase, apiUrl } from '../src/lib/client'; +import { statusOf } from '../src/lib/errors'; import { flatten, byTimeDesc } from '../src/lib/records'; import type { FlatRecord } from '../src/lib/records'; import { collectFieldsObject } from '../src/lib/fields'; @@ -66,6 +68,78 @@ describe('authentication OAuth scopes', () => { }); }); +describe('lib/errors statusOf', () => { + // Exactly what z.errors.Error(message, code, status) produces. + const appError = (status: number) => + new Error( + JSON.stringify({ message: 'Teable: Forbidden resource', code: 'TeableApiError', status }), + ); + + it('reads the status back out of a z.errors.Error', () => { + expect(statusOf(appError(403))).toBe(403); + }); + + it('returns undefined for a plain error (network failure, our own bug)', () => { + expect(statusOf(new Error('socket hang up'))).toBeUndefined(); + }); + + it('returns undefined when the message is JSON but carries no status', () => { + expect(statusOf(new Error(JSON.stringify({ message: 'nope' })))).toBeUndefined(); + }); + + it('returns undefined for a non-Error throw', () => { + expect(statusOf('403')).toBeUndefined(); + }); +}); + +// The Base dropdown is the one place a missing scope surfaces, and a bare 403 +// there is indistinguishable from "this account has no bases" — which is what +// sent the original reporter to Zapier support for days. It has to ask for a +// reconnect instead, because a token's scopes are fixed when it is granted. +describe('triggers/bases 403 handling', () => { + class ExpiredAuthError extends Error {} + const zWith = (request: () => Promise) => + ({ request, errors: { ExpiredAuthError } }) as unknown as ZObject; + const bundle = {} as Bundle; + const perform = bases.operation.perform; + + it('asks the user to reconnect when the base list is forbidden', async () => { + const z = zWith(() => + Promise.reject( + new Error(JSON.stringify({ message: 'x', code: 'TeableApiError', status: 403 })), + ), + ); + await expect(perform(z, bundle)).rejects.toThrow(ExpiredAuthError); + await expect(perform(z, bundle)).rejects.toThrow(/reconnect your Teable account/i); + }); + + it('leaves every other failure alone', async () => { + const boom = new Error(JSON.stringify({ message: 'x', code: 'TeableApiError', status: 500 })); + await expect( + perform( + zWith(() => Promise.reject(boom)), + bundle, + ), + ).rejects.toThrow(boom); + }); + + it('maps bases to dropdown items on success', async () => { + const z = zWith(() => + Promise.resolve({ data: [{ id: 'bse1', name: 'CRM', extra: 'ignored' }] }), + ); + await expect(perform(z, bundle)).resolves.toEqual([{ id: 'bse1', name: 'CRM' }]); + }); + + it('tolerates an empty body', async () => { + await expect( + perform( + zWith(() => Promise.resolve({})), + bundle, + ), + ).resolves.toEqual([]); + }); +}); + // 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. From d6221a48c20873f41dbfc070cff2a063d1c19b1b Mon Sep 17 00:00:00 2001 From: caoxing Date: Mon, 17 Aug 2026 10:46:05 +0800 Subject: [PATCH 3/3] chore(zapier): bump to 1.1.0 and document the multi-step release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CI deploy guard refuses to overwrite a version that is promoted and has Zap users, and 1.0.0 is both (3 users). Without a bump, merging this to main fails the deploy outright. Minor rather than patch: this changes the OAuth scopes the integration requests, so users see a new item on the consent screen and every existing connection has to be re-authorized. That is more than a fix. Also documents the release sequence in the README, because a version bump turns a one-command deploy into four, and the two extra steps both fail silently: - Env is per-version and does NOT follow a bump. A same-version push preserves it; a new version starts empty. Promoting without CLIENT_ID breaks OAuth for everyone — worse than the bug being fixed. - `promote` only decides what new users install. Without `migrate`, the existing users stay on the old version and never get the fix. Co-Authored-By: Claude Opus 5 (1M context) --- packages/zapier/README.md | 34 +++++++++++++++++++++++++++++++ packages/zapier/package-lock.json | 4 ++-- packages/zapier/package.json | 2 +- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/packages/zapier/README.md b/packages/zapier/README.md index c7785dd..c1fe364 100644 --- a/packages/zapier/README.md +++ b/packages/zapier/README.md @@ -110,6 +110,40 @@ https://zapier.com/dashboard/auth/oauth/return/App/ and the app must grant the scopes the integration requests (see `SCOPES` in `src/authentication.ts`). Then connect an account in the Zap editor. +### Releasing a new version + +Pushing to `main` runs `zapier push`, which **overwrites the same version** — fine +while a version is unpublished, and blocked by a CI guard once it is promoted and +has real users. When the guard trips, bump `version` in `package.json` and follow +the full sequence; `push` alone leaves everyone on the old version. + +```bash +npx zapier-platform versions # confirm state + Zap Users +# bump version in package.json, merge -> CI pushes the new version + +npx zapier-platform env:get # ← DO NOT SKIP (see below) +npx zapier-platform promote # new users get it from here on +npx zapier-platform migrate 100% # move the users who already exist +``` + +Two traps, both silent: + +- **Env is per-version and does not follow a version bump.** A same-version push + preserves it, a new version starts from whatever is (not) set. Promoting a + version with no `CLIENT_ID` breaks OAuth for everyone, far worse than whatever + you were fixing. Always `env:get` first, and `env:set` the three vars if it + comes back empty. +- **`promote` does not move existing users** — it only decides what new users + install. Without `migrate`, everyone already on the old version stays there, + which usually means they never receive the fix you just shipped. + +Changing `SCOPES` needs one more thing on top: scopes are frozen when a user +authorizes, so `migrate` swaps their code but not their token. Every existing +user has to reconnect before a newly-requested scope takes effect. `triggers/bases.ts` +turns the resulting 403 into `ExpiredAuthError` so Zapier asks them to, instead of +leaving the dropdown mysteriously empty — keep that pattern for any future scope +that gates a dropdown. + ## Testing ```bash diff --git a/packages/zapier/package-lock.json b/packages/zapier/package-lock.json index 39caaec..efc172c 100644 --- a/packages/zapier/package-lock.json +++ b/packages/zapier/package-lock.json @@ -1,12 +1,12 @@ { "name": "@teable/zapier", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@teable/zapier", - "version": "1.0.0", + "version": "1.1.0", "dependencies": { "zapier-platform-core": "19.0.0" }, diff --git a/packages/zapier/package.json b/packages/zapier/package.json index 51dbb74..ab5be40 100644 --- a/packages/zapier/package.json +++ b/packages/zapier/package.json @@ -1,6 +1,6 @@ { "name": "@teable/zapier", - "version": "1.0.0", + "version": "1.1.0", "description": "Teable is a no-code database platform for building collaborative apps, managing structured data, and automating workflows.", "main": "dist/index.js", "private": true,