Skip to main content
Version: Next

Packager

Opscotch is secure by design: configurations are deployed to the agent in encrypted form.

The opscotch packager exposes HTTP endpoints for:

  • packaging workflows
  • inspecting packaged artifacts
  • adding signatures, encryption, and embedded licenses
  • encrypting bootstrap files and authenticated host strings
  • building workflows from a manifest

All packaging operations are performed via an HTTP API.

Running the Packager

The packager is an opscotch application and runs in an opscotch runtime instance.

There are two ways to run the packager:

  • as a self-contained Docker container
  • as an opscotch application in your own runtime

To use the self-contained Docker container, refer to these instructions

To use the packager as an application in your own runtime, refer to these instructions

Request Formats

The endpoints follow a small number of request formats:

  • workflow packaging, bootstrap encryption, and string encryption use multipart form uploads
  • multipart requests use:
    • body for JSON metadata
    • stream for the uploaded file or plaintext input
  • inspection uploads a packaged file as binary request data and returns JSON
  • workflow manifest building sends JSON in the request body and returns JSON

Resources

Packaged workflows often reference JavaScript resource files.

For example: "resource" : "/a/sample/resource.js"

During packaging, these files are resolved and their contents are loaded into the packaged workflow, so packaged workflows do not depend on resource files at runtime.

If you are using the standalone Docker image, you do not need to change the bootstrap for resources. The important step is to mount your resource directories into the container using the expected paths, as shown in the Docker example later in this page:

  • /opscotch-community-resources
  • /local-resources1
  • /local-resources2

If you are running the packager as an application in your own runtime, resource directories are declared in the packager bootstrap as allowFileAccess entries. The following snippet shows only the relevant fields:

"allowFileAccess": [
{
"id": "communityResources",
"directoryOrFile": "/opscotch-community-resources",
"READ": true,
"LIST": true
},
{
"id": "localResources1",
"directoryOrFile": "/local-resources1",
"READ": true,
"LIST": true
},
{
"id": "localResources2",
"directoryOrFile": "/local-resources2",
"READ": true,
"LIST": true
}
],

A data entry then maps those allowFileAccess entries as resource directories:

"data": {
"resourceIds": [
"communityResources",
"localResources1",
"localResources2"
]
}

All resources referenced in the workflow are resolved relative to the mapped resourceIds directories, in the order listed in resourceIds.

For example, "resource" : "/a/sample/resource.js" is resolved by testing for the first match:

  • /opscotch-community-resources/a/sample/resource.js
  • /local-resources1/a/sample/resource.js
  • /local-resources2/a/sample/resource.js

Opscotch community resources are used throughout many examples and are recommended for your own workflows. Make them available to the packager as part of the setup in Running the Packager.

Quickstart: Package and Inspect a Workflow

Caution

This example is intended for education and experimentation. It is a simple way to install the packager, run it, and produce an end-to-end package, but it does not apply cryptographic assurance and is not intended for actual deployment.

See the later sections for signing, encryption, and embedded licenses.

This example uses the standalone Docker image.

  1. Start the packager:

    docker run \
    -v "/local/opscotch-community/resources:/opscotch-community-resources" \
    -v "/local-app-resources:/local-resources1" \
    -p "39575:39575" \
    ghcr.io/opscotch/opscotch-packager-standalone:latest

    The packager is now running on port 39575.

  2. In this example, the workflow being packaged is my-app.config.json.

    The request contains two parts:

    • body, which contains the JSON metadata for the package
    • stream, which contains the workflow file to package

    The packaged output is my.oapp:

    curl "localhost:39575/workflow-package" \
    -H "Accept: application/octet-stream" \
    -F 'body={ "packageId": "my-package" }' \
    -F 'stream=@my-app.config.json;type=application/octet-stream' \
    -v --output my.oapp

    Always ensure that a 200 status code was returned, otherwise my.oapp will contain an error message.

  3. Inspect the package that was just produced:

    curl "localhost:39575/workflow-inspect" \
    -H "Content-Type: application/octet-stream" \
    -H "Accept: application/json" \
    --data-binary @my.oapp

    This confirms that the file is a valid package and shows the package manifest.

  4. You can now reference the my.oapp file in the runtime bootstrap:

    [
    {
    "deploymentId": "this-is-my-app",
    "remoteConfiguration": "my.oapp",
    "packaging": {
    "packageId": "my-package"
    }
    }
    ]

    remoteConfiguration references the .oapp file.

    The package will not load if packaging.packageId does not match the packaged packageId.

