Files
ElsaWorkflow/CLAUDE.md
Claude 6ec291c096 Add CLAUDE.md with hard-won setup notes
Captures everything that took a debug cycle to figure out: server-vs-Studio
version divergence (3.5 vs 3.7), the two competing login stacks and which
one to keep, the admin password-hash trap, Traefik forwarded-headers, the
SPA fallback carving out /elsa/api, SQLite pinning to App_Data, and the
Coolify quirks (git_repository truncation, dockercompose→service, base64
raw compose). Future sessions read this first before touching versions
or auth wiring.
2026-09-02 09:23:21 +00:00

8.3 KiB

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

"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):

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

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:

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.

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).