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.
# 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"]}'
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;
});
from workers import fetch
from shiftlabs import workflow
@workflow
async def refund_check(ctx):
# No Authorization header: the stripe connection sets it.
async def fetch_charge():
r = await fetch(f"https://api.stripe.com/v1/charges/{ctx.payload['id']}")
return await r.json()
charge = await ctx.step.run("fetch charge", fetch_charge)
return charge["status"]
- Name
1 to 63 lowercase letters, digits and hyphens, starting with a letter or digit. Workflows link it by this name.
- Saving
PUTreplaces the whole connection and raises its version. Every secret the template names must already exist. - Linking
A workflow uses only the connections linked to it. Linking an unknown name is refused, and you can link before the first deploy.
- Deleting
Deleting a connection unlinks it everywhere. A secret a connection uses cannot be deleted: the API answers
409withusedBy.
- Admins create, change and delete connections. Developers link them to workflows. Everyone in the workspace, viewers included, can read connections and their templates; a template holds expressions, never secret values.
- A workspace holds 10 connections on the free tier. One more answers
403 {"error": "quota exceeded", "limit": "connections", "max": 10}. - SDKs that insist on an API key work with a dummy one: the template overwrites whatever the code set. For credentials the code places itself, use secret stand-ins.
Hosts
A connection applies only to the hostnames it lists, matched exactly.
- Exact names
1 to 20 hostnames. No wildcards, ports or IP addresses:
api.stripe.comdoes not coverfiles.stripe.com. Names are trimmed, lowercased and de-duplicated. - Shape
At most 253 characters and at least two labels. Each label is 1 to 63 letters, digits or hyphens, not starting or ending with a hyphen, and the last one contains a letter.
- https only
A connection's credentials are only sent over https. A plain
http://request to a covered host is refused. - Overlaps
When two connections linked to one workflow cover the same host, each request must pick one with
x-shiftlabs-connection, or it is refused. The gateway strips the header before forwarding.
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" }),
});
await fetch("https://api.stripe.com/v1/transfers", method="POST",
headers={"content-type": "application/x-www-form-urlencoded",
"x-shiftlabs-connection": "stripe-connect"},
body="amount=500¤cy=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.
| Part | Keys | What the gateway does |
|---|---|---|
headers | Header names, up to 128 characters. | Sets each header, replacing any value the code sent. |
query | Parameter names, 1 to 128 characters. | Sets each query parameter, replacing any the code sent. |
path | One template, starting with /. | Replaces the whole path. Expression output is percent-encoded; the literal text around it is kept. |
body.json | JSON 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.form | Form fields, 1 to 128 characters. | Sets each field. The request must send content-type: application/x-www-form-urlencoded. |
// 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 }}" } }
- At least one entry and at most 32, counted across all parts. Any other top-level key is refused, and
bodypatchesjsonorform, not both. - Each template string is at most 2,048 characters, renders to at most 16 KiB, and nests expressions at most 32 deep.
- Reserved headers cannot be set:
Host,Content-Length,Transfer-Encoding,Connection,x-shiftlabs-connection,X-Shiftlabs-TenantandX-Abuse-Contact. - Secrets the template names must exist when you save it. Vars are looked up per request, so a var that is not set refuses the request instead.
- The gateway patches the body, then the path, then the query, and renders headers last, so header templates see the final request. Only header templates may read
request.body. - A path template replaces the path the code sent on every request to these hosts.
Expressions
Inside {{ }}: references, quoted text, functions, ~ to join and | to pipe.
| Expression | Gives |
|---|---|
secret.NAME | The secret's value. Only the gateway ever holds it. |
var.NAME | The calling workflow's var: its own value, or the workspace value. See Vars. |
request.method · request.host | The outgoing request's method and hostname. |
request.path · request.query | Its path, and its query string without the ?. |
request.body | Its 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 ~ b | The two joined. |
x | f | The same as f(x). |
x | f(y) | The same as f(x, y). |
( … ) | Grouping. |
| Function | Gives |
|---|---|
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.
- Destination
Refuses IP addresses, ports other than 80 and 443, and any
*.shiftlabs.orghost, then allows at most 30 requests a minute to one host. This applies to every request, with a connection or without. - 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. - 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. - 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. - Send
Every request carries
X-Shiftlabs-TenantandX-Abuse-Contact. One a connection applied to goes over https only and does not follow redirects: your code gets the3xxand re-issues the request, and the rules apply again. - 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 fromsha256and the HMACs stay visible. Binary bodies pass as they are.
Outbound requests · refund-check
3| Request | Status | Time |
|---|---|---|
| GET api.stripe.comvia connection stripe | 200 | 182 ms |
| GET api.stripe.comno connection | blockedconnection billing is not declared for workflow refund-check | 3 ms |
| POST hooks.slack.comno connection | 200 | 95 ms |
| Reason | Status | When |
|---|---|---|
connections stripe, stripe-connect all cover api.stripe.com; pick one with the x-shiftlabs-connection header | 403 | Two linked connections cover the host and the request names neither. |
connection billing is not declared for workflow refund-check | 403 | x-shiftlabs-connection names a connection this workflow has not linked. |
connection billing does not cover api.stripe.com | 403 | It names one whose hosts do not include this host. |
credentials are only sent over https | 403 | The request to a covered host is plain http. |
connection stripe patches the body; GET has none | 403 | A body.json or body.form template meets a GET or HEAD. |
connection patches a JSON body; send content-type: application/json | 403 | The 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) | 403 | The template patches the body or reads request.body, and the body is over 1 MiB. |
var ACCOUNT_ID used by connection maps is not set | 403 | The template names a var with no value for this workflow. |
header X-Signature template produced a control character | 403 | A rendered header holds a line break or a NUL. |
quota exceeded: egressPerDay=3000 | 429 | The 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.com | 429 | One 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.
- Connections screen
Every connection with its hosts, the secrets and vars its template uses, and its version. Admins create one with a name, hosts one per line and the template in a JSON editor, and edit or delete it from its panel.
- Workflows screen
Open a workflow and find its Connections section. Developers and admins choose Edit links, tick the connections the workflow uses and save.
- Audit
connection_put,connection_delete,workflow_link,injectandblocked, for admins on the Audit screen and at/v1/audit.
| Route | Method | Minimum role |
|---|---|---|
/v1/connections [{name, hosts, template, secrets, vars, version, updatedAt}] | GET | viewer |
/v1/connections/:name {"hosts", "template"}; answers the connection | PUT | admin |
/v1/connections/:name {"deleted": name}, or 404 | DELETE | admin |
/v1/workflows/:workflow/connections the linked names | GET | viewer |
/v1/workflows/:workflow/connections {"connections": [...]}; answers the linked names | PUT | developer |