shiftlabs Start free

Connections

Exact hosts and an auth template, owned by an admin. The gateway applies it to every request a linked workflow sends to those hosts.

Create and link

An admin creates the connection. A developer links it to workflows. The workflow code then calls the host without credentials.

shellcurl
# Create or replace a connection (admin key). Answers the connection with its version.
curl -X PUT https://api.shiftlabs.org/v1/connections/stripe \
  -H "authorization: Bearer $SHIFTLABS_API_KEY" -H "content-type: application/json" \
  -d '{"hosts": ["api.stripe.com"],
       "template": {"headers": {"Authorization": "Bearer {{ secret.STRIPE_KEY }}"}}}'

# Link it (developer key). The list replaces the workflow's links; [] removes them all.
curl -X PUT https://api.shiftlabs.org/v1/workflows/refund-check/connections \
  -H "authorization: Bearer $SHIFTLABS_API_KEY" -H "content-type: application/json" \
  -d '{"connections": ["stripe"]}'
refund-check
import { workflow } from "shiftlabs";

export default workflow<{ id: string }>(async ({ payload, step }) => {
  // No Authorization header: the stripe connection sets it.
  const charge = await step.run("fetch charge", () =>
    fetch(`https://api.stripe.com/v1/charges/${payload.id}`)
      .then((r) => r.json()),
  );
  return charge.status;
});

Hosts

A connection applies only to the hostnames it lists, matched exactly.

two connections cover api.stripe.com
await fetch("https://api.stripe.com/v1/transfers", {
  method: "POST",
  headers: {
    "content-type": "application/x-www-form-urlencoded",
    "x-shiftlabs-connection": "stripe-connect",
  },
  body: new URLSearchParams({ amount: "500", currency: "usd" }),
});

The header must name a connection linked to this workflow whose hosts include this host. If only one connection covers the host, you don't need the header.

Requests to hosts that no linked connection covers go out as your code sent them, with any stand-ins replaced.

The template

A JSON object with up to four parts. Every value is a template string: literal text with {{ expressions }} in it.

Template parts
PartKeysWhat the gateway does
headersHeader names, up to 128 characters.Sets each header, replacing any value the code sent.
queryParameter names, 1 to 128 characters.Sets each query parameter, replacing any the code sent.
pathOne template, starting with /.Replaces the whole path. Expression output is percent-encoded. The literal text around it is kept.
body.jsonJSON pointers, starting with /.Sets a string at each pointer, creating objects on the way. The request must send content-type: application/json and a JSON object.
body.formForm fields, 1 to 128 characters.Sets each field. The request must send content-type: application/x-www-form-urlencoded.
templateJSON
// Bearer token
{ "headers": { "Authorization": "Bearer {{ secret.STRIPE_KEY }}" } }

// Basic auth from two secrets
{ "headers": { "Authorization": "{{ basic(secret.TWILIO_SID, secret.TWILIO_TOKEN) }}" } }

// Key in the query string, account in the path
{ "query": { "key": "{{ secret.MAPS_KEY }}" },
  "path": "/v2/accounts/{{ var.ACCOUNT_ID }}/geocode" }

// Token in a JSON body field
{ "body": { "json": { "/auth/token": "{{ secret.PAGER_TOKEN }}" } } }

// Signed request: timestamp and HMAC of the final body
{ "headers": {
    "X-Timestamp": "{{ now() }}",
    "X-Signature": "{{ hmac_sha256(secret.SIGNING_KEY, now() ~ '.' ~ request.body) | base64 }}" } }

Expressions

Inside {{ }}: references, quoted text, functions, ~ to join and | to pipe.

References and syntax
ExpressionGives
secret.NAMEThe secret's value. Only the gateway sees it.
var.NAMEThe calling workflow's var: its own value, or the workspace value. See Vars.
request.method · request.hostThe outgoing request's method and hostname.
request.path · request.queryIts path, and its query string without the ?.
request.bodyIts body as text, up to 1 MiB. Header templates only.
'text' · "text"Literal text, with \n and \t escapes. Use single quotes inside a JSON template to avoid escaping.
a ~ bThe two joined.
x | fThe same as f(x).
x | f(y)The same as f(x, y).
( … )Grouping.
Functions
FunctionGives
base64(x) · base64url(x) · hex(x)The text or bytes encoded.
urlencode(x)The text percent-encoded.
json(x)The text as a JSON string, quotes included.
lower(x) · upper(x) · trim(x)The text lowercased, uppercased or trimmed.
sha256(x)The SHA-256 digest, as bytes.
hmac_sha256(key, data) · hmac_sha512(key, data)The HMAC of data under key, as bytes.
basic(user, password)Basic and the base64 of user:password: a whole Authorization value.
now() · now_ms()The time in Unix seconds or milliseconds, the same for every template in one request.
uuid()A random UUID.

