diff --git a/Directory.Packages.props b/Directory.Packages.props
index 9d5d675..ab2f343 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -5,9 +5,9 @@
-
-
-
+
+
+
@@ -15,17 +15,17 @@
-
-
+
+
-
+
-
+
-
+
@@ -37,7 +37,7 @@
-
+
@@ -45,12 +45,12 @@
-
+
-
+
-
+
diff --git a/Documentation/reference/connection.md b/Documentation/reference/connection.md
index 369d5ff..9629830 100644
--- a/Documentation/reference/connection.md
+++ b/Documentation/reference/connection.md
@@ -23,7 +23,7 @@ Chronicle serves gRPC and the OAuth/HTTP API over a single TLS port (default `35
| Option | Values | Description |
|---|---|---|
-| `disableTls` | `true` / `false` | Disables TLS for the connection. Only needed when connecting through a plaintext-terminating proxy — the Chronicle server itself always serves TLS. |
+| `skipTlsValidation` | `true` / `false` | Whether to skip validation of the server's TLS certificate. On by default, which is what lets the CLI trust a development server's self-signed certificate; set it to `false` to require a valid certificate. |
| `certificatePath` | path | Path to a client certificate file, for pinning a specific expected server certificate. |
| `certificatePassword` | string | Password for the certificate file. |
diff --git a/Documentation/reference/run.md b/Documentation/reference/run.md
index 3c9a7ce..c279ad6 100644
--- a/Documentation/reference/run.md
+++ b/Documentation/reference/run.md
@@ -6,7 +6,12 @@
cratis run [PATH]
```
-You point it at a folder of `.play` files (or run it from inside one), and it hands that folder to the Stage container, which compiles every `.play` file it finds and exposes a live API you can drive.
+```text
+ Starting Stage from /work/invoicing on http://localhost:9090
+ Chronicle Workbench on https://localhost:35000
+```
+
+You point it at a folder of `.play` files (or run it from inside one), and it hands that folder to the Stage container, which compiles every `.play` file it finds and exposes a live API you can drive — plus the Chronicle Workbench, for looking at what the model does to the event store.
## Prerequisites
@@ -25,21 +30,26 @@ You point it at a folder of `.play` files (or run it from inside one), and it ha
|---|---|
| `--tag ` | The `cratis/stage` image tag to run. Default: `latest`. |
| `--port ` | Host port to publish the Stage API on. Default: `9090`. |
+| `--workbench-port ` | Host port to publish the Chronicle Workbench on. Default: `35000`. |
Global options such as `-o/--output` are also accepted — see [Global Options](global-options.md).
## What it does
-The command mounts the folder into the Stage container and publishes the Stage API to your host:
+The command mounts the folder into the Stage container and publishes both of the container's ports to your host:
```bash
-docker run --rm -p 9090:9090 -v "$PWD":/eventmodel cratis/stage:latest
+docker run --rm -p 9090:9090 -p 35000:35000 -v "$PWD":/eventmodel cratis/stage:latest
```
- The folder is mounted at `/eventmodel` inside the container; Stage globs `**/*.play` beneath it and compiles them.
-- The Stage API is published on `http://localhost:9090` (change the host side with `--port`).
+- The Stage API is published on `http://localhost:9090` (change the host side with `--port`). Its API reference is at `http://localhost:9090/scalar/v1`.
+- The **Chronicle Workbench** is published on `https://localhost:35000` (change the host side with `--workbench-port`), so you can inspect the session's events, observers and read models while it runs.
- `--rm` removes the container when it exits, so every run starts from a clean, in-memory store.
+The Workbench is served over HTTPS with a self-signed development certificate, so your browser will warn about it
+the first time. Sign in with the Stage image's development credentials — user `admin`, password `ChangeMeNow!`.
+
The command streams the container's output and exits with the container's exit code. Stop the session with `Ctrl+C`.
## Errors
diff --git a/Integration/Chronicle/for_Context/when_creating_context.cs b/Integration/Chronicle/for_Context/when_creating_context.cs
index 48d5a23..95025c4 100644
--- a/Integration/Chronicle/for_Context/when_creating_context.cs
+++ b/Integration/Chronicle/for_Context/when_creating_context.cs
@@ -16,7 +16,7 @@ async Task Because()
"create",
"integration-test-ctx",
"--server",
- "chronicle://localhost:35000/?disableTls=true",
+ "chronicle://localhost:35000/?skipTlsValidation=true",
"--output",
"json");
diff --git a/Integration/Chronicle/for_Context/when_deleting_context.cs b/Integration/Chronicle/for_Context/when_deleting_context.cs
index a5477e2..16216c4 100644
--- a/Integration/Chronicle/for_Context/when_deleting_context.cs
+++ b/Integration/Chronicle/for_Context/when_deleting_context.cs
@@ -16,7 +16,7 @@ async Task Because()
"create",
"integration-test-del",
"--server",
- "chronicle://localhost:35000/?disableTls=true");
+ "chronicle://localhost:35000/?skipTlsValidation=true");
_deleteResult = await CliCommandRunner.RunAsync("context", "delete", "integration-test-del", "--output", "json");
}
diff --git a/Integration/Chronicle/for_Context/when_renaming_context.cs b/Integration/Chronicle/for_Context/when_renaming_context.cs
index c1c6514..1160539 100644
--- a/Integration/Chronicle/for_Context/when_renaming_context.cs
+++ b/Integration/Chronicle/for_Context/when_renaming_context.cs
@@ -17,7 +17,7 @@ async Task Because()
"create",
"integration-test-ren",
"--server",
- "chronicle://localhost:35000/?disableTls=true");
+ "chronicle://localhost:35000/?skipTlsValidation=true");
_renameResult = await CliCommandRunner.RunAsync("context", "rename", "integration-test-ren", "integration-test-renamed", "--output", "json");
diff --git a/Integration/Chronicle/for_Context/when_renaming_context_to_same_name.cs b/Integration/Chronicle/for_Context/when_renaming_context_to_same_name.cs
index 3b9b56b..39419cb 100644
--- a/Integration/Chronicle/for_Context/when_renaming_context_to_same_name.cs
+++ b/Integration/Chronicle/for_Context/when_renaming_context_to_same_name.cs
@@ -16,7 +16,7 @@ async Task Because()
"create",
"integration-test-same-name",
"--server",
- "chronicle://localhost:35000/?disableTls=true");
+ "chronicle://localhost:35000/?skipTlsValidation=true");
_renameResult = await CliCommandRunner.RunAsync("context", "rename", "integration-test-same-name", "integration-test-same-name", "--output", "json");
diff --git a/Integration/Chronicle/for_Context/when_setting_context.cs b/Integration/Chronicle/for_Context/when_setting_context.cs
index 86da9eb..b88e4e9 100644
--- a/Integration/Chronicle/for_Context/when_setting_context.cs
+++ b/Integration/Chronicle/for_Context/when_setting_context.cs
@@ -17,7 +17,7 @@ async Task Because()
"create",
"integration-test-set",
"--server",
- "chronicle://localhost:35000/?disableTls=true");
+ "chronicle://localhost:35000/?skipTlsValidation=true");
_setResult = await CliCommandRunner.RunAsync("context", "set", "integration-test-set", "--output", "json");
diff --git a/Integration/Chronicle/for_Context/when_setting_context_value.cs b/Integration/Chronicle/for_Context/when_setting_context_value.cs
index 083ac15..d17d36a 100644
--- a/Integration/Chronicle/for_Context/when_setting_context_value.cs
+++ b/Integration/Chronicle/for_Context/when_setting_context_value.cs
@@ -8,7 +8,7 @@ public class when_setting_context_value : Specification
{
CliCommandResult _result = null!;
- async Task Because() => _result = await CliCommandRunner.RunAsync("context", "set-value", "server", "chronicle://localhost:35000/?disableTls=true");
+ async Task Because() => _result = await CliCommandRunner.RunAsync("context", "set-value", "server", "chronicle://localhost:35000/?skipTlsValidation=true");
[Fact] void should_return_success_exit_code() => _result.ExitCode.ShouldEqual(ExitCodes.Success);
diff --git a/Source/Cli.Specs/for_StageContainer/when_building_run_arguments/with_a_custom_host_port.cs b/Source/Cli.Specs/for_StageContainer/when_building_run_arguments/with_a_custom_host_port.cs
index 2cdfe2f..b344053 100644
--- a/Source/Cli.Specs/for_StageContainer/when_building_run_arguments/with_a_custom_host_port.cs
+++ b/Source/Cli.Specs/for_StageContainer/when_building_run_arguments/with_a_custom_host_port.cs
@@ -7,8 +7,9 @@ public class with_a_custom_host_port : Specification
{
IReadOnlyList _arguments;
- void Because() => _arguments = StageContainer.BuildRunArguments("/work/space", "1.2.0", 9191);
+ void Because() => _arguments = StageContainer.BuildRunArguments("/work/space", "1.2.0", 9191, 35001);
[Fact] void should_publish_the_custom_host_port_to_the_api_port() => _arguments.ShouldContain("9191:9090");
+ [Fact] void should_publish_the_custom_workbench_host_port_to_the_workbench_port() => _arguments.ShouldContain("35001:35000");
[Fact] void should_run_the_requested_tag() => _arguments.ShouldContain("cratis/stage:1.2.0");
}
diff --git a/Source/Cli.Specs/for_StageContainer/when_building_run_arguments/with_matching_ports.cs b/Source/Cli.Specs/for_StageContainer/when_building_run_arguments/with_matching_ports.cs
index b1d52f7..4898580 100644
--- a/Source/Cli.Specs/for_StageContainer/when_building_run_arguments/with_matching_ports.cs
+++ b/Source/Cli.Specs/for_StageContainer/when_building_run_arguments/with_matching_ports.cs
@@ -7,11 +7,12 @@ public class with_matching_ports : Specification
{
IReadOnlyList _arguments;
- void Because() => _arguments = StageContainer.BuildRunArguments("/work/space", "latest", 9090);
+ void Because() => _arguments = StageContainer.BuildRunArguments("/work/space", "latest", 9090, 35000);
[Fact] void should_run_a_container() => _arguments.ShouldContain("run");
[Fact] void should_remove_the_container_on_exit() => _arguments.ShouldContain("--rm");
[Fact] void should_publish_the_host_port_to_the_api_port() => _arguments.ShouldContain("9090:9090");
+ [Fact] void should_publish_the_host_port_to_the_workbench_port() => _arguments.ShouldContain("35000:35000");
[Fact] void should_mount_the_folder_at_the_model_path() => _arguments.ShouldContain("/work/space:/eventmodel");
[Fact] void should_run_the_tagged_image() => _arguments.ShouldContain("cratis/stage:latest");
}
diff --git a/Source/Cli/Commands/Chronicle/Auth/LoginCommand.cs b/Source/Cli/Commands/Chronicle/Auth/LoginCommand.cs
index 516eba1..cb1e750 100644
--- a/Source/Cli/Commands/Chronicle/Auth/LoginCommand.cs
+++ b/Source/Cli/Commands/Chronicle/Auth/LoginCommand.cs
@@ -44,9 +44,11 @@ protected override async Task ExecuteAsync(CommandContext context, LoginSet
try
{
var connectionString = new ChronicleConnectionString(settings.ResolveConnectionString());
- var disableTls = connectionString.DisableTls;
- var scheme = disableTls ? "http" : "https";
- var tokenEndpoint = $"{scheme}://{connectionString.ServerAddress.Host}:{connectionString.ServerAddress.Port}/connect/token";
+
+ // Chronicle serves the OAuth endpoint over TLS on the same port as gRPC, and the client always
+ // connects over TLS — a connection string only relaxes certificate validation, which the handler
+ // below does unconditionally.
+ var tokenEndpoint = $"https://{connectionString.ServerAddress.Host}:{connectionString.ServerAddress.Port}/connect/token";
using var handler = CreateHandler();
using var httpClient = new HttpClient(handler);
diff --git a/Source/Cli/Commands/Chronicle/CliChronicleConnection.cs b/Source/Cli/Commands/Chronicle/CliChronicleConnection.cs
index 32eca0b..a489a7c 100644
--- a/Source/Cli/Commands/Chronicle/CliChronicleConnection.cs
+++ b/Source/Cli/Commands/Chronicle/CliChronicleConnection.cs
@@ -58,7 +58,7 @@ public static async Task Connect(ChronicleConnectionStri
NullLoggerFactory.Instance,
cts.Token,
NullLogger.Instance,
- connectionString.DisableTls,
+ connectionString.SkipTlsValidation,
connectionString.CertificatePath,
connectionString.CertificatePassword,
tokenProvider,
@@ -129,7 +129,7 @@ static FileSystemCachingTokenProvider CreateCachingTokenProvider(ChronicleConnec
connectionString.ServerAddress,
connectionString.Username ?? string.Empty,
connectionString.Password ?? string.Empty,
- connectionString.DisableTls,
+ connectionString.SkipTlsValidation,
NullLogger.Instance);
#pragma warning restore CA2000
return new FileSystemCachingTokenProvider(inner, cachePath);
diff --git a/Source/Cli/Commands/Run/RunCommand.cs b/Source/Cli/Commands/Run/RunCommand.cs
index abb2c47..0d3ba0b 100644
--- a/Source/Cli/Commands/Run/RunCommand.cs
+++ b/Source/Cli/Commands/Run/RunCommand.cs
@@ -9,13 +9,14 @@ namespace Cratis.Cli.Commands.Run;
///
/// Runs the Screenplay (.play) files in a folder in a local Stage sandbox using Docker.
///
-[LlmDescription("Runs the current folder's Screenplay (.play) files in a local Stage sandbox via Docker. Errors if no .play files are present. The Stage API is published on the host (default port 9090).")]
+[LlmDescription("Runs the current folder's Screenplay (.play) files in a local Stage sandbox via Docker. Errors if no .play files are present. The Stage API (default port 9090) and the Chronicle Workbench (default port 35000) are published on the host.")]
[CliCommand("run", "Run the Screenplay (.play) files in the current folder in a local Stage sandbox")]
[CliExample("run")]
[CliExample("run", "./screenplays")]
[CliExample("run", "--port", "9191")]
[LlmOption("--tag", "string", "The cratis/stage image tag to run (default: latest).")]
[LlmOption("--port", "int", "Host port to publish the Stage API on (default: 9090).")]
+[LlmOption("--workbench-port", "int", "Host port to publish the Chronicle Workbench on (default: 35000).")]
public class RunCommand : AsyncCommand
{
///
@@ -36,7 +37,7 @@ protected override async Task ExecuteAsync(CommandContext context, RunSetti
return ExitCodes.ValidationError;
}
- var arguments = StageContainer.BuildRunArguments(path, settings.Tag, settings.Port);
+ var arguments = StageContainer.BuildRunArguments(path, settings.Tag, settings.Port, settings.WorkbenchPort);
var startInfo = new ProcessStartInfo { FileName = "docker" };
foreach (var argument in arguments)
{
@@ -48,6 +49,7 @@ protected override async Task ExecuteAsync(CommandContext context, RunSetti
!string.Equals(format, OutputFormats.JsonCompact, StringComparison.Ordinal))
{
AnsiConsole.MarkupLine($" [{OutputFormatter.Muted.ToMarkup()}]Starting Stage from {path.EscapeMarkup()} on http://localhost:{settings.Port}[/]");
+ AnsiConsole.MarkupLine($" [{OutputFormatter.Muted.ToMarkup()}]Chronicle Workbench on https://localhost:{settings.WorkbenchPort}[/]");
}
Process? process;
diff --git a/Source/Cli/Commands/Run/RunSettings.cs b/Source/Cli/Commands/Run/RunSettings.cs
index ca5a25f..8321676 100644
--- a/Source/Cli/Commands/Run/RunSettings.cs
+++ b/Source/Cli/Commands/Run/RunSettings.cs
@@ -30,4 +30,12 @@ public class RunSettings : GlobalSettings
[Description("Host port to publish the Stage API on.")]
[DefaultValue(9090)]
public int Port { get; set; } = 9090;
+
+ ///
+ /// Gets or sets the host port to publish the Chronicle Workbench on.
+ ///
+ [CommandOption("--workbench-port ")]
+ [Description("Host port to publish the Chronicle Workbench on.")]
+ [DefaultValue(35000)]
+ public int WorkbenchPort { get; set; } = 35000;
}
diff --git a/Source/Cli/Commands/Run/StageContainer.cs b/Source/Cli/Commands/Run/StageContainer.cs
index cc97293..f75c1b7 100644
--- a/Source/Cli/Commands/Run/StageContainer.cs
+++ b/Source/Cli/Commands/Run/StageContainer.cs
@@ -18,6 +18,11 @@ public static class StageContainer
///
public const int ApiPort = 9090;
+ ///
+ /// The port the bundled Chronicle kernel serves the Chronicle Workbench on inside the container.
+ ///
+ public const int WorkbenchPort = 35000;
+
///
/// The path inside the container the folder of Screenplay files is mounted at.
///
@@ -25,18 +30,21 @@ public static class StageContainer
///
/// Builds the argument list for docker run that launches the Stage container with the given
- /// folder mounted and the Stage API published on the host.
+ /// folder mounted and the Stage API and Chronicle Workbench published on the host.
///
/// The absolute path to the folder of Screenplay files to mount.
/// The image tag to run.
/// The host port to publish the Stage API on.
+ /// The host port to publish the Chronicle Workbench on.
/// The ordered argument list to pass to the docker executable.
- public static IReadOnlyList BuildRunArguments(string path, string tag, int hostPort) =>
+ public static IReadOnlyList BuildRunArguments(string path, string tag, int hostPort, int workbenchHostPort) =>
[
"run",
"--rm",
"-p",
$"{hostPort}:{ApiPort}",
+ "-p",
+ $"{workbenchHostPort}:{WorkbenchPort}",
"-v",
$"{path}:{MountPath}",
$"{Image}:{tag}"