diff --git a/CHANGELOG.md b/CHANGELOG.md
index b5f0778..6110d70 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,15 @@
# Changelog
+## 1.1.1
+
+Realtime upgrade fix; no contract change. The WebSocket callbacks read the device
+identifier from the socket context's headers, which are matched case-sensitively, while the
+HTTP upgrade matches them case-insensitively. Behind an ingress that lowercases header names
+the device resolved to null on connect and the connection failed before WELCOME (Forge had
+carried a local patch for this on 1.0.0). The device is now resolved once on the authorized
+upgrade and carried on the connection; a regression test connects with lowercase header
+names and verifies WELCOME, an acknowledged inventory, the PRINTERS push and socket replacement.
+
## 1.1.0
Native printing extension **2.4.0** (additive; wire payload v1 and the agent
diff --git a/README.md b/README.md
index 3d6b2ac..4ea7a87 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@ Cloud printing for QQQ 4.1+, paired with the [Windows AppKit connector](https://
**Released:** [QQQ Printing 1.1.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.1.0), with a library JAR, matching POM and `SHA256SUMS` ([1.0.0](https://git.kof22.com/QRun-IO/qbit-printing/releases/tag/v1.0.0) remains available for rollback). The same JAR/POM are published in the QRun-IO Gitea Maven registry. Gitea requires sign-in for downloads. The QBit itself is not published to Maven Central and is not a standalone executable/fat JAR. See [installation and release access](docs/RELEASES.md).
-**Runtime:** Java 21 and QQQ **4.1.0-RC.21**, published on Maven Central with native session login. The optional native UI demo uses QQQ frontend **1.0.0-RC.37**. Product version 1.1.0, native extension 2.4.0 and wire payload v1 are separate version contracts.
+**Runtime:** Java 21 and QQQ **4.1.0-RC.21**, published on Maven Central with native session login. The optional native UI demo uses QQQ frontend **1.0.0-RC.37**. Product version 1.1.1, native extension 2.4.0 and wire payload v1 are separate version contracts.
Implemented: single-use enrollment, device credentials, inventory/approval, PDF/ZPL submission, durable JDBC queue, idempotency, claim/content/events, native QQQ setup/test-print processes, read-only device/printer/job tables and a durable enqueue effect target.
diff --git a/docs/SESSION-STATE.md b/docs/SESSION-STATE.md
index daad597..736d178 100644
--- a/docs/SESSION-STATE.md
+++ b/docs/SESSION-STATE.md
@@ -1,4 +1,8 @@
-# Session state — 2026-11-05
+# Session state — 2026-11-06
+
+## In progress — 1.1.1: realtime upgrade header-case fix
+
+Forge's adoption qualification of released 1.1.0 (`~/.cache/gg-forge-printing-cohort-20261106/HANDOFF.md`) reproduced a connection failure before WELCOME when the WebSocket upgrade headers arrive lowercase: the socket callbacks read `X-QQQ-Device-Id` from the WebSocket context, whose header lookup is case-sensitive, so the device was null and `channels.put` threw. Forge had carried a local patch on 1.0.0. 1.1.1 resolves the device once on the authorized HTTP upgrade (case-insensitive) and carries it as a connection attribute; `PrintingRealtimeTest.lowercaseUpgradeHeadersReachWelcomeAndFrames` fails on the old code (same NullPointerException) and passes on the fix. No contract change. Release 1.1.1 follows the usual path; 1.1.0 must not be adopted.
## Current — 1.1.0 released (2026-11-05)
diff --git a/pom.xml b/pom.xml
index b4f759b..2fd0766 100644
--- a/pom.xml
+++ b/pom.xml
@@ -9,7 +9,7 @@
com.kingsrookqbit-build-parent2.0.0
com.kingsrook.qbits
qbit-printing
- 1.1.0
+ 1.1.1
QQQ Printing
Apache License, Version 2.0https://www.apache.org/licenses/LICENSE-2.0
diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingRealtimeRoutes.java b/src/main/java/com/kingsrook/qbits/printing/PrintingRealtimeRoutes.java
index d8b9b18..60ad0d5 100644
--- a/src/main/java/com/kingsrook/qbits/printing/PrintingRealtimeRoutes.java
+++ b/src/main/java/com/kingsrook/qbits/printing/PrintingRealtimeRoutes.java
@@ -27,6 +27,7 @@ import java.time.Duration;
import java.time.Instant;
import java.util.ArrayDeque;
import java.util.Map;
+import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
@@ -59,6 +60,8 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
{
public static final String PATH = "/qqq/v1/qbit/printing/device/realtime";
public static final String SUBPROTOCOL = "qqq-printing.v1";
+ /** Device identifier resolved on the authorized HTTP upgrade and carried to the WebSocket callbacks. */
+ static final String DEVICE_ATTRIBUTE = "printingRealtimeDevice";
private final PrintingService service;
private final Map channels = new ConcurrentHashMap<>();
private final ExecutorService workers = Executors.newVirtualThreadPerTaskExecutor();
@@ -97,6 +100,10 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
{
validateUpgrade(context);
authorize(context);
+ // The HTTP upgrade context resolves header names case-insensitively; the WebSocket context does not, and an ingress
+ // that lowercases header names would otherwise leave the device null on connect. Resolve it once here, after
+ // authorization, and carry it on the connection.
+ context.attribute(DEVICE_ATTRIBUTE, Objects.requireNonNull(context.header(PrintingAgentAuthorization.DEVICE_HEADER), "authorized upgrade without a device header"));
String cookieName = context.cookie("sessionId") != null && !context.cookie("sessionId").isBlank() ? "sessionId" : "sessionUUID";
context.attribute("printingRealtimeCredential", new Credential(cookieName, context.cookie(cookieName), context.header(PrintingAgentAuthorization.TOKEN_HEADER), context.header("X-QQQ-Client")));
}
@@ -113,7 +120,7 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
{
socket.onConnect(context ->
{
- String device = context.header(PrintingAgentAuthorization.DEVICE_HEADER);
+ String device = Objects.requireNonNull(context.attribute(DEVICE_ATTRIBUTE), "WebSocket connected without the authorized device attribute");
Channel next = new Channel(context, device, context.attribute("printingRealtimeCredential"));
next.policy = service.realtimePolicy();
Channel previous = channels.put(device, next);
@@ -149,7 +156,8 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf
@SuppressWarnings("ReferenceEquality")
private @Nullable Channel current(WsContext context)
{
- Channel channel = channels.get(context.header(PrintingAgentAuthorization.DEVICE_HEADER));
+ String device = context.attribute(DEVICE_ATTRIBUTE);
+ Channel channel = device == null ? null : channels.get(device);
return channel != null && channel.socket.session == context.session ? channel : null;
}
diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingRealtimeTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingRealtimeTest.java
index 48d0f78..c6ebdf2 100644
--- a/src/test/java/com/kingsrook/qbits/printing/PrintingRealtimeTest.java
+++ b/src/test/java/com/kingsrook/qbits/printing/PrintingRealtimeTest.java
@@ -57,6 +57,55 @@ public class PrintingRealtimeTest
Path directory;
+ /** Lowercase upgrade header names (an ingress that normalizes them) must still resolve the device for every socket callback. */
+ @Test
+ void lowercaseUpgradeHeadersReachWelcomeAndFrames() throws Exception
+ {
+ String jdbc = "jdbc:h2:file:" + directory.resolve("lowercase-realtime").toString().replace('\\', '/');
+ PrintingLocalHost host = new PrintingLocalHost(new PrintingLocalSettings(jdbc, "sa", "", "demo", 0, "synthetic-management-token-long-enough", "operator", "synthetic-realtime-password-1234"));
+ AtomicReference app = new AtomicReference<>();
+ QApplicationLauncher launcher = QApplicationLauncher.run(host, new QApplicationLauncherConfig().withRegisterShutdownHook(false).withServerCustomizer(server ->
+ {
+ host.configure(server, false);
+ server.withJavalinConfigurationCustomizer(app::set);
+ }));
+ PrintingService service = Objects.requireNonNull(launcher.getQInstance().getQBits().values().stream().map(value -> value.getConfig()).filter(PrintingQBitConfig.class::isInstance)
+ .map(PrintingQBitConfig.class::cast).findFirst().orElseThrow().getService(), "The printing service was not configured");
+ String base = "http://127.0.0.1:" + Objects.requireNonNull(app.get(), "Test HTTP server was not started").port();
+ JSONObject enrolled = service.enrollForUser(service.createEnrollment("default", "Lowercase fixture").put("deviceName", "Lowercase fixture").put("agentVersion", "test"), "operator");
+ String device = enrolled.getString("deviceId");
+ String proof = enrolled.getString("deviceToken");
+ try(HttpClient http = HttpClient.newHttpClient())
+ {
+ String cookie = login(http, base);
+ // An ingress that lowercases header names must not leave the device unresolved on connect: WELCOME, an acknowledged
+ // inventory and the capability-gated PRINTERS push all work exactly as with the canonical spelling.
+ try(PrintingSocketFixture lowercase = PrintingSocketFixture.open(http, base, cookie, device, proof, true))
+ {
+ lowercase.initialize(new JSONArray().put("realtime-v1").put("REFRESH_INVENTORY").put("CANCEL_SPOOL").put("printer-approval-v1"));
+ lowercase.send("INVENTORY", "inventory-lower", new JSONObject().put("printers", new JSONArray().put(new JSONObject().put("printerId", "queue").put("name", "Synthetic queue")
+ .put("driverName", "Synthetic").put("portName", "SYNTHETIC").put("status", "READY").put("formats", new JSONArray().put("PDF")))));
+ assertEquals("inventory-lower", lowercase.take("ACK").getJSONObject("payload").getString("acknowledgedMessageId"));
+ JSONObject printers = lowercase.take("PRINTERS").getJSONObject("payload");
+ assertEquals("queue", printers.getJSONArray("printers").getJSONObject(0).getString("printerId"));
+ assertFalse(printers.getJSONArray("printers").getJSONObject(0).getBoolean("enabled"));
+ // A replacement socket with canonical spelling supersedes the lowercase one through the same device key.
+ try(PrintingSocketFixture canonical = PrintingSocketFixture.connect(http, base, cookie, device, proof))
+ {
+ assertTrue(lowercase.closed.get(10, TimeUnit.SECONDS) > 0);
+ canonical.send("PING", "ping-1", new JSONObject());
+ canonical.take("PONG");
+ }
+ }
+ }
+ finally
+ {
+ launcher.stop();
+ }
+ }
+
+
+
/** An old client cannot ignore media intent, including after replacing a capable connection. */
@Test
void mediaCapabilityIsReplacedOnEveryAuthenticatedHello() throws Exception
diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingSocketFixture.java b/src/test/java/com/kingsrook/qbits/printing/PrintingSocketFixture.java
index 27d5c8d..726572c 100644
--- a/src/test/java/com/kingsrook/qbits/printing/PrintingSocketFixture.java
+++ b/src/test/java/com/kingsrook/qbits/printing/PrintingSocketFixture.java
@@ -55,11 +55,20 @@ final class PrintingSocketFixture implements WebSocket.Listener, AutoCloseable
/** Separate upgrade from HELLO so a commit in that interval can be tested deterministically. */
static PrintingSocketFixture open(HttpClient client, String base, String cookie, String device, String proof) throws Exception
+ {
+ return open(client, base, cookie, device, proof, false);
+ }
+
+
+ /** Lowercase header names model an ingress that normalizes them, which the device lookup must survive. */
+ static PrintingSocketFixture open(HttpClient client, String base, String cookie, String device, String proof, boolean lowercaseHeaders) throws Exception
{
PrintingSocketFixture fixture = new PrintingSocketFixture();
+ String deviceHeader = lowercaseHeaders ? PrintingAgentAuthorization.DEVICE_HEADER.toLowerCase(java.util.Locale.ROOT) : PrintingAgentAuthorization.DEVICE_HEADER;
+ String tokenHeader = lowercaseHeaders ? PrintingAgentAuthorization.TOKEN_HEADER.toLowerCase(java.util.Locale.ROOT) : PrintingAgentAuthorization.TOKEN_HEADER;
fixture.socket = client.newWebSocketBuilder().subprotocols(PrintingRealtimeRoutes.SUBPROTOCOL).header("Origin", base)
- .header("X-QQQ-Client", "api/qqq-print-agent")
- .header("Cookie", cookie).header(PrintingAgentAuthorization.DEVICE_HEADER, device).header(PrintingAgentAuthorization.TOKEN_HEADER, proof)
+ .header(lowercaseHeaders ? "x-qqq-client" : "X-QQQ-Client", "api/qqq-print-agent")
+ .header("Cookie", cookie).header(deviceHeader, device).header(tokenHeader, proof)
.buildAsync(URI.create(base.replace("http:", "ws:") + PrintingRealtimeRoutes.PATH), fixture).get(10, TimeUnit.SECONDS);
return fixture;
}