Inspecting Packages

Use /workflow-inspect to read the public package manifest from any supported package.

Inspect returns the package manifest as JSON. It contains public metadata only and does not expose encrypted workflow contents or private keys.

The manifest is written into the package when the package is created. It includes:

  • top-level package metadata such as packageId, when that property is stored in the manifest
  • an envelopes array describing each packaged layer

Envelope fields:

FieldMeaning
typeEnvelope type, such as workflow or package
payloadHashHash of the packaged payload
payloadSizePayload size in bytes
payloadPeekShort hex preview of the payload
licenseSizeEmbedded license payload size in bytes
licensePeekShort hex preview of the embedded license payload
totalSizeCombined payload and license size
signaturesSignature metadata including id, signText, signature, date, and signed hash
licensesEmbedded license metadata when licenses are present

If a package contains another packaged envelope, the manifest can contain multiple envelopes entries.

Call the endpoint like this:

curl "localhost:39575/workflow-inspect" \
-H "Content-Type: application/octet-stream" \
-H "Accept: application/json" \
--data-binary @my.oapp -v

Example response:

{
"packageId": "my-package",
"envelopes": [
{
"type": "workflow",
"payloadHash": "3f7d...",
"payloadSize": 18234,
"payloadPeek": "7b22636f6d6d656e74223a22...",
"licenseSize": 1,
"licensePeek": "00",
"totalSize": 18235,
"signatures": [
{
"id": "my-sign-key",
"signText": "My signing key",
"signature": "ab12...",
"date": "2026-03-23T01:23:45.678Z",
"hash": "cd34..."
}
]
}
]
}

If the uploaded file is not a supported package version, the inspect endpoint returns a JSON error object.

Workflow Endpoints

In addition to /workflow-package, the packager exposes helper endpoints for compiling, hashing, and re-enveloping packages.

Compile a Workflow

Use /workflow-compile to validate a workflow, remove comments, and load referenced resources into the returned workflow JSON.

Unlike /workflow-package, this endpoint accepts the workflow JSON directly as the request body and returns JSON.

curl "localhost:39575/workflow-compile" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
--data-binary @my-app.config.json

The response is the compiled workflow JSON after preprocessing.

Hash a Workflow

Use /workflow-hash to calculate the workflow hash after validation, comment removal, and resource loading.

This is essential when you need to know whether the source code under review matches the source code that generated a package. It is particularly useful during review and approval processes, because the hash can be compared with the workflowHash recorded in the package manifest.

This endpoint also accepts the workflow JSON directly as the request body and returns JSON:

curl "localhost:39575/workflow-hash" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
--data-binary @my-app.config.json

Example response:

{
"hash": "3f7d..."
}

Add an Envelope Around an Existing Package

Use /package-envelope to add a new outer package envelope around an existing package. This lets you sign, encrypt, or assign a new top-level packageId to an already packaged artifact.

The request uses multipart form upload:

  • body contains the JSON metadata for the new outer envelope
  • stream contains the existing packaged file

The body supports these packaging fields:

  • packageId
  • keys
  • signers
  • encryptionKeys
  • packagerIdentityPrivateKey

Example:

curl "localhost:39575/package-envelope" \
-H "Accept: application/octet-stream" \
-F 'body={
"packageId": "my-enveloped-package",
"keys": [
{
"id": "my-packager-identity",
"purpose": "authenticated",
"type": "secret",
"keyHex": "D1F8F5CD.....C1BE74A01E"
},
{
"id": "recipient-1",
"purpose": "authenticated",
"type": "public",
"keyHex": "1F89CF2874...8C9AED0"
},
{
"id": "my-sign-key",
"purpose": "sign",
"type": "secret",
"keyHex": "8C9AED0...1F89CF2874"
}
],
"signers": [
{
"key": "my-sign-key",
"signText": "My signing key"
}
],
"packagerIdentityPrivateKey": "my-packager-identity",
"encryptionKeys": [
"recipient-1"
]
}' \
-F 'stream=@my.oapp;type=application/octet-stream' \
-v --output my-enveloped.oapp

The response is the newly enveloped package with the additional signatures, encryption, and other outer package metadata applied.

