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")));