Declare a connection

Connection setup and security

Build guided connection steps and understand edge enforcement.

For: plugin authors.

Build guided connection steps and understand edge enforcement.

Guided steps

When the admin has to make an app on the other system first, as with an app of the tenant's own on Slack or Discord, a step can be guided: it has no browser, and the edge's own page runs it. The page shows a link that creates the app with everything filled in, the values to copy into the app's settings, and a form for what the admin pastes back from them; then it runs the step's checks, and the flow goes on only once every one has passed.

What is pasted goes to the edge's page, never through management or the console: a secret is sealed as a tenant secret and never shown again, a value is kept on the connection.

oz0-plugin.yaml
    steps:
      - name: app
        description: Create your Forge app, then paste its credentials here
        create:
          label: Create the app on Forge
          url: "https://forge.example/apps/new"
          json_query:                     # a JSON document, URL-encoded into the link's query
            manifest:
              name: "{params.app_name}"
              webhook_url: "{events_url.forge}"
              redirect_url: "{callback_url}"
        copy:
          - {label: Webhook URL, value: "{events_url.forge}"}
        paste:
          - {name: client_id, label: Client ID, value: client_id, format: {pattern: '[0-9]+'}}
          - {name: token, label: Bot token, secret: FORGE_BOT_TOKEN, format: {prefix: "fb_", min_length: 20}}
          - {name: signing, label: Signing secret, secret: FORGE_SIGNING_SECRET, format: {min_length: 32, max_length: 32}}
        verify:
          - name: token                    # runs with what was pasted, before anything is saved
            description: Forge accepts the bot token
            request:
              method: GET
              url: "{params.api_url}/me"
              headers: {Authorization: "Bearer {paste.token}"}
              expect: {bot: "true", app_id: "{paste.client_id}"}
          - name: signed                   # waits for a delivery that passes the source's signature check
            description: Forge's test delivery reaches the edge, signed with this secret
            delivery: forge
            hint: Click Send test delivery on your app's webhook page.
FieldMeaning
createA link: label, url, query, and json_query, parameters whose value is a JSON document made from the object and URL-encoded, as json_fields does for a form. It opens in a new tab. Its templates read params, values, {callback_url}, {edge_url}, {events_url.<source>} and {tenant}: never a secret.
copy[]label and value: what the admin copies into the app's settings, such as an interactions URL. The page and the console show each with a copy button.
paste[]Named inputs saved as secrets, connection values or both. At most 8 fields, 4096 bytes each. See field validation.
verify[]Requests or signed deliveries verify the pasted values. At most 4 checks, including one delivery. See check timing and credentials.

Pasted fields

Each paste[] entry has name, label and description, plus secret, value or both. A secret is a tenant secret named with the connection's prefix. A value is kept on the connection as {values.<name>}.

Both may be set for an identifier that is not secret but is used to check deliveries, such as a bot's app ID used as its tokens' audience. The page then does not ask for it again.

format checks prefix, min_length, max_length and pattern (a whole-field RE2) before anything is sent or saved, catching a value pasted into the wrong box. There are at most 8 fields, each at most 4096 bytes.

Guided verification checks

Each verify[] entry has name, description and hint, which tells the admin how to make it pass. It uses either request or delivery. A request is made by the edge like an exchange, with expect; a delivery names one of the plugin's event sources.

A request may read {paste.<name>} and the connection's own {secret.*} in headers, basic auth and body. It may never put them in url or query, which an error could repeat.

A request reading {paste.*} runs when fields are pasted, and saves nothing if it fails. Other requests run once the fields are saved and again on Check again.

A delivery check passes when a delivery to its source passes signature verification after the paste. That proves the signing secret or public key and the events URL together. A step has at most 4 checks, including at most one delivery check.

A guided step may also offer an app package the admin uploads to the other system, such as a Teams app:

oz0-plugin.yaml
        package:
          label: Download the Teams app package
          file: oz0-teams-app.zip
          icons: {color: color.png, outline: outline.png}   # 192 and 32 pixels, drawn in the brand's colours
          manifest:                                         # manifest.json, every string a template of the create scopes
            id: "{values.app_id}"
            bots: [{botId: "{values.app_id}", scopes: [personal, team]}]

The edge builds the zip from the template, with the tenant's URLs and the values pasted on earlier steps, and never a secret, the flow's state or a pasted field; the guided page links to it, and admins download it from the Connections page and with oz0 connection package <connection>.

