Chat Service

NO WARRANTY. Trax auth is plumbing, not a security product. You are solely responsible for securing systems that use it. See API Security.

samples/ChatService is a chat server whose messages reach the other participants over GraphQL subscriptions. Chat mutations are ordinary Trax trains. When one completes, a lifecycle hook publishes its output to a topic for the room, and every participant subscribed with onChatEvent(chatRoomId:) receives it on their WebSocket. Everything runs in one process, on two SQLite files, so it needs no Docker.

What it proves

FeatureWhere
A subscription field of your own on Trax's subscription root, LifecycleSubscriptionsSubscriptions/ChatSubscriptions.cs
Publishing to that field from an ITrainLifecycleHook when a chat train completesHooks/ChatLifecycleHook.cs
Socket authentication: the API key in connection_init, a socket without one closed with 4403ChatEventSubscriptionTests
Per-subscriber authorization: [TraxAuthorize] on the field plus a participant check when subscribingChatSubscriptions.SubscribeToChatEventAsync
Caller identity: every train is [TraxAuthorize], reads the caller from TraxPrincipal, and no input names the callerTrains/*/Junctions
Invites: only a room's member can add someone, and the server, not the client, names who was addedTrains/InviteToChatRoom, People/ChatDirectory.cs
Room-scoped events only: no chat train is [TraxBroadcast], so Trax's lifecycle subscriptions refuse a chat userInviteToChatRoomTests
LifecycleSubscriptionTests

Layout

samples/ChatService/
├── Trax.Samples.ChatService.Data/     EF Core entities, ChatDbContext, migrations
├── Trax.Samples.ChatService/          trains, the lifecycle hook, the subscription type, auth constants
├── Trax.Samples.ChatService.Api/      the host (Program.cs)
└── Trax.Samples.ChatService.Client/   React + Apollo Client, graphql-ws for subscriptions

Run

From the Trax.Samples root:

# The API, in Development, on http://localhost:5210
dotnet run --project samples/ChatService/Trax.Samples.ChatService.Api
 
# Optional: the React client on http://localhost:5173
cd samples/ChatService/Trax.Samples.ChatService.Client
npm ci
npm run dev

dotnet run starts the API in Development through Properties/launchSettings.json, which is the only environment that registers the demo keys. Open the React client in two tabs and pick Alice in one and Bob in the other. As Alice, create a room and press + Invite to add Bob: the room appears in Bob's list within a few seconds, marked new, and the two of you can chat.

The client's Under the hood panel lists what each tab sends to Trax and gets back as it happens: every GraphQL request and response, and every WebSocket frame. A message can be followed from the dispatch.sendMessage mutation that runs its SendMessage train to the MessageSent event that ChatLifecycleHook publishes for the same run; the entries share the run's external id. The API key in connection_init is shown masked.

Try it

The demo keys are alice-key-do-not-use-in-production, bob-key-do-not-use-in-production and charlie-key-do-not-use-in-production. Each resolves to a principal whose id Trax qualifies with the scheme, so Alice is TraxApiKey:alice.

G=http://localhost:5210/trax/graphql
 
# 1. Alice creates a room
curl -s $G -H 'Content-Type: application/json' -H 'X-Api-Key: alice-key-do-not-use-in-production' \
  -d '{"query":"mutation { dispatch { createChatRoom(input: { name: \"General\" }) { output { chatRoomId name } } } }"}'
# {"data":{"dispatch":{"createChatRoom":{"output":{"chatRoomId":"35427876-...","name":"General"}}}}}
 
ROOM=<chatRoomId from step 1>
 
# 2. Alice adds Bob (Bob could also join by the room's id himself, with joinChatRoom)
curl -s $G -H 'Content-Type: application/json' -H 'X-Api-Key: alice-key-do-not-use-in-production' \
  -d "{\"query\":\"mutation { dispatch { inviteToChatRoom(input: { chatRoomId: \\\"$ROOM\\\", userId: \\\"TraxApiKey:bob\\\" }) { output { userId displayName invitedByDisplayName } } } }\"}"
# {"data":{"dispatch":{"inviteToChatRoom":{"output":{"userId":"TraxApiKey:bob","displayName":"Bob","invitedByDisplayName":"Alice"}}}}}
  1. Subscribe as Bob. Any graphql-ws client works; the key goes in the connection_init payload as apiKey. From Node, save this as subscribe.mjs in the client folder (which has graphql-ws installed) and run node subscribe.mjs $ROOM:

    import { createClient } from "graphql-ws";
    const client = createClient({
      url: "ws://localhost:5210/trax/graphql",
      connectionParams: { apiKey: "bob-key-do-not-use-in-production" },
    });
    client.subscribe(
      { query: `subscription { onChatEvent(chatRoomId: "${process.argv[2]}") { eventType payload } }` },
      { next: (m) => console.log(JSON.stringify(m)), error: console.error, complete: () => {} },
    );
# 4. Alice sends a message
curl -s $G -H 'Content-Type: application/json' -H 'X-Api-Key: alice-key-do-not-use-in-production' \
  -d "{\"query\":\"mutation { dispatch { sendMessage(input: { chatRoomId: \\\"$ROOM\\\", content: \\\"Hello!\\\" }) { output { messageId senderUserId content } } } }\"}"

Bob's subscription prints:

{"data":{"onChatEvent":{"eventType":"MessageSent","payload":"{\"$id\":\"1\",\"messageId\":\"4738eb9f-...\",\"chatRoomId\":\"35427876-...\",\"senderUserId\":\"TraxApiKey:alice\",\"senderDisplayName\":\"Alice\",\"content\":\"Hello!\",\"sentAt\":\"2026-10-03T16:38:49.03Z\"}"}}}
# 5. Charlie never joined: his read is refused, and so is his subscription
curl -s $G -H 'Content-Type: application/json' -H 'X-Api-Key: charlie-key-do-not-use-in-production' \
  -d "{\"query\":\"{ discover { getChatHistory(input: { chatRoomId: \\\"$ROOM\\\" }) { messages { content } } } }\"}"
# {"errors":[{"message":"You are not a participant in room 35427876-....","extensions":{"code":"TRAX_TRAIN_ERROR"}}],...}
 
# 6. Without a key, every operation is refused
curl -s $G -H 'Content-Type: application/json' \
  -d "{\"query\":\"{ discover { getChatHistory(input: { chatRoomId: \\\"$ROOM\\\" }) { messages { content } } } }\"}"
# {"errors":[{"message":"Not authorized.","extensions":{"code":"TRAX_AUTHORIZATION"}}],...}
 
# 7. Bob's rooms: the query takes no input, it lists the caller's own
curl -s $G -H 'Content-Type: application/json' -H 'X-Api-Key: bob-key-do-not-use-in-production' \
  -d '{"query":"{ discover { getChatRooms { rooms { id name participantCount lastMessageAt } } } }"}'

Charlie's node subscribe.mjs (with his key) prints error [{"message":"Not authorized.","extensions":{"code":"TRAX_AUTHORIZATION"}}], and a client that sends no key at all is closed with 4403 Missing auth token in connection_init payload. before it can subscribe.

How it works

The host

using Microsoft.EntityFrameworkCore;
using Trax.Api.Auth;
using Trax.Api.Auth.ApiKey;
using Trax.Api.Extensions;
using Trax.Api.GraphQL.Extensions;
using Trax.Effect.Data.Sqlite.Extensions;
using Trax.Effect.Extensions;
using Trax.Effect.Provider.Json.Extensions;
using Trax.Effect.Provider.Parameter.Extensions;
using Trax.Mediator.Extensions;
 
var builder = WebApplication.CreateBuilder(args);
 
builder.Services.AddDbContext<ChatDbContext>(o => o.UseSqlite("Data Source=chat.db"));
 
// Demo keys, Development only. The factory overload sets a display name.
if (builder.Environment.IsDevelopment())
    builder.Services.AddTraxApiKeyAuth(keys =>
        keys.Add("alice-key-do-not-use-in-production", () => new TraxPrincipal("alice", "Alice", ["User"]))
            .Add("bob-key-do-not-use-in-production", () => new TraxPrincipal("bob", "Bob", ["User"])));
builder.Services.AddAuthentication();
builder.Services.AddAuthorization();
 
builder.Services.AddTrax(trax =>
    trax.AddEffects(effects => effects
            .UseSqlite("Data Source=trax.db")
            .AddJson()
            .SaveTrainParameters()
            .AddLifecycleHook<ChatLifecycleHookFactory>())
        .AddMediator(typeof(ChatLifecycleHookFactory).Assembly));
 
builder.Services.AddTraxGraphQL(graphql => graphql.AddTypeExtension<ChatSubscriptions>());
 
// The CORS default policy's origins are also the origins a browser socket is accepted from.
builder.Services.AddCors(o => o.AddDefaultPolicy(p => p
    .WithOrigins("http://localhost:5173").AllowAnyHeader().AllowAnyMethod().AllowCredentials()));
 
var app = builder.Build();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.UseTraxGraphQL();
app.Run();

Packages: Trax.Api, Trax.Api.GraphQL, Trax.Api.Auth.ApiKey, Trax.Effect.Data.Sqlite, Trax.Effect.Provider.Json, Trax.Effect.Provider.Parameter, Trax.Mediator, and HotChocolate.AspNetCore in the library that defines the subscription type.

The trains act as the caller

Every train is [TraxAuthorize(Roles = "User")], and a junction that needs the user injects TraxPrincipal. The gate refuses an anonymous request before the junction is built, so the injection cannot fail at run time. The inputs carry no user field, which is what makes it impossible to act for somebody else:

public record SendMessageInput
{
    public Guid ChatRoomId { get; init; }
    public required string Content { get; init; }
}
 
public class ValidateSenderJunction(ChatDbContext db, TraxPrincipal caller)
    : Junction<SendMessageInput, SendMessageInput>
{
    public override async Task<SendMessageInput> Run(SendMessageInput input)
    {
        if (!await db.ChatParticipants.AnyAsync(p =>
                p.ChatRoomId == input.ChatRoomId && p.UserId == caller.Id))
            throw new TrainException($"You are not a participant in room {input.ChatRoomId}.");
        return input;
    }
}

caller.Id is the qualified id (TraxApiKey:alice), so that is what the sample stores. A TrainException's message reaches the client as written; any other exception's message does not. GetChatRoomsInput is an empty record, so getChatRooms takes no input argument.

Inviting someone

inviteToChatRoom is the one input that names a user, and only as the person to add. Its first junction admits the invite only from a member of the room, only for someone the host's ChatDirectory knows, and only for someone not in the room already; its second adds them under the name the directory holds, so the inviter cannot choose what the room calls them. The host builds the directory from the same people it registers credentials for, with each id qualified the way Trax qualifies the caller's:

builder.Services.AddSingleton(
    new ChatDirectory(
        demoPeople.Select(p => new ChatPerson(
            TraxPrincipalId.Qualify(Trax.Api.Auth.ApiKey.ApiKeyDefaults.SchemeName, p.Id),
            p.Name
        ))
    )
);

getChatRoomPeople(input: { chatRoomId }) lists everyone else in the directory, each marked as in the room or not, to a member of the room. ChatLifecycleHook publishes a completed invite as UserJoined, so every member's open room shows "Alice added Bob" as it happens. The invitee's room list is a query the client polls, so the room appears there on its next refresh.

Publishing a room's events

The hook runs after every train. It ignores all but the four chat mutations (an invite publishes as UserJoined, like a join), reads the room id out of the serialized output, and sends to that room's topic:

public class ChatLifecycleHook(ITopicEventSender eventSender) : ITrainLifecycleHook
{
    public async Task OnCompleted(Metadata metadata, CancellationToken ct)
    {
        if (!TrainEventTypes.TryGetValue(metadata.Name, out var eventType) || metadata.Output is null)
            return;
 
        using var doc = JsonDocument.Parse(metadata.Output);
        if (!doc.RootElement.TryGetProperty("chatRoomId", out var roomId))
            return;
 
        var chatEvent = new ChatSubscriptionEvent(
            roomId.GetGuid(), eventType, metadata.Output,
            metadata.EndTime ?? DateTime.UtcNow, metadata.ExternalId);
        await eventSender.SendAsync(ChatSubscriptions.Topic(roomId.GetGuid()), chatEvent, ct);
    }
}

A lifecycle hook fires for every train, so it filters by name itself: metadata.Name is the train's canonical name, the service interface's FullName (typeof(ISendMessageTrain).FullName). The hook does not depend on [TraxBroadcast], and no chat train carries it: a broadcast train's onTrainCompleted delivers every run's output to every caller the train admits, so every user would read every room's messages. Trax's lifecycle subscriptions therefore refuse a chat user, and onChatEvent is the only feed. metadata.Output is camelCase JSON and starts with a "$id" property, which the client ignores. The hook is registered with AddLifecycleHook<ChatLifecycleHookFactory>(), a factory that builds it with ActivatorUtilities, so it can take ITopicEventSender from the container.

The subscription field

[ExtendObjectType("LifecycleSubscriptions")]
public class ChatSubscriptions
{
    public static string Topic(Guid chatRoomId) => $"ChatRoom:{chatRoomId}";
 
    [TraxAuthorize(Roles = "User")]
    [Subscribe(With = nameof(SubscribeToChatEventAsync))]
    public ChatSubscriptionEvent OnChatEvent(Guid chatRoomId, [EventMessage] ChatSubscriptionEvent message)
        => message;
 
    public async ValueTask<ISourceStream<ChatSubscriptionEvent>> SubscribeToChatEventAsync(
        Guid chatRoomId, TraxCaller caller, ChatDbContext db,
        ITopicEventReceiver receiver, CancellationToken cancellationToken)
    {
        var userId = caller.Principal?.Id;
        var isParticipant = userId is not null && await db.ChatParticipants.AnyAsync(
            p => p.ChatRoomId == chatRoomId && p.UserId == userId, cancellationToken);
        if (!isParticipant)
            throw new GraphQLException(ErrorBuilder.New()
                .SetMessage("Not authorized.").SetCode("TRAX_AUTHORIZATION").Build());
 
        return await receiver.SubscribeAsync<ChatSubscriptionEvent>(Topic(chatRoomId), cancellationToken);
    }
}

Three things in it are load-bearing:

  • The target name. Trax's subscription root is named LifecycleSubscriptions. An extension of OperationTypeNames.Subscription ("Subscription") targets a type that does not exist, and HotChocolate drops it without an error, so the field is simply missing from the schema. The sample shipped that way until an E2E test subscribed for real.
  • The posture. A field added to a root type inherits no gate, so Trax refuses to start a host whose extension field declares neither [TraxAuthorize] nor [TraxAllowAnonymous].
  • The subscribe resolver. [TraxAuthorize] decides who may use the field at all; which rooms a caller may listen to is the resolver's job, checked once when the subscription is made. The method named by Subscribe(With = ...) does not become a field of its own.

See Subscriptions: your own subscription fields for the general recipe.

The browser client

Apollo splits traffic: HTTP for queries and mutations with an X-Api-Key header, graphql-ws for subscriptions with the key in connectionParams. Browsers cannot set a header on a WebSocket upgrade, so the key must be in the payload, under apiKey or authToken. A key under any other name (the client once sent "X-Api-Key") leaves the socket without a credential, and it is closed with 4403.

Tests

dotnet test tests/Trax.Samples.ChatService.Tests   # junctions and the hook, in memory (40 tests)
dotnet test tests/Trax.Samples.ChatService.E2E     # the real host over HTTP and WebSocket, SQLite (53 tests)
ClassProves
ChatEventSubscriptionTestsonChatEvent is in the served schema, reaches the sender and another participant with the sender's identity, carries nothing from another room, refuses a non-participant and closes an anonymous socket with 4403; a non-participant receives none of a room's messages on any lifecycle field
ChatCallerIdentityTestsno anonymous operation, no reading or posting in a room you have not joined, a message is stored as its sender, rooms list only your own
LifecycleSubscriptionTestsTrax's own lifecycle fields (onTrainStarted, onTrainCompleted, onTrainFailed, onTrainCancelled, onTrainStateChanged) refuse a chat user, because no chat train is [TraxBroadcast]

The subscription tests never wait on a fixed delay: they keep sending until the first event arrives, which proves the subscription is live, then assert on the next one.

SDK Reference

Subscriptions | TraxBroadcast | AddLifecycleHook | TraxAuthorize | TraxCaller | Injecting TraxPrincipal | AddTraxApiKeyAuth | AddTraxGraphQL