Skip to content
Merged
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
4 changes: 3 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ jobs:
# pack re-verifies each project's own Release output deterministically.
for p in packages/*/*.csproj analyzers/Goldpath.Analyzers/Goldpath.Analyzers.csproj \
tools/Goldpath.Cli/Goldpath.Cli.csproj templates/Goldpath.Templates.csproj; do
dotnet pack "$p" --nologo -c Release -o out
# -p:GoldpathConsoleRequired=true arms the console package's own gate: this is
# the path that PUBLISHES, so this is where an empty console must stop the train.
dotnet pack "$p" --nologo -c Release -o out -p:GoldpathConsoleRequired=true
done
ls out
# Login LAST-MINUTE: the exchanged key lives ~1 hour, so it must be born next to
Expand Down
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,37 @@
All notable changes to the Goldpath packages are documented here.
Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · Versioning: SemVer.

## [0.1.0-preview.4] - 2026-07-27

The console train. Goldpath ships an operations console — served by the adopter's own
management head, driving the frozen admin contract with the operator's own credentials —
plus the tenancy fix that the console's own gate uncovered. Upgrade guide:
`docs/upgrades/0.1.0-preview.4.md` (no breaking changes).

### Added
- **`Goldpath.Console` — the operations console, mapped by your app.**
`app.MapGoldpathConsole()` serves the built single page from embedded assets, behind the
SAME ops floor as the admin surfaces. Adopters never run Node: the dist is built by
Goldpath's CI and shipped inside the package, which refuses to pack without it. The
cross-service registry is configuration (`AddService(name, adminBaseUrl)`), not a file
beside the dist.
- **The console itself** (`ui/console`, `ui/kit`): a TODAY triage screen across every
configured service, then panels for all five modules — runs (trigger/pause/rerun/replay),
bulk intake (upload → the engine's validation report → the four-eyes gate), the campaign
governor (pacer numbers + live throttle + pause/resume/abort), notification evidence
(masked recipients, three lenses, no verbs by contract) and archival (chain verification,
keyed retrieval, hold/lift/erase). Capability-lit: a module the app never composed has no
panel, and one that refuses says why in the SERVER's words.

### Fixed
- **Multi-tenancy is a MARKER, not "some `ITenantContext` exists".** Composing a broker
registers an `ITenantContext` for message-scoped propagation, and the admin seam read
that as "this app is multi-tenant" — so a SINGLE-tenant app that merely added messaging
had its admin surfaces refuse: 400 on the tenant-scoped ones, 403 on campaign. Shipped in
preview.3; invisible because the reference app composes multitenancy. `AddGoldpathMultiTenancy`
now registers `GoldpathMultiTenancyMarker` and the seam asks for that. Multi-tenant
behaviour is unchanged.

## [0.1.0-preview.3] - 2026-07-25

The security train — every actionable finding of the 2026-07-21 independent audit's
Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

<PropertyGroup>
<VersionPrefix>0.1.0</VersionPrefix>
<VersionSuffix>preview.3</VersionSuffix>
<VersionSuffix>preview.4</VersionSuffix>
<Authors>Omer Celik</Authors>
<PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>
<Company>Omer Celik</Company>
Expand Down
46 changes: 46 additions & 0 deletions docs/upgrades/0.1.0-preview.4.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Upgrading to 0.1.0-preview.4

**No breaking changes — take it blind.**

The train adds a package and fixes a tenancy misread. Nothing that worked on preview.3
behaves differently, with one exception that can only make things work again (below).

## The tenancy fix, if you were bitten by it

If your app composes **messaging** but NOT **multitenancy**, preview.3's admin surfaces
refused you: `400 no ambient tenant on a multi-tenant app` on bulk/archival/notification,
and `403` on campaign. That was a misread — `AddGoldpathMessaging` registers an
`ITenantContext` to propagate the tenant of a consumed message, and the admin seam took
its presence as "this app is multi-tenant".

Take preview.4 and the surfaces answer again. Nothing to change in your app.

Multi-tenant apps: unchanged. The signal is now the marker
`AddGoldpathMultiTenancy` registers, so tenant scoping (revision R1) applies exactly where
it did before.

## Taking the console

```csharp
// in your management head, next to the admin mappers
app.MapGoldpathConsole();
```

- It sits behind the **same ops floor** as `/goldpath/admin/*` — an operator without
`goldpath-ops` is challenged for the console's own page, so you cannot ship an
unauthenticated console by accident. `exposeUnsecured: true` exists for hosts with no
auth at all, and logs that choice like the admin mappers do.
- One console can drive several services:

```csharp
app.MapGoldpathConsole(configure: console => console
.AddService("payments", "https://payments.internal")
.AddService("claims", "https://claims.internal"));
```

Each additional service must share the console's auth boundary or allow its origin in
CORS **with credentials** — the console carries the operator's own credentials and holds
no token of its own (console RFC §3).
- The console adds no capability your API does not already expose. Everything it does is a
call an operator could make with `curl`, which is why it can be dropped in without a
review of what it might do.
2 changes: 2 additions & 0 deletions packages/Goldpath.Abstractions/PublicAPI.Shipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,5 @@ const Goldpath.GoldpathHeaders.ApiKey = "X-Goldpath-Api-Key" -> string!
Goldpath.GoldpathPolicies
const Goldpath.GoldpathPolicies.Ops = "goldpath-ops" -> string!
const Goldpath.GoldpathPolicies.OpsAllTenants = "goldpath-ops-all-tenants" -> string!
Goldpath.GoldpathMultiTenancyMarker
Goldpath.GoldpathMultiTenancyMarker.GoldpathMultiTenancyMarker() -> void
2 changes: 0 additions & 2 deletions packages/Goldpath.Abstractions/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
#nullable enable
Goldpath.GoldpathMultiTenancyMarker
Goldpath.GoldpathMultiTenancyMarker.GoldpathMultiTenancyMarker() -> void
7 changes: 6 additions & 1 deletion packages/Goldpath.Console/Goldpath.Console.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,12 @@
<None Include="README.md" Pack="true" PackagePath="/" />
</ItemGroup>

<Target Name="RefuseAnEmptyConsole" BeforeTargets="GenerateNuspec">
<!-- The gate fires on the PUBLISHING path only (`release.yml` sets the property). Local
and CI tooling packs the whole solution to build a source feed — GM shapes do it on
every run — and demanding Node there would make an unrelated proof depend on a
toolchain it has no use for. What must never happen is PUBLISHING an empty console,
and that is exactly where this fires. -->
<Target Name="RefuseAnEmptyConsole" BeforeTargets="GenerateNuspec" Condition="'$(GoldpathConsoleRequired)' == 'true'">
<Error Condition="!Exists('$(MSBuildProjectDirectory)/wwwroot/index.html')"
Text="Goldpath.Console has no built console in wwwroot/. Run scripts/build-console.sh before packing — a package that maps an empty console would 404 in every adopter's face." />
</Target>
Expand Down
12 changes: 12 additions & 0 deletions packages/Goldpath.Console/PublicAPI.Shipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,13 @@
#nullable enable
Goldpath.GoldpathConsoleEndpoints
Goldpath.GoldpathConsoleOptions
Goldpath.GoldpathConsoleOptions.AddService(string! name, string! adminBaseUrl) -> Goldpath.GoldpathConsoleOptions!
Goldpath.GoldpathConsoleOptions.GoldpathConsoleOptions() -> void
Goldpath.GoldpathConsoleOptions.Services.get -> System.Collections.Generic.IList<Goldpath.GoldpathConsoleService!>!
Goldpath.GoldpathConsoleService
Goldpath.GoldpathConsoleService.AdminBaseUrl.get -> string!
Goldpath.GoldpathConsoleService.AdminBaseUrl.init -> void
Goldpath.GoldpathConsoleService.GoldpathConsoleService(string! Name, string! AdminBaseUrl) -> void
Goldpath.GoldpathConsoleService.Name.get -> string!
Goldpath.GoldpathConsoleService.Name.init -> void
static Goldpath.GoldpathConsoleEndpoints.MapGoldpathConsole(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! prefix = "/goldpath/console", bool exposeUnsecured = false, System.Action<Goldpath.GoldpathConsoleOptions!>? configure = null) -> Microsoft.AspNetCore.Routing.IEndpointRouteBuilder!
12 changes: 0 additions & 12 deletions packages/Goldpath.Console/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1,13 +1 @@
#nullable enable
Goldpath.GoldpathConsoleEndpoints
Goldpath.GoldpathConsoleOptions
Goldpath.GoldpathConsoleOptions.AddService(string! name, string! adminBaseUrl) -> Goldpath.GoldpathConsoleOptions!
Goldpath.GoldpathConsoleOptions.GoldpathConsoleOptions() -> void
Goldpath.GoldpathConsoleOptions.Services.get -> System.Collections.Generic.IList<Goldpath.GoldpathConsoleService!>!
Goldpath.GoldpathConsoleService
Goldpath.GoldpathConsoleService.AdminBaseUrl.get -> string!
Goldpath.GoldpathConsoleService.AdminBaseUrl.init -> void
Goldpath.GoldpathConsoleService.GoldpathConsoleService(string! Name, string! AdminBaseUrl) -> void
Goldpath.GoldpathConsoleService.Name.get -> string!
Goldpath.GoldpathConsoleService.Name.init -> void
static Goldpath.GoldpathConsoleEndpoints.MapGoldpathConsole(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! prefix = "/goldpath/console", bool exposeUnsecured = false, System.Action<Goldpath.GoldpathConsoleOptions!>? configure = null) -> Microsoft.AspNetCore.Routing.IEndpointRouteBuilder!
2 changes: 2 additions & 0 deletions packages/Goldpath.Idempotency/PublicAPI.Shipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,5 @@ Goldpath.StoredHttpResponse.ContentType.init -> void
Goldpath.StoredHttpResponse.StatusCode.get -> int
Goldpath.StoredHttpResponse.StatusCode.init -> void
static Goldpath.GoldpathIdempotencyExtensions.AddGoldpathIdempotency<TBuilder>(this TBuilder builder, System.Action<Goldpath.GoldpathIdempotencyOptions!>? configure = null) -> TBuilder
Goldpath.StoredHttpResponse.Location.get -> string?
Goldpath.StoredHttpResponse.Location.init -> void
2 changes: 0 additions & 2 deletions packages/Goldpath.Idempotency/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
#nullable enable
Goldpath.StoredHttpResponse.Location.get -> string?
Goldpath.StoredHttpResponse.Location.init -> void
6 changes: 4 additions & 2 deletions scripts/console-smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ echo "── postgres"
docker rm -f "$PG_NAME" >/dev/null 2>&1 || true
docker run -d --name "$PG_NAME" -e POSTGRES_PASSWORD=smoke -e POSTGRES_DB=smoke -p 55432:5432 postgres:17-alpine >/dev/null
CONNECTION="Host=localhost;Port=55432;Database=smoke;Username=postgres;Password=smoke"
for _ in $(seq 1 60); do docker exec "$PG_NAME" pg_isready -U postgres >/dev/null 2>&1 && break; sleep 1; done
# 120s, not 60: a nightly runner is doing nine other things and a cold image pull plus
# first-boot initdb has taken longer than a minute there (it did, on 2026-07-27).
for _ in $(seq 1 120); do docker exec "$PG_NAME" pg_isready -U postgres >/dev/null 2>&1 && break; sleep 1; done
docker exec "$PG_NAME" pg_isready -U postgres >/dev/null 2>&1 || { echo "postgres never came up:"; docker logs "$PG_NAME" 2>&1 | tail -20; exit 1; }

echo "── rabbitmq (campaign RELEASES through a broker — campaign RFC D8, no stand-in)"
Expand All @@ -57,7 +59,7 @@ docker run -d --name "$MQ_NAME" -p 55672:5672 rabbitmq:4-alpine sh -c \
BROKER="amqp://guest:guest@localhost:55672"
# BOUNDED: an unbounded wait on a container that died turns a two-minute failure into a
# hang with no output at all.
for _ in $(seq 1 60); do
for _ in $(seq 1 90); do
docker exec "$MQ_NAME" rabbitmq-diagnostics -q check_port_connectivity >/dev/null 2>&1 && break
sleep 2
done
Expand Down
Loading