Package Security Options

The opscotch packager can add the following cryptographic protections:

  • multiple package signatures
  • mutually authenticated encryption, where the encrypting and decrypting parties are both known and asserted by the packager
  • additional signature endorsements
  • embedded licenses

Use these features as follows:

  • use signatures to prove package integrity and signer identity
  • use authenticated encryption to restrict which deployments can open a package
  • use embedded licenses when the deployed runtime should not need to supply a license
  • use bootstrap and string encryption for secrets that should not be stored in plaintext

The smallest valid package request body is:

{
"packageId": "my-package"
}

A package key object has this shape:

{
"id": "<id>",
"type": "<public/secret>",
"purpose": "<sign/authenticated/symmetric/anonymous>",
"keyHex": "<key hex>"
}

Generate Keys

The packager exposes a key generation endpoint at /key-gen.

Call it with a purpose query parameter:

  • sign
  • authenticated
  • anonymous
  • symmetric

Examples:

curl "localhost:39575/key-gen?purpose=sign"
curl "localhost:39575/key-gen?purpose=authenticated"

Example response for purpose=sign:

{
"purpose": "sign",
"publicKeyType": "public",
"secretKeyType": "secret",
"publicKey": "D01FF58C9AE......F28741F89C",
"secretKey": "8C9AED0...1F89CF2874"
}

Example response for purpose=symmetric:

{
"purpose": "symmetric",
"publicKeyType": null,
"secretKeyType": "secret",
"publicKey": null,
"secretKey": "A1B2C3D4..."
}

The returned values are hex encoded. In the implementation, /key-gen maps each purpose to the underlying cryptographic primitive exposed by CryptoContext.

PurposeCryptoContext generatorAlgorithmPublic key typeSecret key typePublic key bytesSecret key bytesPublic key hex charsSecret key hex chars
signcryptoSignKeypairEd25519 signing keyspublicsecret326464128
authenticatedcryptoBoxKeypairX25519 key pair for authenticated public-key encryption (crypto_box)publicsecret32326464
anonymouscryptoBoxKeypairX25519 key pair for anonymous public-key encryption (crypto_box_seal)publicsecret32326464
symmetriccryptoSecretBoxKeygenXSalsa20-Poly1305 secret key (crypto_secretbox)nonesecretnone32none64

The packaging APIs validate that supplied keys match the required type and length. For example:

  • signing uses a secret signing key during packaging and matching public signing keys at runtime
  • authenticated encryption uses an authenticated secret key for the packager identity and authenticated public keys for recipients
  • symmetric keys are secret-only and do not have a public key

You can also bring your own keys instead of using /key-gen, provided they are valid for the intended purpose, use the correct type, and are supplied as hex in the required length.

Add Signatures to a Package

Packages can be signed with multiple private keys, which allows the opscotch runtime to verify the signatures against the matching public keys supplied in the bootstrap.

Secret signing keys are used at packaging time; matching public keys are used at runtime.

During packaging, provide secret signing keys and a signers list:

{
"packageId": "my-package",
"keys": [
{
"id": "my-sign-key",
"type": "secret",
"purpose": "sign",
"keyHex": "8C9AED0...1F89CF2874"
},
{
"id": "another-sign-key",
"type": "secret",
"purpose": "sign",
"keyHex": "1F89CF2874...8C9AED0"
}
],
"signers": [
{
"key": "my-sign-key",
"signText": "My signing key"
},
{
"key": "another-sign-key",
"signText": "Another signing key"
}
]
}

In the runtime bootstrap, supply the matching public keys and either:

  • requiredSigners to declare which signatures must be present and valid
  • additionalSigners with requiredAdditionalSignerCount
    • this allows you to define a pool of permitted additionalSigners and set the minimum number that must match
[
{
"deploymentId": "this-is-my-app",
"remoteConfiguration": "my.oapp",
"packaging": {
"packageId": "my-package",
"requiredSigners": [
{
"id": "my-sign-key",
"description": "My signing key"
}
],
"additionalSigners": [
{
"id": "another-sign-key",
"description": "Another signing key"
},
{
"id": "some-other-sign-key",
"description": "Some other signing key"
}
],
"requiredAdditionalSignerCount": 1
},
"keys": [
{
"id": "my-sign-key",
"type": "public",
"purpose": "sign",
"keyHex": "D01FF58C9AE......F28741F89C"
},
{
"id": "another-sign-key",
"type": "public",
"purpose": "sign",
"keyHex": "6D9841078........D23BA3F156"
},
{
"id": "some-other-sign-key",
"type": "public",
"purpose": "sign",
"keyHex": "5912C087D6C9......BBA5927A760F9"
}
]
}
]

