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:
bodyfor JSON metadatastreamfor 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
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.
-
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:latestThe packager is now running on port
39575. -
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 packagestream, 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.oappAlways ensure that a
200status code was returned, otherwisemy.oappwill contain an error message. -
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.oappThis confirms that the file is a valid package and shows the package manifest.
-
You can now reference the
my.oappfile in the runtime bootstrap:[
{
"deploymentId": "this-is-my-app",
"remoteConfiguration": "my.oapp",
"packaging": {
"packageId": "my-package"
}
}
]remoteConfigurationreferences the.oappfile.The package will not load if
packaging.packageIddoes not match the packagedpackageId.
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
envelopesarray describing each packaged layer
Envelope fields:
| Field | Meaning |
|---|---|
type | Envelope type, such as workflow or package |
payloadHash | Hash of the packaged payload |
payloadSize | Payload size in bytes |
payloadPeek | Short hex preview of the payload |
licenseSize | Embedded license payload size in bytes |
licensePeek | Short hex preview of the embedded license payload |
totalSize | Combined payload and license size |
signatures | Signature metadata including id, signText, signature, date, and signed hash |
licenses | Embedded 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:
bodycontains the JSON metadata for the new outer envelopestreamcontains the existing packaged file
The body supports these packaging fields:
packageIdkeyssignersencryptionKeyspackagerIdentityPrivateKey
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:
signauthenticatedanonymoussymmetric
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.
| Purpose | CryptoContext generator | Algorithm | Public key type | Secret key type | Public key bytes | Secret key bytes | Public key hex chars | Secret key hex chars |
|---|---|---|---|---|---|---|---|---|
sign | cryptoSignKeypair | Ed25519 signing keys | public | secret | 32 | 64 | 64 | 128 |
authenticated | cryptoBoxKeypair | X25519 key pair for authenticated public-key encryption (crypto_box) | public | secret | 32 | 32 | 64 | 64 |
anonymous | cryptoBoxKeypair | X25519 key pair for anonymous public-key encryption (crypto_box_seal) | public | secret | 32 | 32 | 64 | 64 |
symmetric | cryptoSecretBoxKeygen | XSalsa20-Poly1305 secret key (crypto_secretbox) | none | secret | none | 32 | none | 64 |
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:
requiredSignersto declare which signatures must be present and validadditionalSignerswithrequiredAdditionalSignerCount- this allows you to define a pool of permitted
additionalSignersand set the minimum number that must match
- this allows you to define a pool of permitted
[
{
"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 keyidencryptionKeys, whose values must match the recipient public keyidvalues
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.packagerIdentitieslisting 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 loadcustomizations: 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 configuredworkflowSourceIdscustomizations: optional changes to apply before the included workflows are merged
Each customization object supports:
op: one ofvalue,add, ordeletesourcePath: a JSONPath-like path beginning with$sourceValue: required forvalueandadd
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:
sourceWorkflowmust be a relative path and may not contain..- the packager reads workflow files from the ids listed in
bootstrap.data.workflowSourceIds addappends to an existing array, replaces an existing non-array value, or creates the property if it does not existdeleteremoves 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