Files
ElsaWorkflow/CLAUDE.md
EugeneTes 2378a5ff78 Collapse NATS activity credentials into a single Connection string
Replaces separate Url/User/Password inputs on NatsPublish and NatsTrigger with
one Connection input in standard NATS URL form (e.g. nats://user:pass@host:4222,
wss://user:pass@host/). The activity's input description explains the format so
it renders as help text in the Studio property panel.

NatsConnectionCache now parses the connection string, keeps its cache keyed on
the parsed (url, user, password) tuple, and exposes ParseConnectionString for
reuse. NatsTriggerStimulus collapses to (Connection, Stream, Subject); existing
NATS workflows must be republished to re-index with the new stimulus shape.
2026-09-02 14:56:59 +00:00

240 lines
11 KiB
Markdown

# CLAUDE.md — Elsa custom server
Notes for future sessions working on this repo. Everything here is the result of
"we tried it, this is what actually worked". Read before touching versions,
authentication, or the Docker/Coolify wiring.
## What this repo is
Custom Elsa Workflows server + Studio, built as one .NET 9 container:
- `src/Elsa.CustomServer/` — ASP.NET Core host. Runs the Elsa server API and
serves the Studio WASM shell from the same origin. Custom activities live in
`Activities/` and are auto-registered by `AddActivitiesFrom<Program>()`.
- `src/Elsa.Studio.Wasm/` — Blazor WebAssembly Studio shell. Referenced as a
project so `dotnet publish` copies its `wwwroot/` into the server's output.
- `Dockerfile` — multi-stage .NET 9 build. Restores BOTH csprojs before copying
sources so the WASM restore lands in cache too.
- Deployed to Coolify as an `elsa` app at `https://elsa.tes.gd`,
UUID `u3qd22pn77pxyx4yf1p5ws2i`, build_pack=dockerfile, sourced from
`gitea.tes.gd/admin/ElsaWorkflow`.
## Adding a new activity (the whole flow)
1. Drop a `.cs` under `src/Elsa.CustomServer/Activities/`, inheriting from
`CodeActivity`, `CodeActivity<T>`, or `Activity` (the last for triggerable ones).
Decorate with `[Activity("Category", "Name", "Description", Kind = ActivityKind.Task)]`.
2. `git push`.
3. Fire a Coolify deploy — fire-and-forget, per global rules:
`curl -sS -X POST "$COOLIFY_URL/api/v1/deploy?uuid=$APP" -H "Authorization: Bearer $COOLIFY_KEY"`
4. Hard-refresh the browser (or open incognito — Blazor caches assets aggressively).
5. New activity appears in the Studio toolbox under its declared category.
No `AddActivity<T>()` needed — the assembly scan picks it up.
## Elsa 3.x package version pinning — the tricky part
Server packages and Studio packages have **diverged release trains**:
- **Server**: `Elsa`, `Elsa.Http`, `Elsa.Identity`, `Elsa.EntityFrameworkCore*`,
`Elsa.CSharp`, `Elsa.JavaScript`, `Elsa.Liquid`, `Elsa.Scheduling`,
`Elsa.Workflows.Api` — top out at **3.5.3** on nuget.org.
- **Studio**: `Elsa.Studio*` — at **3.7.0**.
Do NOT try to force everything to a single version — you'll get "package not
found". The 3.7 Studio talks to a 3.5 server just fine (protocol is
backward-compatible within v3).
Transitive Microsoft.AspNetCore.Components pins: Studio 3.7 pulls
`Elsa.Api.Client 3.7.0-rc1` → `Microsoft.Extensions.Http.Polly 9.0.13`, which
requires `Microsoft.AspNetCore.Components >= 9.0.13`. Pin those explicitly in
the WASM csproj or NuGet errors out on downgrade.
The `NU1603` warning about `Elsa.Api.Client 3.7.0-preview.4523 not found ->
3.7.0-rc1 resolved instead` is expected and benign — do not chase it.
## Authentication — pick ONE login stack, not both
Elsa Studio 3.7 ships two login modules that BOTH register a `/login` route.
Adding both raises `System.InvalidOperationException: The following routes are
ambiguous: 'login' in ...` on first WASM render.
- **Old (do not use)**: `Elsa.Studio.Login.BlazorWasm` + `AddLoginModule()` +
`Elsa.Studio.Login.HttpMessageHandlers.AuthenticatingApiHttpMessageHandler`.
- **New (use this)**: `Elsa.Studio.Authentication.ElsaIdentity.BlazorWasm` +
`AddElsaIdentity()` **and** `Elsa.Studio.Authentication.ElsaIdentity.UI` +
`AddElsaIdentityUI()` + `Elsa.Studio.Authentication.ElsaIdentity.HttpMessageHandlers.ElsaIdentityAuthenticatingApiHttpMessageHandler`.
You need BOTH `.BlazorWasm` (platform services / token accessor) AND `.UI`
(login page + unauthorized-redirect handler). Missing `.UI` = shell shows
"Unauthorized" with nowhere to go — no login screen.
## Admin credentials — the empty-hash trap
`Elsa.Identity` uses config-based users with `HashedPassword` + `HashedPasswordSalt`.
Leaving them as empty strings never validates — every login returns
`{"isAuthenticated":false}` even with the "right" password.
Reference well-known hashes for `admin` / `password` (from
`Elsa.ServerAndStudio.Web/appsettings.json` on the v3 branch of elsa-core):
```json
"HashedPassword": "TfKzh9RLix6FPcCNeHLkGrysFu3bYxqzGqduNdi8v1U=",
"HashedPasswordSalt": "JEy9kBlhHCNsencitRHlGxmErmSgY+FVyMJulCH27Ds="
```
Both are already in `appsettings.json`. To create a real user, generate a new
hash + salt through Elsa's password hasher and overwrite via env var
(`Identity__Users__0__HashedPassword`, `Identity__Users__0__HashedPasswordSalt`).
## Behind Coolify's Traefik — forwarded headers are mandatory
Traefik terminates TLS and forwards the request as plain HTTP. Without
configuring forwarded headers, Kestrel thinks it's serving HTTP and every
generated URL (Blazor's `apiUrl`, redirects, links) comes out as `http://`,
which the browser refuses to fetch from an HTTPS page ("Failed to fetch",
mixed-content errors).
Fix (already in `Program.cs`):
```csharp
builder.Services.Configure<ForwardedHeadersOptions>(o =>
{
o.ForwardedHeaders = ForwardedHeaders.All;
o.KnownNetworks.Clear(); // Traefik isn't in a known private range
o.KnownProxies.Clear();
});
// ...
app.UseForwardedHeaders(); // must be BEFORE UseRouting / UseAuthentication
```
Setting `ASPNETCORE_FORWARDEDHEADERS_ENABLED=true` as an env var does the same
thing but we prefer the code path so behaviour is source-controlled.
## SPA fallback — carve out `/elsa/api` so 404s stay 404
`MapFallbackToPage("/_Host")` catches EVERYTHING no other endpoint claims,
including unknown `/elsa/api/*` paths — which then return the Studio HTML with
a 200 status, confusing anything that expects JSON.
Fix (already in `Program.cs`):
```csharp
app.MapFallback("/elsa/api/{**rest}", () => Results.NotFound(new { error = "not_found" }));
app.MapFallbackToPage("/_Host");
```
Order matters — the API-specific fallback must be registered FIRST.
## Studio → backend wiring
The WASM Studio reads its backend URL at runtime from a JS-injected client
config, NOT from a compile-time constant. `Pages/_Host.cshtml` renders:
```js
window.getClientConfig = function () { return { "apiUrl": "https://elsa.tes.gd/elsa/api" }; };
```
…assembled from `Hosting:BaseUrl` (or `Http:BaseUrl`) + `Hosting:ApiPrefix`
(default `elsa/api`). `Elsa.Studio.Wasm/Program.cs` reads this via `IJSRuntime`
after DI is set up and stuffs it into `BackendOptions.Url`. This lets the SAME
compiled WASM point at any origin without a rebuild — set `HTTP__BASEURL` in
Coolify env vars to change the target.
## SQLite persistence — pin the path
`UseSqlite()` with no connection string writes to `elsa.sqlite.db` in the
process working dir → `/app/elsa.sqlite.db` inside the container, which is
OUTSIDE the mounted `/app/App_Data` volume. Every redeploy loses all state.
Fix (already in `Program.cs`): read `ConnectionStrings:Sqlite` (defaults to
`Data Source=App_Data/elsa.sqlite.db;Cache=Shared;`) and pass it to
`ef.UseSqlite(sqliteConnectionString)`.
For anything real, move to Postgres — add `Elsa.EntityFrameworkCore.PostgreSql`
NuGet, switch `.UseSqlite(...)` → `.UsePostgreSql(...)`, provision a Coolify
Postgres service and set `ConnectionStrings__Sqlite` (still the config key)
to its DSN.
## Coolify deployment quirks (repo-specific)
- App is built via `POST /applications/public` with `build_pack=dockerfile` —
proper application, not a service. (`POST /applications/dockercompose`
creates a "service" instead and lacks the clean `docker_compose_domains`
handling — avoid.)
- `git_repository` is truncated to `owner/repo.git` on create. Always
PATCH it back with the full `https://user:token@host/owner/repo.git` after.
Confirm with a GET.
- `docker_compose_raw` (if you ever go that route) must be **base64-encoded**
at create time. `POST /applications/dockercompose` rejects it otherwise.
- Deploys are fire-and-forget — never poll from Claude. User's global rule.
## NATS activities
Connection details live on the activity nodes themselves — configured per-node
in the workflow designer as a **single connection string** in the standard NATS
URL form `<scheme>://[user:password@]host[:port]` (schemes: `nats`, `tls`,
`ws`, `wss`). Every stored trigger carries its own connection string; a
`NatsConnectionCache` singleton parses the string into `(cleanUrl, user,
password)` and hands out one `NatsConnection` per unique tuple so repeat
invocations reuse the same TCP/TLS session.
Passwords with special characters (`@ : / #`) must be URL-encoded (`%40 %3A
%2F %23`). The activity's input `Description` shows this in the Studio
property panel.
Files under `src/Elsa.CustomServer/`:
- `Activities/NatsPublish.cs` — publisher `CodeActivity`. Inputs: `Connection`
(single connection string), `Subject`, `Body`, optional `MessageId` (sets
the `Nats-Msg-Id` header for JetStream server-side dedup), optional
`ExpectedStream` (fail-fast assertion). Outputs `Sequence` and `Duplicate`.
- `Activities/NatsTrigger.cs` — trigger `Trigger<string>`. Inputs:
`Connection`, `Stream`, `Subject` (wildcards allowed). Outputs: `Result` =
message body (UTF-8), `ReceivedSubject`, `Headers`. Its trigger name /
stimulus name is `NATS.NatsTrigger`; the stimulus record includes the
connection string so a message from server A only starts the workflows
designed to listen on server A.
- `Nats/NatsConnectionCache.cs` — singleton connection-string parser +
`(cleanUrl, user, password) → NatsConnection` cache. Exposes static
`ParseConnectionString(...)` for anyone else who needs the same split.
- `Nats/NatsTriggerHostedService.cs` — one `BackgroundService` that scans
`ITriggerStore` for stored triggers named `NATS.NatsTrigger`, spins up one
durable pull consumer per unique stimulus, and dispatches workflows via
`IWorkflowDispatcher.DispatchAsync(DispatchTriggerWorkflowsRequest)` as
messages arrive. The mediator handler `NatsTriggerIndexChangedHandler`
re-runs the scan whenever `WorkflowTriggersIndexed` fires (i.e. after
publishing a workflow).
Consumer model: shared durable pull consumer with `DurableName =
elsa-<sanitizedStream>-<sanitizedSubject>` and `AckPolicy.Explicit`. That means
if multiple Elsa instances are running, exactly one processes each message
(load-balanced), at-least-once. `MaxDeliver = 5`, `AckWait = 30s`,
`MaxAckPending = 256`. Failed dispatches NAK with a 5-second delay.
**Security note:** trigger stimuli — including the NATS connection string,
which typically embeds the password — are persisted in the trigger store DB.
Don't log the stimulus record verbatim, and treat the trigger store with the
same care as any secret storage.
**Migration note:** activities were changed from three fields
(`Url / User / Password`) to a single `Connection` input. Any workflow authored
against the old shape needs to be reopened in Studio and its NATS
publish/trigger nodes re-filled — republishing then re-indexes the trigger
store with the new stimulus shape.
Package: `NATS.Net` (v2 client). The v1 `NATS.Client` package does not support
`wss://` from .NET — do not use it.
## Session-specific reminders
- Custom activities added under `Activities/` in the SERVER project (not the
WASM project). The server's assembly scan is the one that matters for the
toolbox — Studio just reads descriptors over the API.
- Blazor WASM cache is stubborn. If a change doesn't show up after
Ctrl+Shift+R, open the site in incognito to confirm whether it's a caching
issue vs. a real failure to deploy.
- When Studio complains about auth, `curl -X POST /elsa/api/identity/login`
first — if the API returns a JWT, the server is fine and the bug is
client-side (module wiring, cache).