Menu QaldyDocs

HTTP API connector

Any HTTPS API: a base URL, an auth style and a key. Methods and paths stay fenced.

Connect any HTTPS API without waiting for a native connector: a base URL on a public host, how the key is sent, the methods and path prefix apps may use, and optional fixed headers. Apps call it through the gate; the key never reaches app code, the host is pinned, and every call is audited.

Connect one (admins)

/connectors → Add an HTTP API (or Set up on a service's card for a form filled in from its preset, which only asks for a name, the key and, where the service has one, your own address):

FieldRule
NameHow it appears on the gate in the City
Base URLhttps://<public host>[/path]. No IP literals, no localhost/.local/.internal/.test names, no userinfo, no query, port 443 only, host ≤ 100 characters. The API also refuses a name that resolves to a private address
How the key is sentBearer token (Authorization: Bearer …), Custom header (X-Api-Key: …, or a raw Authorization), User name and password (Authorization: Basic …), Secret in the URL path (webhook-style: see below), No key (public APIs: the fence and the audit still apply). There is no query-parameter style: why
KeyWrite-only, ≥ 8 characters (basic passwords excepted), printable ASCII for header styles; for a path token, plain URL segments. Sealed to this connection and this host: a new host, auth style or user name needs it typed again
MethodsAny of GET, POST, PUT, PATCH, DELETE. Read-only? Keep GET alone
Path prefixApps may only call paths under it (/items allows /items and /items/3, never /itemsx). / allows everything
Test pathA harmless GET for Test connection (defaults to the prefix). A connection that can't GET, or that says testable: false (a service that only sends), is not probed: Test connection only opens the sealed key and says nothing was sent
Fixed headersUp to 8 non-secret headers the gate adds (Notion-Version, Ozon's Client-Id). Shown to admins: never a key

A connection made from a service's preset also carries preset (the catalog entry's id) so the gallery shows it on that service's card; the HTTP API connector's own card lists every connection.

After saving, the dialog shows the connection id (conn_…), the SDK call and the manifest entry, and Test connection makes one real GET through the gate ("Works. api.example.com answered 200 in 142 ms", or what failed and the next step). A refused key (401/403) marks the gate Expired.

API: POST /v1/workspaces/:ws/connectors/http with HttpConnectorInput, PUT /v1/workspaces/:ws/connections/:id/http with HttpConnectorUpdate (schemas in packages/contracts/src/connector-catalog.ts).

Use it from an app

The manifest names the connection in scope (the builder writes this; a person approves it):

json
{ "id": "http", "actions": ["request"], "scope": "conn_k3v9…" }
ts
const api = ctx.connectors.http('conn_k3v9…')
const low = await api.get<{ items: Item[] }>('/items', { query: { below: 5 } })
await api.post('/items/BEAN-1/reorder', { json: { qty: 20 } })
// put, patch, delete too. A reply outside 2xx throws a ConnectorError (code `upstream_error`,
// with the API's own message); pass { throwOnError: false } to read it yourself.

HttpResponse is { status, ok, headers, body, text }; body is parsed JSON when the reply is JSON. Refusals the gate writes itself carry x-qaldy-gate: refused, so the SDK maps them to egress_blocked (with reason), not_connected, policy_unavailable… instead of an upstream error.

Path token

Some services make the URL the credential: a Slack incoming webhook (https://hooks.slack.com/services/T000/B000/XXXX), a Bitrix24 inbound webhook (https://<portal>.bitrix24.<tld>/rest/<user>/<code>/<method>). For those, pick Secret in the URL path (path_token). The base URL holds the part that is not secret (https://hooks.slack.com/services, https://acme.bitrix24.kz/rest); the secret (T000/B000/XXXX, 1/abcdef…) is sealed, and the gate builds every upstream path as

text
<base path> / <secret> / <the app's path>
  • The secret is one or more /-separated segments of letters, digits and - _ . ~, none empty and none . or .., at most 512 characters (isHttpPathToken in contracts, is_path_token in the gate). It can add neither a query nor a fragment nor a dot segment.
  • It always comes before anything the app chose, and the app's path is normalised first (.., %2e, %2f, \ refused), so an app can't replace it, shorten it or climb out of it. A path that repeats the secret is just more path after the real one.
  • The reply is scrubbed of the whole secret, of each segment of 6+ characters, and of their percent-encoded forms, in the body and every header (also across chunk boundaries).
  • Nothing records the path: audit rows, City events and logs carry the host and the status only.
  • In the console a webhook preset takes the whole URL and reads the host (if it is the admin's own) and the secret part from it (parsePresetWebhookUrl).

Rust tests: http_connector::tests::a_path_token_is_spliced…, …an_app_cannot_override_the_path_token…, flow_tests::http_flows::path_token_connections_splice_the_secret_and_the_app_cannot_read_or_override_it.

Why there is no query-parameter style

A key in ?api_key=… is copied into the upstream's access logs, proxies and browser histories, and into any error message that quotes the URL. The style was removed:

  • Zod refuses { style: "query" } with the reason ("A key in the URL query ends up in the service's access logs … Use a header, a bearer token or a path token") on create and update.
  • The gate refuses a policy that still carries it: 503 connector_query_auth_removed, nothing sent.
  • A connection stored with it before the removal is read, not lost: the API reports it as not working, Test connection answers query_auth_removed, the console says why and opens the form on a bearer token. Moving it to another style needs the key typed again (it was sealed for that style).

What the gate does on every call

https://connector.qaldy.internal/http/<connection>/<path>?<query>, signed by the runtime host:

  1. The version's policy must grant {id: "http", actions: ["request"], scope: <connection>} and list the connection (action_not_declared otherwise).
  2. Re-validates the connection's settings (connector_misconfigured if they don't parse).
  3. Method allowed (method_not_allowed), path normalised and under the prefix (path_not_allowed: ./.. segments, %2e, encoded / or \, empty segments are refused).
  4. Opens the sealed key with aad <workspace>|http:<connection>:<host>|<keyId> (connector_not_connected, connector_secret_unavailable).
  5. Resolves the host once; every address must be public (no loopback, private, link-local or metadata ranges: private_address) and it connects to exactly those addresses (no DNS rebinding). TLS with SNI = the host; redirects are never followed.
  6. Builds the request: the app's headers minus routing, authorization, cookies, proxy and x-qaldy-* headers; the fixed headers; the key per auth style (a header, or spliced into the path); Accept-Encoding: identity.
  7. Streams the reply back (≤ 16 MiB, ≤ 60 s) with every echoed form of the key replaced by [redacted] in headers and body, also when it is split across chunks (raw, : percent-encoded, form-encoded, base64 basic credentials). Set-Cookie and trailers are dropped. A compressed reply is refused (connector_upstream_encoded), because it can't be checked for the key.
  8. Records connector.called (connection, host, method, status, duration; never paths, queries, bodies or headers) → an audit row and the City's parcel between the app and its gate.

Threat notes

ThreatMitigation
App reaches an internal service through the connectionHost fixed by the admin, public addresses only, pinned per call, path prefix enforced after normalisation
App exfiltrates the keyIt never enters the isolate; echoes are scrubbed; compressed replies refused
Key replayed against another hostaad binds the key to the connection id and host: a moved base URL can't open it
Compromised control plane repoints a keySame binding: the API can't send an existing key to a new host without the plaintext
Short key scrubbing mangles repliesKeys under 8 characters are refused (basic passwords: the base64 credentials are scrubbed)
ResidualThe upstream sees the key (by design). Responses up to 16 MiB stream through; size and rate limits per connection are planned

Type to search. Up and down to choose, Enter to open, Esc to close.