Bytes from sha256 and the HMACs come out as lowercase hex unless you pipe them to base64, base64url or hex. Functions that take text, and ~, don't accept bytes. Encode them first. Functions with no arguments still need their parentheses.

If a template fails at request time, the request is refused with 403 and the reason.

At the gateway

Every fetch your code makes goes through the egress gateway. It handles each request in this order.

  1. Destination

    Refuses IP addresses, ports other than 80 and 443, and any *.shiftlabs.org host. Allows at most 30 requests a minute to one host. Applies to all requests, with or without a connection.

  2. Quota

    Counts the request against the workspace's outbound requests: 3,000 per 24 hours on the free tier, then 429. Requests refused by connection rules count too.

  3. Choose

    Finds the linked connections whose hosts include this host, or the one named in x-shiftlabs-connection. Without a match, the request goes out as sent.

  4. Apply

    Loads the secrets and vars the template names, patches the request and overwrites what the code set. Each use is audited as inject, with the connection, host and secret names.

  5. Send

    Every request carries X-Shiftlabs-Tenant and X-Abuse-Contact. A request a connection applied to is sent over https only and does not follow redirects. Your code gets the 3xx. If it re-issues the request, the rules apply again.

  6. Scrub

    If the response to that request echoes a secret, or a reversible encoding of it such as base64, hex or Basic auth, in a header or a text body, your code sees [redacted]. Signatures from sha256 and the HMACs stay visible. Binary bodies pass through unchanged.

Outbound requests · refund-check

3
RequestStatusTime
GET api.stripe.comvia connection stripe200182 ms
GET api.stripe.comno connectionblockedconnection billing is not declared for workflow refund-check3 ms
POST hooks.slack.comno connection20095 ms
Refusals: {"error": "egress_blocked", "reason": "…"} with x-shiftlabs-gateway: blocked
ReasonStatusWhen
connections stripe, stripe-connect all cover api.stripe.com; pick one with the x-shiftlabs-connection header403Two linked connections cover the host and the request names neither.
connection billing is not declared for workflow refund-check403x-shiftlabs-connection names a connection this workflow has not linked.
connection billing does not cover api.stripe.com403It names one whose hosts do not include this host.
credentials are only sent over https403The request to a covered host is plain http.
connection stripe patches the body; GET has none403A body.json or body.form template meets a GET or HEAD.
connection patches a JSON body; send content-type: application/json403The content type does not match the body part. The form part needs application/x-www-form-urlencoded. A JSON body that is not an object is refused as well.
request body too large for a connection that reads or patches it (1 MiB)403The template patches the body or reads request.body, and the body is over 1 MiB.
var ACCOUNT_ID used by connection maps is not set403The template names a var with no value for this workflow.
header X-Signature template produced a control character403A rendered header holds a line break or a NUL.
quota exceeded: egressPerDay=3000429The workspace's outbound requests for the last 24 hours are used up (3,000 on the free tier).
rate limited: over 30 requests/min to api.stripe.com429One host got more than 30 requests from the workspace in a minute.

Every refusal shows in the run's outbound requests with its reason. Refusals about which connection applies, or a missing var, are also written to the audit log as blocked.

Dashboard and API

Manage connections in the dashboard or with the /v1 API. Roles are the same in both.

Routes
RouteMethodMinimum role
/v1/connections [{name, hosts, template, secrets, vars, version, updatedAt}]GETviewer
/v1/connections/:name {"hosts", "template"}; returns the connectionPUTadmin
/v1/connections/:name {"deleted": name}, or 404DELETEadmin
/v1/workflows/:workflow/connections the linked namesGETviewer
/v1/workflows/:workflow/connections {"connections": [...]}; returns the linked namesPUTdeveloper