Save-time rules: require one of, allowed hosts
## 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 A10–A12 ("reuse code… Improve the base connection… Each connector's code should be as slim as possible"). It adds the two smallest generic save-time capabilities the new vendors need, so no vendor branch lands in shared code. It adds NO credential type.
## Settled requirement (PRD R-C.4, verbatim)
> **Slimness bar**: each new API-key connector = one registry entry (+ at most one provider entry when it has Connect) and **zero** tester functions, zero TypeScript, zero migrations. A connector needing bespoke logic (Zapier's "test when connected", Make's zone-built host) adds the smallest generic capability that expresses it, never a vendor branch in shared code.
## Approach (Architecture §2.2, §2.3, G7 — pinned contract)
1. Add `require_one_of: tuple[tuple[str, ...], ...] = ()` to `CredentialTypeSpec`. A row can be saved when at least one group has every key non-blank.
- Enforce it in `apps/api/app/routes/credentials.py::_require_declared_fields` (`:610`), after the per-field required loop. That covers create, update, and the node login-wizard writes that call it. The Connect callback does not re-run it, which is acceptable because Connect only adds fields.
- A stored sealed secret counts as filled: `_is_blank(SealedSecret)` is already False. Use the SAME predicate as the required loop and add no new one. Test that an update blanking the last filled group returns 400, and that an update keeping a stored client secret saves (risk K2).
- 400 detail names keys, not labels: `"fill at least one of: client_id + client_secret; mcp_server_url; webhook_url"`. Groups are joined with `; ` and keys within a group with ` + `.
2. Add `allowed_hosts: tuple[str, ...] = ()` to `CredentialTypeSpec`. When it is non-empty, a non-blank host-field value must be exactly an allowed origin: scheme `https`, lowercased hostname in the set, no userinfo, no port, and an empty or `/` path. So `https://u:p@us1.make.com:8443/x` is refused.
- Write ONE clause function, `apps/api/app/credentials/testers.py::_allowed_host_clause(spec, value) -> Optional[str]`. Call it from BOTH `_field_problem` (test time, prefixed with the field label) and the url branch of `routes/credentials.py::_validated_value` (save time, prefixed with `field.key`). This mirrors how `require_https_base_url` is enforced in both places.
- Build the sentence from the tuple in order, e.g. `"must be one of https://us1.make.com, https://us2.make.com, https://eu1.make.com, https://eu2.make.com"`.
3. Add import guard G7 as a sibling `_check_save_rules(types)` next to the tester guards. Every `require_one_of` key must be declared and optional, and an `allowed_hosts` type needs a host field whose `default` (if any) passes `_allowed_host_clause`. It raises `RuntimeError` and can be called on a synthetic dict.
4. Test on SYNTHETIC specs only (monkeypatched or injected registry entries), because no real vendor type exists yet.
## Acceptance criteria this child owns the generic half of (PRD §6, verbatim)
> AC5 — Make: Zone defaults to `us1` on a new row; Test calls `https://<zone>.make.com/api/v2/...` for the chosen zone; an invalid zone is refused at save with a message naming the allowed values. (This child: the generic "refused at save with a message naming the allowed values" mechanism + the test-time refusal.)
>
> 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. (This child: the generic "≥1 group required on save" rule Zapier/Heroku need.)
>
> AC10 — Guards still enforce consistency … fails at import. (This child: G7.)
Quality bar: each refusal sentence must tell the operator exactly what to fill or which hosts are allowed; a bare "invalid" is a FAIL. There must be no `if slug == "make"` or `"zapier"` anywhere: the capability is pure data on the spec.
Credentials & reach: AC8/AC9/AC10 — worktree only — **verified**: nothing beyond the worktree. This child has no dev-UI oracle; operators see its rules through the vendor children.
Out of scope:
- No new credential types; sibling children own them. Do not set `require_one_of` or `allowed_hosts` on any existing type.
- Do NOT touch `KeyTestSpec`, `_declared_tester` or the tester migration (sibling child).
- "Do NOT add a DB migration." "Do NOT add a per-type web component or touch the credential form beyond what generic rendering already does; no new field kind." — nothing under `apps/web` or `apps/api/migrations`.
- "Do NOT change existing types' slugs, labels, fields, or test messages … do not change any `default_base_url` of an existing type."
- "Do NOT touch the generic types' semantics (`oauth2_client`, `generic_login`, `generic_api_key`) beyond moving shared code underneath them."
Done means:
- require_one_of — unit tests on a synthetic spec, all passing in `./dc check full`:
- create with no group filled → 400 with detail exactly `fill at least one of: <groups>` in the pinned format
- create with one full group → 201
- update keeping a stored sealed secret in the only filled group → 200
- update blanking the last filled group → 400
- allowed_hosts — unit tests on a synthetic spec: each allowed host saves. Each of the following is refused both at save (400 naming the allowed origins) and at Test (a `_field_problem` result naming the field label):
- a host not in the set
- `http://`, on a synthetic spec WITHOUT `require_https_base_url` (with that flag the existing https sentence fires first, which is correct)
- userinfo
- a port
- a non-root path
- G7 — a unit test shows each of these raises `RuntimeError` from `_check_save_rules`: a `require_one_of` naming an undeclared key, one naming a required key, and an `allowed_hosts` type whose host default is not allowed.
- AC9 — the PR diff touches nothing under `apps/web` or `apps/api/migrations`.
Outcome
Added generic require_one_of + allowed_hosts save rules (test and save) and the G7 _check_save_rules guard, tested on synthetic specs. PR #5232 merged; Jaah Deploy succeeded. Deferred: stored-host re-check on update, and PAT-paste path rules.
Artifacts
Activity
- Draft
- Ready
- Runner started
- Runner ended (done)