A step is a browser step or a guided one, never both; a guided step has no callback, exchange, pkce or save. A guided step with nothing to paste and only requests, such as checking a bot token right after an install, runs at once. Every result and its time are on the connection: the Connections tab, oz0 connection show and the API (ConnectionStep.checks) show them, never what was pasted. expect values are templates too, such as "{paste.client_id}".

Templates

Every string is a template. Expressions are {scope.name}, and |base takes what follows the last / (a repository's name in owner/name), |json quotes a value as a JSON string.

ExpressionWhatWhere
{params.<name>}A param, or its defaulteverywhere
{values.<name>}A value an earlier step savedeverywhere
{callback.<name>}A query parameter of the callbackexchange, save
{answer.<path>}A field of the exchange's JSON answer, such as {answer.owner.login}save
{secret.<NAME>}A tenant secret named with the connection's prefix, other than token.jwt.keyexchange, token request, check (its headers, basic auth and body, never a url or query)
{arguments.<name>}An argument of the callscopes[].resource
{state}, {callback_url}, {edge_url}, {events_url.<source>}The step's state; the edge's callback for this connection; the edge's address; one of this plugin's event sources' URLbrowser, exchange; all but {state} in a guided step's create and copy
{tenant}The tenant's nameeverywhere, and in a param's default
{paste.<name>}A field the admin pasted on this guided stepthat step's verify[].request
{pkce.challenge}, {pkce.verifier}The step's PKCE pair; the verifier only in the exchangebrowser, exchange

A value in a URL's path is escaped, so it cannot add a segment or a query; an expression at the very start of a URL, such as {params.api_url}, is the URL's base and must make an http or https address. oz0 plugin lint checks every template against where it is used.

A param in the address part of a URL the edge sends a credential to (an exchange or check that reads a {secret.*} or signs with token.jwt, the token or refresh endpoint) or that a token passes on is pinned on the connection when a step saves its secrets and when it is connected: the edge sends the credential, and hands out the token, only while the param is as pinned (or, before any step pinned it, at its default).

Changing it means connecting again from the first step, so give such a param a default that is your service's own address. A token set by hand goes with such a param only at its default, or where the tenant bound the secret (secret set --host). A check that sends a pasted field marked secret: to an address that is not the default runs only once whoever pastes confirms that address on the step's page. A saved value's name never has a colon: names with one are the edge's own.

Rules the edge keeps

  • What a step saves that is secret goes only to the edge, which seals it; management and the console see the names, never the values.
  • A connection reads and writes only tenant secrets named with its prefix, never the gateway's keys or another plugin's. Prefixes nest (GITHUB_ holds GITHUB_APP_), so the install refuses a connection whose prefix starts, or starts with, another plugin's connection's prefix, and one that names a secret another plugin's connection names; the edge refuses the same secrets at every use. Pick a name that is yours, such as your plugin's name.
  • A connection belongs to the plugin that declares it: two plugins of a tenant may not declare one name, and a plugin installed later may not take over a connection another plugin connected until an admin deletes it.
  • What a connection's steps save reaches no other plugin's nodes, whatever their requires.secrets say, and its JWT key reaches no node at all: plugin lint refuses requires.secrets naming token.jwt.key. A saved secret your own tool reads, such as a bot token, goes in your requires.secrets as usual.
  • A scope with a resource gives a token only for the resource the call's arguments name: a call that names none gets no token, never one for every resource.
  • The edge's exchanges and token requests reach public addresses over https only (any address in dev mode, and the networks a cluster admin lists with server start --connect-allow-net); they follow no redirect.
  • PKCE is S256 or nothing: plugin lint refuses pkce: plain, a code_challenge_method other than S256 in the browser's query, and {pkce.challenge} in a step without pkce: S256. Use PKCE wherever the provider takes it; the verifier never goes through the browser.
  • The callback takes a state the edge signed, for 15 minutes, once. A guided step's form carries the same state, which the paste spends and renews, so a form sent twice saves once.
  • What an admin pastes on a guided step reaches only the edge: it is never shown again, never logged, and never in the API, the audit trail (which names the secrets, not their values) or a job's history.
  • A node gets a token only for a call of a tool of a plugin it runs, and only for a function a scope lists. The one exception is the listing of a remote tool on the connection, which the node takes once when it starts, without a call (a few a node may ask for an hour).
  • A remote tool's host that comes from a param is the host the connection was connected for: a changed param gets no token until the connection is connected again (for a connection without steps, until its token's secret is set again), and a host that resolves to a private address gets none unless the cluster's admin lists its network.
Copyright © 2026