Now the runtime will only load the app if my-sign-key is present and valid and either another-sign-key or some-other-sign-key is present and valid.

Encrypt a Workflow for Specific Deployments

The opscotch packager can encrypt a workflow so that only selected recipients can open it. The package is readable only by those recipients, and the other recipients are not disclosed in the package.

The packager uses authenticated public-key encryption, where the recipients' public keys are known to the packager and the packager's public key is known to the recipient. The absence of either will render the package unreadable.

During packaging, provide:

  • an authenticated secret key in keys
  • one or more recipient authenticated public keys in keys
  • packagerIdentityPrivateKey, which must match the encrypting secret key id
  • encryptionKeys, whose values must match the recipient public key id values

Example:

{
"packageId": "my-package",
"keys": [
{
"id": "encrypter-identity",
"purpose": "authenticated",
"type": "secret",
"keyHex": "D1F8F5CD.....C1BE74A01E"
},
{
"id": "recipient-1",
"type": "public",
"purpose": "authenticated",
"keyHex": "1F89CF2874...8C9AED0"
},
{
"id": "recipient-2",
"type": "public",
"purpose": "authenticated",
"keyHex": "8C9AED0....1F89CF2874"
}
],
"packagerIdentityPrivateKey": "encrypter-identity",
"encryptionKeys": [
"recipient-1",
"recipient-2"
]
}

At runtime, the bootstrap provides:

  • the packager identity as an authenticated public key
  • one recipient identity as an authenticated secret key
  • packaging.packagerIdentities listing the permitted packager public key ids

The following example is a complete bootstrap record:

[
{
"deploymentId": "this-is-my-app",
"remoteConfiguration": "my.oapp",
"packaging": {
"packageId": "my-package",
"packagerIdentities": [ "encrypter-identity" ]
},
"keys": [
{
"id": "encrypter-identity",
"purpose": "authenticated",
"type": "public",
"keyHex": "C1BE74A01E.....D1F8F5CD"
},
{
"id": "recipient-1",
"type": "secret",
"purpose": "authenticated",
"keyHex": "8C9AED0......1F89CF2874"
}
]
}
]

Now the package is only openable by selected recipients.

Embed a License in a Package

You can embed a license in a package, which means the deployed runtime does not need to supply a license for this package.

Opscotch licenses are runtime specific: only a production license can be used in a production runtime, and a non-prod license can only be used in a non-prod runtime.

You can, however, embed multiple licenses in a package:

{
"packageId": "my-package",
"licenses": [
{
"name": "embedded-non-prod",
"base64License": "AwJiAR+LC...n1kmlF/BM/E4WpU"
},
{
"name": "embedded-prod",
"base64License": "AwJhAR+LCqVCP....FBZ2oOZYDc="
}
]
}

Secret Encryption Endpoints

Encrypt a Bootstrap

Bootstrap files can be encrypted with a call similar to workflow packaging. The uploaded bootstrap file must still be a valid bootstrap document that matches the bootstrap schema.

curl "localhost:39575/bootstrap-encrypt" \
-H "Accept: application/octet-stream" \
-F 'body=
{
"keys" : [
{
"id": "encrypter-identity",
"purpose": "authenticated",
"type": "secret",
"keyHex": "D1F8F5CD.....C1BE74A01E"
},
{
"id": "recipient-1",
"type": "public",
"purpose": "authenticated",
"keyHex": "1F89CF2874...8C9AED0"
}
],
"packagerIdentityPrivateKey" : "encrypter-identity",
"encryptionKeys" : [
"recipient-1"
]
}' \
-F 'stream=@bootstrap.json;type=application/octet-stream' \
-v

The response is a base64 encoded encrypted bootstrap file.

To decrypt an encrypted bootstrap file, the decryption keys must be supplied via secret or environment variable OPSCOTCH_BOOTSTRAP_SECRETKEY.

The variable value must be in this format: <senderPublicKeyHex>/<decryptPrivateKeyHex>

Encrypt an Authenticated Host Data String

