From ac835f8b9396b641b525669249fdb4a1e2a50c99 Mon Sep 17 00:00:00 2001 From: James Maes Date: Thu, 5 Nov 2026 15:39:39 -0600 Subject: [PATCH] feat(realtime): add printer-approval-v1 and the PRINTERS frame for extension 2.4.0 Add the printer-approval-v1 HELLO capability (maxItems 5), extend ControlCapability and the historical control-poll/management capability arrays to four entries, and define RealtimePrinters, RealtimePrintersPayload and PrinterApprovalState with the union entry. The server sends PRINTERS only on connections that advertised the capability, after each accepted INVENTORY and on every approval change; it is not acknowledged and carries no job data. Add the PRINTERS, five-capability hello and 2.4.0 discovery fixtures, cover the new frame in the directional and negative verifier checks, document the frame, the 2.4.0 registry row and the negotiation rule, and record distribution 1.1.0 as unreleased. Wire payload v1, the subprotocol and every 2.3.0 fixture are unchanged. 79 definitions compile and 82 fixtures pass. Co-Authored-By: Claude Fable 5.1 Signed-off-by: James Maes --- CHANGELOG.md | 18 +++++ README.md | 7 ++ docs/REALTIME.md | 25 ++++++- docs/VERSIONS.md | 17 +++-- ...ting-realtime-extension-240.discovery.json | 5 ++ fixtures/realtime-approval-hello.message.json | 27 +++++++ fixtures/realtime-printers.message.json | 16 +++++ package-lock.json | 4 +- package.json | 2 +- schemas/protocol.schema.json | 70 +++++++++++++++++-- tools/fixture-types.mjs | 3 + tools/verify-realtime.mjs | 41 ++++++++++- tools/verify-schema.mjs | 2 + 13 files changed, 223 insertions(+), 14 deletions(-) create mode 100644 fixtures/printing-realtime-extension-240.discovery.json create mode 100644 fixtures/realtime-approval-hello.message.json create mode 100644 fixtures/realtime-printers.message.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 92eb44a..f7bec30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 1.1.0 — unreleased + +Additive contract for printing semantic extension **2.4.0**. Wire payload stays +**v1** and the subprotocol stays `qqq-printing.v1`; every 2.3.0 fixture is unchanged. + +- Add the `printer-approval-v1` capability to `RealtimeHelloPayload` (`maxItems` 5) + and `ControlCapability`; the historical control poll and management + `Device.control` accept four non-realtime capabilities. +- Add the server → client `PRINTERS` frame (`RealtimePrinters`, + `RealtimePrintersPayload`, `PrinterApprovalState`) carrying the complete approval + state of the device's latest accepted inventory; sent only to connections that + advertised `printer-approval-v1`, after each accepted INVENTORY and on every + approval change. Not acknowledged, not durable, no job or document data. +- Add `realtime-printers.message.json`, a five-capability hello and a 2.4.0 + discovery fixture; the verifiers cover the directional frame, bounds and + required fields. See [REALTIME.md](docs/REALTIME.md#printer-approval) and the + [version registry](docs/VERSIONS.md). + ## 1.0.0 Distribution version 1.0.0 of the contract and fixtures from 0.4.0-rc.1. The wire diff --git a/README.md b/README.md index 3594d7e..8bc076c 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,13 @@ granting device management. Use the stable cohort above for these profiles; older 0.3.2 clients do not support them. Wire version 1 and the client-initiated realtime transport stay the same. +Source version **1.1.0** (unreleased) adds printing semantic extension **2.4.0**: +the `printer-approval-v1` HELLO capability and the server → client `PRINTERS` +frame, which reports each exposed printer's server approval over the +[realtime channel](docs/REALTIME.md#printer-approval). Clients advertise it only +after discovering a stable 2.4+ extension; a 2.3.0 server or a client without the +capability keeps the 2.3.0 wire unchanged. + ## Current connection and delivery 1. Configure the QQQ server origin in the Windows app. Discover its UI/UX API and sign in using the server's configured authentication method. diff --git a/docs/REALTIME.md b/docs/REALTIME.md index 52f1eed..34aa5d2 100644 --- a/docs/REALTIME.md +++ b/docs/REALTIME.md @@ -32,7 +32,9 @@ The client opens one persistent WebSocket to the configured QQQ origin: `X-QQQ-Device-Id`, `X-QQQ-Device-Token`, `X-QQQ-Client: api/qqq-print-agent` and the canonical same-origin `Origin`. - Capability: `realtime-v1` is mandatory in `HELLO`. Optional command capabilities - are the existing `REFRESH_INVENTORY` and `CANCEL_SPOOL`. + are the existing `REFRESH_INVENTORY` and `CANCEL_SPOOL`; optional feature + capabilities are `printer-media-v1` (extension 2.3.0) and `printer-approval-v1` + (extension 2.4.0), each gated by [discovery](VERSIONS.md#negotiation-rules). Do not add credentials to URLs, subprotocols, logs or frame payloads. Refuse cross-origin redirects and arbitrary content URLs. The existing session is @@ -68,6 +70,7 @@ compression that could bypass decoded-size limits are not supported. | Server → client | DESIRED_STATE | Existing `DeviceDesiredState` directly | | Server → client | SETTINGS | `RealtimeSettings` directly | | Server → client | JOB_AVAILABLE | Empty object; a coalescible wake-up, not a job or claim | +| Server → client | PRINTERS | `printers`: complete `printerId`/`enabled` approval of the latest accepted inventory; only after `printer-approval-v1` | | Server → client | COMMAND | Existing `DeviceCommand` directly | | Client → server | JOB_EVENT | `jobId`, existing `JobEvent` as `event` | | Client → server | COMMAND_RECEIPT | `commandId`, existing `DeviceCommandReceipt` as `receipt` | @@ -97,6 +100,26 @@ refreshing inventory or querying jobs/controls. Only one serialized writer owns socket sends; the receive/control path must not await document download, rendering, printer submission or UI work. +### Printer approval + +Extension 2.4.0 adds `printer-approval-v1`. A client advertises it only after +discovering a stable 2.4+ extension within major two. The server then sends +PRINTERS (`RealtimePrinters`) on that connection, through the same serialized +writer, after it has processed an accepted INVENTORY from that connection +(after that INVENTORY's ACK) and whenever the approval of any printer belonging +to the device changes by any server-side path. Nothing else triggers it; there +is no timer and the client does not acknowledge it. + +The payload lists every printer in the device's latest accepted inventory, for +this device only, as `printerId` plus boolean `enabled`, at most 200 entries. +`enabled:false` means the server knows the printer and has not approved it. A +printer the device no longer exposes is not listed; an empty inventory yields +`{ "printers": [] }`. Each frame replaces the previous one entirely, so frames +are idempotent and coalescible like JOB_AVAILABLE. The frame is not a durable +event and carries no job or document data. A client that did not advertise the +capability never receives it and keeps reading its existing authorized catalog +for approval; a server never sends it to a connection that did not advertise it. + ## Ordering, synchronization and replay 1. Authenticate the connection, then require HELLO before other application work. diff --git a/docs/VERSIONS.md b/docs/VERSIONS.md index 9e6c81a..9278ac8 100644 --- a/docs/VERSIONS.md +++ b/docs/VERSIONS.md @@ -5,7 +5,7 @@ compare them as if they were one version sequence. See the [stable cohort](INTEG | Number | Meaning | Current | | --- | --- | --- | -| Protocol distribution | Version of the specification and fixture archive | 1.0.0 | +| Protocol distribution | Version of the specification and fixture archive | 1.1.0 (unreleased; 1.0.0 published) | | Wire payload version | Shape of the JSON messages in this repository | 1 | | WebSocket subprotocol | Framing and message envelope | `qqq-printing.v1` | | Printing semantic extension | Server feature level discovered at `/qqq/v1/version` | see below | @@ -21,22 +21,31 @@ additive and keeps the earlier behavior. | 2.1.0 | Record, table-list and text print processes | server documentation | | 2.2.0 | Render-to-PDF process, header and footer values, content sections. No media negotiation | server documentation | | 2.3.0 | Least-privilege operator catalog, submission and request lookup; reported queue-default media and `printer-media-v1`; opt-in retained-source facade | [PRINTER-MEDIA.md](PRINTER-MEDIA.md) | +| 2.4.0 | `printer-approval-v1` and the server → client `PRINTERS` frame reporting each exposed printer's approval | [REALTIME.md](REALTIME.md#printer-approval) | ## Negotiation rules - A client advertises a capability only when it supports it **and** the discovered extension is a stable `major.minor.patch` version in major 2 at or above the - feature minimum. `printer-media-v1` has minimum 2.3.0. + feature minimum. `printer-media-v1` has minimum 2.3.0; `printer-approval-v1` + has minimum 2.4.0. - A client facing 2.0, 2.1 or 2.2 stays a three-capability peer (`realtime-v1`, - `REFRESH_INVENTORY`, `CANCEL_SPOOL`) and sends no media inventory. + `REFRESH_INVENTORY`, `CANCEL_SPOOL`) and sends no media inventory. A client + facing 2.3 is at most a four-capability peer and receives no `PRINTERS` frame. +- A server sends `PRINTERS` only on a connection whose HELLO advertised + `printer-approval-v1`, whatever extension version it serves; a client that did + not advertise it keeps its own approval fallback. - A server must not let a client that did not advertise `printer-media-v1` claim a job that carries a media selection, whatever extension version that client discovered. - Capabilities are re-evaluated on each new socket, so a server upgrade from 2.0 - to 2.3, or a downgrade from 2.3 to 2.2, takes effect at the next reconnect + to 2.4, or a downgrade from 2.4 to 2.2, takes effect at the next reconnect without a polling schedule. - Pre-release, ambiguous or malformed versions (`2.3.0-rc.1`, `2.3`, `02.3.0`, four-part values) never enable a feature. QBit 1.0.0 advertises native printing extension 2.3.0. Windows 1.0.1 is the paired stable client; their product versions do not replace capability discovery. +QBit 1.1.0 advertises 2.4.0 and pairs with Windows 1.2.0; a 2.4.0 server still +serves 1.0.1/1.1.x clients as four-capability peers, and a 1.2.0 client facing +2.3.0 does not advertise `printer-approval-v1`. diff --git a/fixtures/printing-realtime-extension-240.discovery.json b/fixtures/printing-realtime-extension-240.discovery.json new file mode 100644 index 0000000..c03b155 --- /dev/null +++ b/fixtures/printing-realtime-extension-240.discovery.json @@ -0,0 +1,5 @@ +{ + "name": "printing", + "version": "2.4.0", + "pathPrefix": "/qbit/printing" +} diff --git a/fixtures/realtime-approval-hello.message.json b/fixtures/realtime-approval-hello.message.json new file mode 100644 index 0000000..4c1ea89 --- /dev/null +++ b/fixtures/realtime-approval-hello.message.json @@ -0,0 +1,27 @@ +{ + "type": "HELLO", + "messageId": "hello-connection-1", + "payload": { + "agentVersion": "1.2.0", + "capabilities": [ + "realtime-v1", + "REFRESH_INVENTORY", + "CANCEL_SPOOL", + "printer-media-v1", + "printer-approval-v1" + ], + "lastAppliedRevision": 0, + "lastSettingsRevision": 0, + "limits": { + "maxPdfBytes": 536870912, + "maxZplBytes": 104857600, + "maxFrameBytes": 16777216, + "maxQueuedFrames": 4096, + "maxQueuedFrameBytes": 67108864, + "maxConcurrentDownloads": 16, + "maxConcurrentPrints": 16, + "maxStagedBytes": 4294967296, + "maxCopies": 32767 + } + } +} diff --git a/fixtures/realtime-printers.message.json b/fixtures/realtime-printers.message.json new file mode 100644 index 0000000..fb4ffa4 --- /dev/null +++ b/fixtures/realtime-printers.message.json @@ -0,0 +1,16 @@ +{ + "type": "PRINTERS", + "messageId": "printers-connection-1", + "payload": { + "printers": [ + { + "printerId": "laser-example", + "enabled": true + }, + { + "printerId": "label-example", + "enabled": false + } + ] + } +} diff --git a/package-lock.json b/package-lock.json index d533324..343ef65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qrun-io/qqq-print-protocol", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qrun-io/qqq-print-protocol", - "version": "1.0.0", + "version": "1.1.0", "license": "Apache-2.0", "devDependencies": { "@eslint/js": "10.0.1", diff --git a/package.json b/package.json index a2ce2fc..36099f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qrun-io/qqq-print-protocol", - "version": "1.0.0", + "version": "1.1.0", "private": true, "description": "Versioned wire contract and compatibility fixtures for QQQ cloud printing.", "license": "Apache-2.0", diff --git a/schemas/protocol.schema.json b/schemas/protocol.schema.json index ae7be6a..2a58947 100644 --- a/schemas/protocol.schema.json +++ b/schemas/protocol.schema.json @@ -993,7 +993,8 @@ "enum": [ "REFRESH_INVENTORY", "CANCEL_SPOOL", - "printer-media-v1" + "printer-media-v1", + "printer-approval-v1" ] }, "ProtocolVersionResponse": { @@ -1060,7 +1061,7 @@ "items": { "$ref": "#/$defs/ControlCapability" }, - "maxItems": 3, + "maxItems": 4, "uniqueItems": true }, "lastPollAt": { @@ -1099,7 +1100,7 @@ "items": { "$ref": "#/$defs/ControlCapability" }, - "maxItems": 3, + "maxItems": 4, "uniqueItems": true }, "lastAppliedRevision": { @@ -1718,11 +1719,12 @@ "realtime-v1", "REFRESH_INVENTORY", "CANCEL_SPOOL", - "printer-media-v1" + "printer-media-v1", + "printer-approval-v1" ] }, "minItems": 1, - "maxItems": 4, + "maxItems": 5, "uniqueItems": true, "contains": { "const": "realtime-v1" @@ -2068,6 +2070,61 @@ }, "additionalProperties": false }, + "PrinterApprovalState": { + "description": "Server approval of one printer in the device's latest accepted inventory; enabled:false means known but not approved.", + "type": "object", + "required": [ + "printerId", + "enabled" + ], + "properties": { + "printerId": { + "$ref": "#/$defs/Id" + }, + "enabled": { + "type": "boolean" + } + }, + "additionalProperties": true + }, + "RealtimePrintersPayload": { + "description": "Complete approval state of every printer the device currently exposes; each frame replaces the previous one.", + "type": "object", + "required": [ + "printers" + ], + "properties": { + "printers": { + "type": "array", + "items": { + "$ref": "#/$defs/PrinterApprovalState" + }, + "maxItems": 200 + } + }, + "additionalProperties": true + }, + "RealtimePrinters": { + "description": "Sent only on connections whose HELLO advertised printer-approval-v1; not acknowledged.", + "type": "object", + "required": [ + "type", + "messageId", + "payload" + ], + "properties": { + "type": { + "const": "PRINTERS" + }, + "messageId": { + "$ref": "#/$defs/Id" + }, + "payload": { + "$ref": "#/$defs/RealtimePrintersPayload" + } + }, + "additionalProperties": false + }, "RealtimeCommand": { "type": "object", "required": [ @@ -2184,6 +2241,9 @@ { "$ref": "#/$defs/RealtimeJobAvailable" }, + { + "$ref": "#/$defs/RealtimePrinters" + }, { "$ref": "#/$defs/RealtimeCommand" }, diff --git a/tools/fixture-types.mjs b/tools/fixture-types.mjs index 5821a3c..92f5842 100644 --- a/tools/fixture-types.mjs +++ b/tools/fixture-types.mjs @@ -4,6 +4,7 @@ // Shared correspondence between wire examples and schema definitions. export const fixtureTypes = { "printing-realtime-extension.discovery.json": "PrintingRealtimeExtensionDiscovery", + "printing-realtime-extension-240.discovery.json": "PrintingRealtimeExtensionDiscovery", "version-legacy.response.json": "ProtocolVersionResponse", "version-controls.response.json": "ProtocolVersionResponse", "control-poll.request.json": "DeviceControlPollRequest", @@ -82,4 +83,6 @@ export const fixtureTypes = { "operator-printers.response.json": "OperatorPrinterList", "operator-submit.request.json": "OperatorSubmitJobRequest", "realtime-media-hello.message.json": "RealtimeHello", + "realtime-approval-hello.message.json": "RealtimeHello", + "realtime-printers.message.json": "RealtimePrinters", }; diff --git a/tools/verify-realtime.mjs b/tools/verify-realtime.mjs index 4699a09..aa5357f 100644 --- a/tools/verify-realtime.mjs +++ b/tools/verify-realtime.mjs @@ -27,6 +27,10 @@ for(const version of ["1.0.0", "1.1.0", "3.0.0", "2.0", "2.01.0", "2.0.0-rc.1", } assert(!valid("PrintingRealtimeExtensionDiscovery", { ...extension, name: "another-extension" })); assert(!valid("PrintingRealtimeExtensionDiscovery", { ...extension, pathPrefix: "/printing/v1" })); +const approvalExtension = fixture("printing-realtime-extension-240.discovery.json"); +assert(valid("PrintingRealtimeExtensionDiscovery", approvalExtension), ajv.errorsText()); +assert.equal(approvalExtension["version"], "2.4.0", "printer-approval-v1 has feature minimum 2.4.0."); +assert.deepEqual({ ...approvalExtension, version: extension["version"] }, extension, "2.4.0 keeps the 2.x descriptor name and path prefix."); /******************************************************************************* ** Cross-field policy relationships require semantic checks beyond JSON Schema. @@ -91,8 +95,23 @@ assert(!valid("RealtimeHello", { ...hello, messageId: "hello:unsafe-prefix" })); assert(!valid("RealtimeHello", { ...hello, token: "not-a-real-token" })); assert(!valid("RealtimeClientMessage", { type: "RUN_SHELL", messageId: "unsupported-1", payload: {} })); +/******************************************************************************* + ** Capability hellos: the 2.3.0 four-capability and 2.4.0 five-capability peers + ** both remain valid; the list is bounded, unique and closed. + *******************************************************************************/ +const mediaHello = /** @type {typeof hello} */ (/** @type {unknown} */ (fixture("realtime-media-hello.message.json"))); +const approvalHello = /** @type {typeof import("../fixtures/realtime-approval-hello.message.json")} */ (/** @type {unknown} */ (fixture("realtime-approval-hello.message.json"))); +assert(valid("RealtimeHello", mediaHello), ajv.errorsText()); +assert(valid("RealtimeHello", approvalHello), ajv.errorsText()); +assert.equal(approvalHello.payload.capabilities.length, 5); +assert(approvalHello.payload.capabilities.includes("printer-approval-v1")); +assert.deepEqual(approvalHello.payload.limits, hello.payload.limits, "Approval negotiation does not change declared ceilings."); +assert(!valid("RealtimeHello", { ...approvalHello, payload: { ...approvalHello.payload, capabilities: [...approvalHello.payload.capabilities, "printer-approval-v1"] } }), "Duplicate capability accepted"); +assert(!valid("RealtimeHello", { ...approvalHello, payload: { ...approvalHello.payload, capabilities: [...approvalHello.payload.capabilities, "printer-approval-v2"] } }), "Unknown sixth capability accepted"); +assert(!valid("RealtimeHello", { ...hello, payload: { ...hello.payload, capabilities: ["printer-approval-v1"] } }), "realtime-v1 remains mandatory"); + const clientFixtures = ["hello", "state-applied", "job-event", "command-receipt", "heartbeat", "inventory", "ping"]; -const serverFixtures = ["welcome", "desired-state", "job-available", "command", "settings", "ack", "error", "pong"]; +const serverFixtures = ["welcome", "desired-state", "job-available", "printers", "command", "settings", "ack", "error", "pong"]; for(const [files, accepted, rejected] of /** @type {[string[],string,string][]} */ ([ [clientFixtures, "RealtimeClientMessage", "RealtimeServerMessage"], [serverFixtures, "RealtimeServerMessage", "RealtimeClientMessage"], @@ -110,6 +129,26 @@ for(const [files, accepted, rejected] of /** @type {[string[],string,string][]} assert(!valid(accepted, { ...message, contentUrl: "https://unrelated.invalid/document" })); } } +/******************************************************************************* + ** PRINTERS carries the complete approval state of the latest accepted inventory; + ** entries are bounded, both fields are required and the envelope stays closed. + *******************************************************************************/ +const printers = /** @type {typeof import("../fixtures/realtime-printers.message.json")} */ (/** @type {unknown} */ (fixture("realtime-printers.message.json"))); +assert.equal(printers.payload.printers.length, 2); +assert.deepEqual(printers.payload.printers.map(printer => printer.enabled), [true, false], "Fixture shows one approved and one awaiting printer."); +assert(valid("RealtimePrinters", { ...printers, payload: { printers: [] } }), "An empty inventory yields an empty approval list."); +assert(valid("RealtimePrinters", { ...printers, payload: { printers: Array(200).fill(printers.payload.printers[0]) } })); +assert(!valid("RealtimePrinters", { ...printers, payload: { printers: Array(201).fill(printers.payload.printers[0]) } })); +assert(valid("RealtimePrinters", { ...printers, payload: { ...printers.payload, futureField: true } }), "Additive payload fields remain compatible."); +assert(valid("RealtimePrinters", { ...printers, payload: { printers: [{ ...printers.payload.printers[0], futureField: true }] } })); +assert(!valid("RealtimePrinters", { ...printers, payload: { printers: [{ printerId: "laser-example" }] } }), "enabled is required"); +assert(!valid("RealtimePrinters", { ...printers, payload: { printers: [{ enabled: true }] } }), "printerId is required"); +assert(!valid("RealtimePrinters", { ...printers, payload: { printers: [{ printerId: "laser-example", enabled: "true" }] } }), "enabled must be boolean"); +assert(!valid("RealtimePrinters", { ...printers, payload: { printers: [{ printerId: "", enabled: true }] } })); +assert(!valid("RealtimePrinters", { ...printers, payload: {} }), "printers list is required"); +assert(!valid("RealtimePrinters", { ...printers, type: "JOB_AVAILABLE" })); +assert(!valid("RealtimeJobAvailable", { ...printers, type: "JOB_AVAILABLE" }), "PRINTERS payload is not the empty wake-up"); + const event = fixture("realtime-job-event.message.json"); assert.deepEqual(fixture("realtime-job-event-replay.message.json"), event, "Lost ACK replay preserves the exact envelope."); assert.equal((/** @type {typeof import("../fixtures/realtime-ack.message.json")} */ (/** @type {unknown} */ (fixture("realtime-ack.message.json")))).payload.acknowledgedMessageId, event["messageId"]); diff --git a/tools/verify-schema.mjs b/tools/verify-schema.mjs index bb646cb..5c65a04 100644 --- a/tools/verify-schema.mjs +++ b/tools/verify-schema.mjs @@ -77,6 +77,8 @@ const poll = control("control-poll.request.json"); assert(!validControl("DeviceControlPollRequest", { ...poll, controlVersion: 2 })); assert(!validControl("DeviceControlPollRequest", { ...poll, capabilities: ["CANCEL_SPOOL", "CANCEL_SPOOL"] })); assert(!validControl("DeviceControlPollRequest", { ...poll, capabilities: ["RESTART_SERVICE"] })); +assert(validControl("DeviceControlPollRequest", { ...poll, capabilities: ["REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1", "printer-approval-v1"] }), "Historical control poll accepts every non-realtime capability."); +assert(!validControl("DeviceControlPollRequest", { ...poll, capabilities: ["REFRESH_INVENTORY", "CANCEL_SPOOL", "printer-media-v1", "printer-approval-v1", "printer-approval-v1"] })); assert(!validControl("DeviceControlPollRequest", { ...poll, lastAppliedRevision: -1 })); assert(!validControl("DeviceControlPollRequest", { ...poll, lastAppliedRevision: 9007199254740992 })); const pollResult = /** @type {typeof import("../fixtures/control-poll.response.json")} */ (/** @type {unknown} */ (control("control-poll.response.json"))); -- 2.54.0