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.
This commit is contained in:
182
CLAUDE.md
Normal file
182
CLAUDE.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# 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.
|
||||
|
||||
## 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).
|
||||
Reference in New Issue
Block a user