/string-encrypt supports exactly one recipient. The encryptionKeys array must contain exactly one key id.

echo secret | curl "localhost:39575/string-encrypt" \
-H "Accept: text/plain" \
-F 'body={
"keys" : [
{
"id": "encrypter-identity",
"purpose": "authenticated",
"type": "secret",
"keyHex": "D1F8F5CD.....C1BE74A01E"
},
{
"id": "recipient-1",
"type": "public",
"purpose": "authenticated",
"keyHex": "1F89CF2874...8C9AED0"
}
],
"packagerIdentityPrivateKey" : "encrypter-identity",
"encryptionKeys" : [
"recipient-1"
]
}' \
-F 'stream=@-;type=application/octet-stream;type=application/octet-stream'

The response is an encoded string. In current packager versions this is a versioned prefix followed by base64 data.

To decrypt an encrypted string, the decryption keys must be supplied via secret or environment variable OPSCOTCH_STRING_SECRETKEYS.

Because a bootstrap can contain multiple encrypted strings, potentially encrypted with different keys, the OPSCOTCH_STRING_SECRETKEYS variable accepts a list of <senderPublicKeyHex>/<decryptPrivateKeyHex> values.

The variable value must be in this format: <senderPublicKeyHex>/<decryptPrivateKeyHex>,<senderPublicKeyHex>/<decryptPrivateKeyHex>,...

Build a Workflow from a Manifest

If you have multiple workflow files and want to merge them into a single workflow file, you can use a workflow manifest.

The manifest specifies which workflow files to include and which transformations to apply.

Setup

To enable the manifest feature, add bootstrap allowFileAccess entries for the locations that contain the workflows, and map them using data.workflowSourceIds. The following snippet shows only the relevant bootstrap fields:

{
"allowFileAccess": [
{
"id": "workflows1",
"directoryOrFile": "/my-workflows1",
"READ": true,
"LIST": true
},
{
"id": "workflows2",
"directoryOrFile": "/my-workflows2",
"READ": true,
"LIST": true
}
],
"data": {
"workflowSourceIds": [
"workflows1",
"workflows2"
]
}
}

If you are using the Docker version, this has already been done and can be set via mounts:

    -v /my-workflows1:/workflow-compare1 \
-v /my-workflows2:/workflow-compare2 \

Call the Endpoint

To produce a workflow from a manifest, call /build-workflows.

The request body is a JSON manifest with:

  • includes: required list of workflow files to load
  • customizations: optional changes to apply after all included workflows are merged

Each include object requires:

  • sourceWorkflow: path to the workflow file relative to one of the configured workflowSourceIds
  • customizations: optional changes to apply before the included workflows are merged

Each customization object supports:

  • op: one of value, add, or delete
  • sourcePath: a JSONPath-like path beginning with $
  • sourceValue: required for value and add

Example: merge two workflow files using the workflowSourceIds configured in the bootstrap:

{
"includes": [
{
"sourceWorkflow": "orders/workflow.json"
},
{
"sourceWorkflow": "payments/workflow.json"
}
]
}

Example: apply customizations to an included workflow and to the final merged result:

{
"includes": [
{
"sourceWorkflow": "orders/workflow.json",
"customizations": [
{
"op": "value",
"sourcePath": "$.workflows[0].steps[0].trigger.timer.period",
"sourceValue": 900000
},
{
"op": "add",
"sourcePath": "$.data.tags",
"sourceValue": "orders"
}
]
},
{
"sourceWorkflow": "payments/workflow.json"
}
],
"customizations": [
{
"op": "value",
"sourcePath": "$.comment",
"sourceValue": "This file is generated from a manifest"
}
]
}

You can call the endpoint like this:

curl "http://localhost:39575/build-workflows" \
-H "Content-Type: application/json" \
--data-binary @manifest.json

The response is a JSON workflow file that contains the merged workflows array and any merged data entries.

Notes:

  • sourceWorkflow must be a relative path and may not contain ..
  • the packager reads workflow files from the ids listed in bootstrap.data.workflowSourceIds
  • add appends to an existing array, replaces an existing non-array value, or creates the property if it does not exist
  • delete removes an existing property or array item
  • the output file always includes the generated comment THIS FILE IS MANAGED BY THE OPSCOTCH MANIFEST PACKAGER - DO NOT EDIT DIRECTLY