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 saves the connection. A developer links it to the workflows that use it. The code then calls the host with no credentials of its own.

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. With one covering connection you never need it.

A host no linked connection covers gets the request exactly as the code sent it, with any stand-ins swapped.

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 ever holds 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. Inside a JSON template, single quotes save 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 work on text, and ~, refuse bytes: encode them first. Functions with no arguments still need their parentheses.

A template error at request time refuses that request with 403 and the reason, so a typo never sends a half-built credential.

At the gateway

Every fetch your code makes passes through the egress gateway. This is what it does with a request, in order.

  1. Destination

    Refuses IP addresses, ports other than 80 and 443, and any *.shiftlabs.org host, then allows at most 30 requests a minute to one host. This applies to every request, with a connection or without.

  2. Quota

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

  3. Choose

    Finds the linked connections whose hosts include this host; with x-shiftlabs-connection, the one it names. 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. One a connection applied to goes over https only and does not follow redirects: your code gets the 3xx and re-issues the request, and 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 as they are.

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 asks for 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

The same operations, with the same roles, in the dashboard and under /v1.

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