Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions docs/guides/Expressions.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
title: "Expressions"
sidebar:
order: 2
---

SuperPlane components evaluate expressions to compute dynamic field values, route events, and reference data from other nodes in the canvas.

## Where expressions are evaluated

Any text or expression field on a component accepts the standard `{{ ... }}` placeholder syntax. At execution time, every placeholder is replaced with the value of the expression inside. The same expression body (without the surrounding braces) is also used wherever a component asks for an expression directly (for example the Filter or If condition).

```
https://api.example.com/repos/{{ $['GitHub PR'].data.repository }}
```

## Expression environment

Inside an expression you have access to:

- `$` — the run context. Use `$['Node Name'].data.foo` to read fields emitted by an upstream node. The literal `$` alone resolves to the full run context map.
- `root()` — the event that started the current run.
- `previous()` — the payload from the immediate predecessor that emitted into the current node. Optionally pass an integer depth (`previous(2)`) to walk further upstream.
- `secrets("name")` — an organization secret, returned as a map of its keys. See [Secrets in expressions](#secrets-in-expressions) below.

Standard helpers such as `date`, `duration`, `now`, `timezone`, and `int` are also available.

## Secrets in expressions

Use `secrets("name").key` inside any expression to inject the value of an organization secret. For example:

```
{{ secrets("api").token }}
```

resolves to the value stored under the `token` key of the `api` secret.

Secrets resolved this way:

- Are looked up at execution time, so each run picks up the latest value of the secret.
- Are never written back into the saved component configuration. Only the expression `secrets("api").token` is persisted; the actual token only exists in memory during execution.
- Must select a specific key. Embedding the whole secret (`secrets("api")` without a key) is rejected so the entire decrypted secret cannot leak into a URL, header, payload, or log.

If a referenced secret or key does not exist at execution time, the run fails with an error identifying the missing secret.
Comment thread
cursor[bot] marked this conversation as resolved.
9 changes: 5 additions & 4 deletions pkg/components/merge/merge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/superplanehq/superplane/pkg/crypto"
"github.com/superplanehq/superplane/pkg/database"
"github.com/superplanehq/superplane/pkg/models"
"github.com/superplanehq/superplane/pkg/workers/contexts"
Expand Down Expand Up @@ -525,7 +526,7 @@ func (s *MergeTestSteps) CreateSingleQueueItemForProcess1() {
func (s *MergeTestSteps) ProcessFirstEvent(m *Merge) {
fmt.Println("Processing first event")

ctx1, err := contexts.BuildProcessQueueContext(http.DefaultClient, s.Tx, s.MergeNode, s.QueureItem1, nil, nil, nil)
ctx1, err := contexts.BuildProcessQueueContext(http.DefaultClient, crypto.NewNoOpEncryptor(), s.Tx, s.MergeNode, s.QueureItem1, nil, nil, nil)
assert.NoError(s.t, err)

execution, err := m.ProcessQueueItem(*ctx1)
Expand All @@ -538,7 +539,7 @@ func (s *MergeTestSteps) ProcessFirstEvent(m *Merge) {
func (s *MergeTestSteps) ProcessFirstEventExpectFinish(m *Merge) {
fmt.Println("Processing first event (expect finish)")

ctx1, err := contexts.BuildProcessQueueContext(http.DefaultClient, s.Tx, s.MergeNode, s.QueureItem1, nil, nil, nil)
ctx1, err := contexts.BuildProcessQueueContext(http.DefaultClient, crypto.NewNoOpEncryptor(), s.Tx, s.MergeNode, s.QueureItem1, nil, nil, nil)
assert.NoError(s.t, err)

execution, err := m.ProcessQueueItem(*ctx1)
Expand All @@ -549,7 +550,7 @@ func (s *MergeTestSteps) ProcessFirstEventExpectFinish(m *Merge) {
func (s *MergeTestSteps) ProcessSecondEvent(m *Merge) {
fmt.Println("Processing second event")

ctx2, err := contexts.BuildProcessQueueContext(http.DefaultClient, s.Tx, s.MergeNode, s.QueureItem2, nil, nil, nil)
ctx2, err := contexts.BuildProcessQueueContext(http.DefaultClient, crypto.NewNoOpEncryptor(), s.Tx, s.MergeNode, s.QueureItem2, nil, nil, nil)
assert.NoError(s.t, err)

execution, err := m.ProcessQueueItem(*ctx2)
Expand All @@ -560,7 +561,7 @@ func (s *MergeTestSteps) ProcessSecondEvent(m *Merge) {
func (s *MergeTestSteps) ProcessSecondEventExpectNoFinish(m *Merge) {
fmt.Println("Processing second event")

ctx2, err := contexts.BuildProcessQueueContext(http.DefaultClient, s.Tx, s.MergeNode, s.QueureItem2, nil, nil, nil)
ctx2, err := contexts.BuildProcessQueueContext(http.DefaultClient, crypto.NewNoOpEncryptor(), s.Tx, s.MergeNode, s.QueureItem2, nil, nil, nil)
assert.NoError(s.t, err)

execution, err := m.ProcessQueueItem(*ctx2)
Expand Down
14 changes: 14 additions & 0 deletions pkg/configuration/expressionvalidation/canvas_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,20 @@ func TestValidateNodeExpressions_NestedPaths(t *testing.T) {
}, fields, knownSet())
assertOneError(t, errs, "extra.unknown", "unknown node reference 'Missing'")
})

t.Run("secrets() calls validate as ordinary expressions", func(t *testing.T) {
fields := []configuration.Field{
{Name: "url", Type: configuration.FieldTypeString},
{Name: "text", Type: configuration.FieldTypeText},
}
errs := validateNodeExpressions("n1", "HTTP", map[string]any{
"url": `https://api.example.com/?token={{ secrets("api").token }}`,
"text": `Bearer {{ secrets("svc").key }} for {{ $['Build'].user }}`,
}, fields, knownSet("Build"))
if len(errs) != 0 {
t.Fatalf("expected no errors, got %+v", errs)
}
})
}

func TestValidateNodeExpressions_Aggregation(t *testing.T) {
Expand Down
10 changes: 10 additions & 0 deletions pkg/configuration/expressionvalidation/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,15 @@ func checkTopLevelCall(name string, args []ast.Node) error {
return fmt.Errorf("previous() depth must be an integer literal")
}
}
case "secrets":
if len(args) != 1 {
return fmt.Errorf("secrets() takes exactly one argument (secret name), got %d", len(args))
}
switch args[0].(type) {
case *ast.StringNode, *ast.IdentifierNode, *ast.MemberNode, *ast.CallNode:
default:
return fmt.Errorf("secrets() argument must be a string")
}
}
return nil
}
Expand Down Expand Up @@ -173,6 +182,7 @@ func compileWithStubEnv(body string, knownNodeNames map[string]struct{}, extraEn
exprruntime.DateFunctionOption(),
expr.Function("root", func(params ...any) (any, error) { return nil, nil }),
expr.Function("previous", func(params ...any) (any, error) { return nil, nil }),
expr.Function("secrets", func(params ...any) (any, error) { return map[string]string{}, nil }),
}

if _, err := expr.Compile(body, opts...); err != nil {
Expand Down
6 changes: 6 additions & 0 deletions pkg/configuration/expressionvalidation/validator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ func TestValidateExpression_Valid(t *testing.T) {
{name: "date builtin", raw: `date('2026-01-01')`},
{name: "type conversion", raw: `int($['Build'].count) + 1`, knownNames: []string{"Build"}},
{name: "standalone dollar", raw: `$`},
{name: "secrets call", raw: `secrets('api').token`},
{name: "secrets in concat", raw: `'Bearer ' + secrets('api').token`},
{name: "secrets dashed name", raw: `secrets('my-api').token`},
})
}

Expand Down Expand Up @@ -86,6 +89,9 @@ func TestValidateExpression_BadArity(t *testing.T) {
{name: "memory.find no args", raw: `memory.find()`, wantErr: "memory.find() requires a namespace and matches"},
{name: "memory.findFirst no args", raw: `memory.findFirst()`, wantErr: "memory.findFirst() requires a namespace and matches"},
{name: "memory.find too many", raw: `memory.find('ns', {}, 'extra')`, wantErr: "memory.find() requires a namespace and matches"},
{name: "secrets no args", raw: `secrets()`, wantErr: "secrets() takes exactly one argument"},
{name: "secrets too many", raw: `secrets('a', 'b')`, wantErr: "secrets() takes exactly one argument"},
{name: "secrets int argument", raw: `secrets(1)`, wantErr: "secrets() argument must be a string"},
})
}

Expand Down
1 change: 1 addition & 0 deletions pkg/grpc/actions/canvases/invoke_node_trigger_hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ func InvokeNodeTriggerHook(
"parameters": expressionParameters,
}).
WithConfigurationFields(hookProvider.Configuration()).
WithSecretResolver(contexts.NewRuntimeSecretResolver(tx, encryptor, models.DomainTypeOrganization, orgID)).
Build(contexts.WithoutRunTitleConfiguration(node.Configuration.Data()))
if err != nil {
return nil, grpcerrors.InvalidArgument(err, "failed to resolve trigger configuration")
Expand Down
2 changes: 1 addition & 1 deletion pkg/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ func startWorkers(
if os.Getenv("START_WORKFLOW_NODE_QUEUE_WORKER") == "yes" || os.Getenv("START_NODE_QUEUE_WORKER") == "yes" {
log.Println("Starting Node Queue Worker")

w := workers.NewNodeQueueWorker(registry, gitProvider, rabbitMQURL)
w := workers.NewNodeQueueWorker(encryptor, registry, gitProvider, rabbitMQURL)
go w.Start(context.Background())
}

Expand Down
Loading