---
title: "Connection setup and security"
description: "Build guided connection steps and understand edge enforcement."
canonical_url: "https://docs.oz0.ai/build/parts/connections/setup-and-security"
---
# 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.

```yaml [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.
```

<table>
<thead>
  <tr>
    <th>
      Field
    </th>
    
    <th>
      Meaning
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        create
      </code>
    </td>
    
    <td>
      A link: <code>
        label
      </code>
      
      , <code>
        url
      </code>
      
      , <code>
        query
      </code>
      
      , and <code>
        json_query
      </code>
      
      , parameters whose value is a JSON document made from the object and URL-encoded, as <code>
        json_fields
      </code>
      
       does for a form. It opens in a new tab. Its templates read params, values, <code>
        {callback_url}
      </code>
      
      , <code>
        {edge_url}
      </code>
      
      , <code>
        {events_url.&lt;source>}
      </code>
      
       and <code>
        {tenant}
      </code>
      
      : never a secret.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        copy[]
      </code>
    </td>
    
    <td>
      <code>
        label
      </code>
      
       and <code>
        value
      </code>
      
      : 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.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        paste[]
      </code>
    </td>
    
    <td>
      Named inputs saved as secrets, connection values or both. At most 8 fields, 4096 bytes each. See <a href="#pasted-fields">
        field validation
      </a>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        verify[]
      </code>
    </td>
    
    <td>
      Requests or signed deliveries verify the pasted values. At most 4 checks, including one delivery. See <a href="#guided-verification-checks">
        check timing and credentials
      </a>
      
      .
    </td>
  </tr>
</tbody>
</table>

### 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:

```yaml [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.

<table>
<thead>
  <tr>
    <th>
      Expression
    </th>
    
    <th>
      What
    </th>
    
    <th>
      Where
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        {params.&lt;name>}
      </code>
    </td>
    
    <td>
      A param, or its default
    </td>
    
    <td>
      everywhere
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        {values.&lt;name>}
      </code>
    </td>
    
    <td>
      A value an earlier step saved
    </td>
    
    <td>
      everywhere
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        {callback.&lt;name>}
      </code>
    </td>
    
    <td>
      A query parameter of the callback
    </td>
    
    <td>
      exchange, save
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        {answer.&lt;path>}
      </code>
    </td>
    
    <td>
      A field of the exchange's JSON answer, such as <code>
        {answer.owner.login}
      </code>
    </td>
    
    <td>
      save
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        {secret.&lt;NAME>}
      </code>
    </td>
    
    <td>
      A tenant secret named with the connection's prefix, other than <code>
        token.jwt.key
      </code>
    </td>
    
    <td>
      exchange, token request, check (its headers, basic auth and body, never a <code>
        url
      </code>
      
       or <code>
        query
      </code>
      
      )
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        {arguments.&lt;name>}
      </code>
    </td>
    
    <td>
      An argument of the call
    </td>
    
    <td>
      <code>
        scopes[].resource
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        {state}
      </code>
      
      , <code>
        {callback_url}
      </code>
      
      , <code>
        {edge_url}
      </code>
      
      , <code>
        {events_url.&lt;source>}
      </code>
    </td>
    
    <td>
      The step's state; the edge's callback for this connection; the edge's address; one of this plugin's event sources' URL
    </td>
    
    <td>
      browser, exchange; all but <code>
        {state}
      </code>
      
       in a guided step's create and copy
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        {tenant}
      </code>
    </td>
    
    <td>
      The tenant's name
    </td>
    
    <td>
      everywhere, and in a param's <code>
        default
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        {paste.&lt;name>}
      </code>
    </td>
    
    <td>
      A field the admin pasted on this guided step
    </td>
    
    <td>
      that step's <code>
        verify[].request
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        {pkce.challenge}
      </code>
      
      , <code>
        {pkce.verifier}
      </code>
    </td>
    
    <td>
      The step's PKCE pair; the verifier only in the exchange
    </td>
    
    <td>
      browser, exchange
    </td>
  </tr>
</tbody>
</table>

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.


## Sitemap

See the full [sitemap](https://docs.oz0.ai/sitemap.md) for all pages.
