Add Heroku and Zapier Connect types
## Original requirement (verbatim, epic body)
> Add to Credential types:
>
> - Apify
> - Zappier
> - Make
> - N8n - just check that it has a default option to connect to their main cloud
> - Gumloop
> - IFTTT
> - Replit
> - Lovable
> - Heroku
>
> Make sure you reuse code, e.g. generic Oauth or something else generic. Improve the base connection. Each connector's code should be as slim as possible.
This child covers asks A2 ("Zappier", labelled **Zapier**, the vendor's spelling), A9 ("Heroku"), and A10 ("reuse code, e.g. generic Oauth") for them. The six API-key vendors are a sibling child. It builds on the Foundation children: `KeyTestSpec`, the `_BESPOKE_TESTERS`-based `_TESTABLE_CONNECTABLE` narrowing, and `require_one_of`.
## Settled requirements this child builds (PRD R-A + R-C.3, verbatim)
> | `zapier` | Zapier | Client ID, Client secret, Refresh token/Access token/expiry (Connect-filled), MCP server URL (**secret** — it embeds a token), Webhook URL (**secret** — anyone holding it fires the Zap); all optional, ≥1 group required on save | Zapier API host | When OAuth-connected: user-profile call; otherwise result `unsupported` with a one-line reason (never red) | Connect (pinned Zapier endpoints) |
> | `heroku` | Heroku | API token (secret; required unless Connect fills it), Client ID, Client secret, Refresh token (+ Connect cache fields) | `https://api.heroku.com` fixed | `GET /account`, `Authorization: Bearer`, `Accept: application/vnd.heroku+json; version=3`; names the account email | Connect (pinned `https://id.heroku.com/oauth/authorize` / `/oauth/token`) |
>
> **R-C.3 OAuth reuse**: vendor Connect (Heroku, Zapier …) is a pinned `ConnectProviderSpec` entry on the existing CP flow — no new route, no new OAuth code path.
Settled refinements (Architecture, answered by the operator; where they differ from the PRD row, they take precedence):
- Q3 "No Test for Connect-only": Heroku tests only a pasted API token, and a blank token gives a neutral `unsupported` result naming Connect. Zapier is `testable=False` with a reason and a Connect remedy, and is never red.
- Q4: Zapier's MCP credential is one secret field, "MCP server URL".
- Zapier's fixed host is `zapier.com` (its token host), because no Test calls the API host.
- Heroku and Zapier add `scope`, `access_token` and `expiry_date`: the Connect scope input plus the CP token cache that `oauth2_tokens.py` writes.
## Approach (Architecture §2.4, §2.5 rows, D-C/D-D/D-E — pinned)
1. Provider entries in `apps/api/app/credentials/oauth_wizards.py::OAUTH_CONNECT_PROVIDERS`:
```python
"heroku": ConnectProviderSpec(slug="heroku", action_label="Connect Heroku",
authorize_url="https://id.heroku.com/oauth/authorize", token_url="https://id.heroku.com/oauth/token",
default_scope="global", provider_label="Heroku"),
"zapier": ConnectProviderSpec(slug="zapier", action_label="Connect Zapier",
authorize_url="https://api.zapier.com/v2/authorize", token_url="https://zapier.com/oauth/token/",
default_scope="profile", provider_label="Zapier"),
```
No new route, callback or wire code. Both entries pin their endpoints, so the existing guard (`oauth_wizards.py:136-150`) requires only the `client_id`, `client_secret` and `refresh_token` fields.
2. Registry entries in `apps/api/app/credentials/registry.py`. Place them after `stripe` and before the generic-types banner, in the D-G alphabetical order: Apify, Gumloop, **Heroku**, IFTTT, Lovable, Make, Replit, **Zapier**. The others come from the sibling child; keep the order on rebase.
- `heroku` "Heroku":
- Fields: `api_token` "API token" secret opt (placeholder "Or leave blank and press Connect"); `client_id` "Client ID" **text** opt; `client_secret` "Client secret" secret opt; `scope` "Scopes" text opt (placeholder "global - what Connect asks Heroku for"); `refresh_token` secret opt; `access_token` secret opt; `expiry_date` text opt. Use the same labels and placeholders as `oauth2_client` (`registry.py:~519-529`).
- `default_base_url="https://api.heroku.com"`.
- `key_test=KT(path="/account", key_field="api_token", value_prefix="Bearer", accept="application/vnd.heroku+json; version=3", identity=(("email",),), blank_key_reason="No API token on this credential - a Connect-only row has no Test; press Connect, which fails at once if the client is wrong.", reject_names_vendor=True)`.
- `oauth_connect="heroku"`; `require_one_of=(("api_token",),("client_id","client_secret"))`; `nodevault_eligible=False`; `require_https_base_url=True`.
- `zapier` "Zapier":
- Fields: `client_id` "Client ID" text opt; `client_secret` "Client secret" secret opt; `scope` "Scopes" text opt (placeholder "profile - what Connect asks Zapier for"); `refresh_token` and `access_token` secret opt; `expiry_date` text opt; `mcp_server_url` "MCP server URL" **secret** opt; `webhook_url` "Webhook URL" **secret** opt (placeholder "https://hooks.zapier.com/hooks/catch/...").
- `default_base_url="https://zapier.com"`.
- `testable=False`; `untestable_reason="Zapier refresh tokens rotate, so the only possible check - spending one - would retire the token being tested; and the MCP and webhook URLs would run your Zaps."`; `untestable_connect_remedy="Press Connect instead: it fails immediately if the client is wrong."`.
- `oauth_connect="zapier"`; `require_one_of=(("client_id","client_secret"),("mcp_server_url",),("webhook_url",))`; `nodevault_eligible=False`.
- The MCP and webhook URLs get NO URL validation at save: they are opaque capabilities, and secret fields carry no format rule. They are `secret`, not `url`, so neither can become the host field.
3. Add `"heroku"` and `"zapier"` to the hand-kept `_CONNECTABLE` literal in `apps/api/tests/test_oauth2_connect_types.py:28`; otherwise `:286` fails. The Foundation child already narrowed `_TESTABLE_CONNECTABLE` so that declarative Heroku stays out of the refresh-exchange arms. Verify it stays excluded.
4. Tests:
- Provider pinning: the authorize and token URLs above, which the operator cannot change (see 7).
- The import guard still holds.
- Connect start for a saved Heroku or Zapier row with a client pair redirects to the pinned authorize host, with the default scope when `scope` is blank.
- Heroku `/account`: 200 → green naming the email; 401 → "Heroku rejected the API token - check it."; blank token → `unsupported` with the blank_key_reason and zero requests; a whitespace-only `api_token` fails on that field (`_field_problem`).
- Zapier: `POST /test` → `unsupported` with exactly `untestable_reason`, never red; reason and remedy on `GET /api/credentials/types` (see 6).
- `require_one_of` refusals:
- Zapier with nothing filled → 400 `fill at least one of: client_id + client_secret; mcp_server_url; webhook_url`
- Zapier with only `webhook_url` → saves
- Heroku with neither a token nor a client pair → 400
- an update that blanks Zapier's last filled group → 400
5. Existing registry-wide tests this child MUST update (found at the critic gate), all in `apps/api/tests/test_credentials.py`:
- `_EXPECTED_TYPES` (`:~2022`, reused by `test_credentials_tenant_types.py:~24`): add the full heroku and zapier entries in D-G order. This is the AC1/AC2 unit oracle.
- `fixed_host` set (`:~2855`): add heroku, zapier.
- `require_https_base_url` set (`:~3749`): add heroku.
- `TESTERS` set (`:~6001`): add heroku.
- Untestable set (`:~6025`): add zapier.
6. Zapier Test contract: `testers.unsupported_result` returns `untestable_reason` ONLY and MUST NOT change. The Connect remedy is appended by `routes/credentials.py::_untestable_reason_out` on `GET /api/credentials/types`. So assert that `POST /test` on a Zapier row returns `ok=False, status="unsupported", message=untestable_reason` (never red), and assert the reason plus remedy on the types endpoint.
7. Pinning test: `_merged_config` returns 400 for undeclared keys. So prove that the operator cannot change endpoints in two ways: at the Connect-resolution level on a dict (the pinned spec's `authorize_url`/`token_url` are used whatever the row config says), AND by showing that a save carrying `authorize_url` is refused with 400.
## Acceptance criteria (PRD §6, verbatim — for Heroku and Zapier)
> AC1 — All 8 new types (Apify, Zapier, Make, Gumloop, IFTTT, Replit, Lovable, Heroku) appear in Credentials → New → Type, with exactly those labels (readable, correct brand spelling, no raw slugs), above the generic types. (This child: Heroku, Zapier.)
> AC2 — Each new type's form shows exactly the fields in R-A with readable labels; secret fields render as secrets and are never returned to the browser after save. (Read against the Architecture field list above, which adds scope/access_token/expiry_date.)
> AC3 — Testable types with a valid key go green with a sentence naming the vendor (and identity where the vendor returns one); an invalid key gives a clear red naming the vendor. (Heroku.)
> AC4 — … Zapier without Connect returns `unsupported` with a reason, never red. (Per Q3: Zapier is untestable with reason + Connect remedy in every state.)
> AC6 — Heroku and Zapier rows show a Connect button; pressing it goes to the vendor's consent page (id.heroku.com / Zapier) and a completed consent stores the refresh token; the Connect endpoints cannot be changed by the operator.
> AC8 — no new type in R-A that is a single-GET key check has a tester function.
> AC9 — Each new connector's code is one registry entry (+ one provider entry when it has Connect); no TypeScript, no migration changed for them.
> AC11 — Available to the operator: everything above is ON by default, behind no flag, on the dev control plane.
Quality bar: the Connect buttons read "Connect Heroku" and "Connect Zapier". Every untestable or unsupported sentence tells the operator what to do next (press Connect) rather than looking like a failure. Field labels and placeholders make it obvious that either the token or the client pair is enough. A Zapier row that shows red, or a Heroku Connect-only row that shows a confusing error on Test, is a FAIL.
Credentials & reach: (Architecture §7, verbatim rows)
- Dev UI: AC1, AC2, AC4, AC6 (reaching the consent host), AC11 — Credential `jaah/debug/main/operator-login` (operator login, works on dev) against `https://app.dev.jaah.ai` — **verified**: the roster fetch succeeded, and the dev console answered 200 from h2.
- AC6 Connect start reaching the vendor host — operator login (row above) + placeholder `client_id`/`client_secret` typed on dev (not a credential) — **verified**: the operator login reaches dev, and the start route needs only a saved client pair (`routes/credentials.py:2830-2835`).
- AC3 garbage-key red for each new testable vendor — none (worktree + public vendor hosts) — **verified**: … Heroku answered 401 to unauthenticated/bogus probes.
- AC3 real-key green for Heroku — a vendor key — **deferred**: none in the roster; the operator chose to defer (Q5). Covered by mocked unit tests.
- AC6 completed consent (refresh token stored) for Heroku and Zapier; proof of the Zapier authorize pin — a Heroku OAuth client and a Zapier OAuth client — **deferred**: none in the roster; the operator chose to defer (Q5).
- AC8/AC9 — worktree only — **verified**: nothing beyond the worktree.
Out of scope:
- The six API-key vendors (apify, gumloop, ifttt, lovable, make, replit); a sibling child owns them.
- "Do NOT add new OAuth routes, callback paths, or a second OAuth implementation — extend `OAUTH_CONNECT_PROVIDERS` only." No change to `oauth2_wire.py`, `oauth2_tokens.py`, or the Connect routes.
- "Do NOT commit any OAuth client id/secret or vendor key (operator rule, `oauth_wizards.py:9-11`)."
- "Do NOT make a Test call that mutates vendor state (no IFTTT webhook trigger, no Zapier webhook/MCP call, no OAuth refresh-token spend on rotating vendors)."
- "Do NOT touch the generic types' semantics (`oauth2_client`, `generic_login`, `generic_api_key`) beyond moving shared code underneath them."
- "Do NOT add a per-type web component or touch the credential form beyond what generic rendering already does; no new field kind." "Do NOT add a DB migration." — nothing under `apps/web` or `apps/api/migrations`.
- Do NOT edit the `KeyTestSpec`, `_declared_tester`, `require_one_of` or `unsupported_result` machinery, except for a bug fix proven by a failing test; report any such fix in the PR body.
- Deferred, not in this child: a Connect-only Test using the cached access token (Q3), and the stale comment at `registry.py:~539-540` (doc hygiene).
Done means:
- AC6/AC4/AC3/require_one_of unit — `./dc check full` is green with the new tests listed in Approach 4, 6 and 7, and `tests/test_oauth2_connect_types.py` is green with `_CONNECTABLE` widened.
- AC8 — `grep -n "def test_" apps/api/app/credentials/testers.py` shows none for heroku or zapier.
- AC9 — the PR diff touches nothing under `apps/web` or `apps/api/migrations`; for each vendor, the diff is one registry entry plus one provider entry, plus tests.
- AC1 — after this PR's Jaah Deploy succeeds on dev: Credentials → New → Type lists Heroku and Zapier with those exact labels, above the generic types, and `GET /api/credentials/types` lists both slugs. Screenshot in the report.
- AC2 — on dev, for each type: create a row with dummy values, save, and reload. The secret fields (client secret, API token, refresh/access token, MCP server URL, Webhook URL) show "leave blank to keep", and their values are absent from `GET /api/credentials/{id}`. Delete the dummy rows afterwards.
- AC4 — on dev:
- A Zapier row shows Test disabled with the untestable reason and the "Press Connect instead…" remedy (from `GET /api/credentials/types`), never red.
- A Heroku row with no API token shows the neutral `unsupported` sentence on Test.
- AC6 start — on dev, Connect on a Heroku row saved with placeholder client_id/client_secret redirects to `id.heroku.com/oauth/authorize`, and on a Zapier row to `api.zapier.com/v2/authorize`. Record the redirect host in the report, with any client value stripped.
- AC6 completion — (deferred — no Heroku/Zapier OAuth client in the roster; operator chose to defer, Q5). Report it as deferred; the Zapier authorize pin stays unverified (risk K3).
- AC3 Heroku red (live) — on dev, Test on a Heroku row with a garbage API token shows "Heroku rejected the API token - check it." Green (deferred — no Heroku token in the roster, Q5).
- AC11 — everything above is observed on dev with no flag toggled.
- AC1 (all 8) — whichever of the two vendor children deploys SECOND also screenshots the dropdown showing all 8 labels (Apify, Gumloop, Heroku, IFTTT, Lovable, Make, Replit, Zapier) above the generic types. The first to deploy notes in its report that the combined check falls to the sibling.
Outcome
Heroku + Zapier credential types (registry + pinned Connect providers) and a redactor key widening. PR #5244 merged; full gate green; Jaah Deploy to dev succeeded and dev checks pass. Deferred: OAuth consent and real-key green (no clients/keys), and 7 UX items in the report.
Artifacts
Activity
- Draft
- Ready
- Runner started
- Runner ended (done)