feat: define native printing authentication and device controls

Add bounded desired-state controls, command receipts and replay/expiry contracts.
Specify the standard QQQ semantic session transport and authoritative device
binding, preserving explicit legacy wire compatibility. Include shared fixtures
and package both consumer contracts in the 0.2.0-rc.1 candidate bundle.

Validation: 42 schema definitions and 54 fixtures pass, including invalid-input
and integrity checks; bundle contents and git diff --check verified. Consumers
and real browser/provider deployment remain separately qualified.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
This commit is contained in:
james.maesandCodex committed 2026-10-31 08:31:08 -05:00
1 parent c33244913d
commit cc3c4e50e8
45 files changed
+1255 -15

No files matched your search

+12 -1
View File
@@ -1,5 +1,17 @@
# Changelog
## Unreleased — proposed 0.2.0-rc.1
- Add optional wire-v1 device-control discovery and independent control polling.
- Specify server dispatch/connection controls, irreversible credential revocation,
inventory refresh, queued-only cancellation, and exact owned spool cancellation.
- Define expiring commands, durable replayable receipts, bounded history, and
timestamped admin requests that cannot execute again after retention pruning.
- Add synthetic schema fixtures and invalid-input checks; physical output and
consumer implementation acceptance remain separate gates.
- Specify the native semantic printing API, standard QQQ session authorization,
secondary bound-device proof and authoritative session identity fixtures.
## 0.1.4-rc.1 — 2026-10-30
- Gate publication on security checks, promote the tested archive and verify packaged checksums.
@@ -13,7 +25,6 @@
- Existing requests remain valid; incompatible ZPL media, null/unknown profiles and missing request identity are rejected.
- Schema gate covers 29 definitions and 22 fixtures, including explicit 300-dpi test requests.
## 0.1.2-rc.1 — 2026-10-30
- Optional v1 heartbeat device profile and printer display-name/description/location fields, preserving physical queue names and identifiers.
+8 -1
View File
@@ -23,7 +23,7 @@ The release archive and `SHA256SUMS` are written to `dist/`. Schema/fixture cons
## Conventions
Base path: `/printing/v1`. JSON uses UTF-8, camelCase properties, uppercase enum strings, and RFC 3339 UTC timestamps. IDs are opaque case-sensitive strings using URL-safe characters. Consumers ignore unknown response fields for compatible additions. Breaking changes require a new path version. Configure the complete server origin; production requires HTTPS. Explicit localhost-only development may use HTTP.
Primary new-client base: `/qqq/v1/qbit/printing`, discovered as QQQ semantic extension `printing` version `1.0.0`, using standard QQQ user authentication and secondary device proof. See [QQQ authentication](docs/QQQ-AUTHENTICATION.md). The routes below describe explicit legacy compatibility at `/printing/v1`; native authentication failure never silently selects that transport. JSON uses UTF-8, camelCase properties, uppercase enum strings, and RFC 3339 UTC timestamps. IDs are opaque case-sensitive strings using URL-safe characters. Consumers ignore unknown response fields for compatible additions. Breaking changes require a new major contract. Configure the complete server origin; production requires HTTPS. Explicit localhost-only development may use HTTP.
Use `Content-Type: application/json` except the content-download response. All authenticated endpoints require `Authorization: Bearer <token>`. Device tokens and admin credentials are distinct; a device token must never authorize `/admin` operations. Enrollment uses only the single-use code in its request body. Never put credentials in query strings or log them.
@@ -49,6 +49,13 @@ The host application authenticates management requests and determines authorized
Device lists and job lists are scoped by host authorization. Listing pagination and destination/profile CRUD are beyond this first wire contract. Native application code may submit through the QBit service rather than the management HTTP API.
The additive [device-control contract](docs/DEVICE-CONTROLS.md) defines capability
discovery, dispatch pause/resume, operational disconnect/reconnect, permanent
credential revocation, inventory refresh, queued-job clearing, and exact owned
Windows spool cancellation. It uses durable request identities, bounded command
polling, and explicit receipts. This is source under development for a proposed
`0.2.0-rc.1` distribution; the published version above remains unchanged.
## DTOs
`schemas/protocol.schema.json` defines the DTOs under `$defs`. Validate a message against the corresponding `#/$defs/<Name>`. Example request/response files in `fixtures/` use synthetic identities and nonworking illustrative credentials only.
+208
View File
@@ -0,0 +1,208 @@
# Device controls — additive wire v1
This extension provides server dispatch controls and two bounded Windows agent
commands. Its additive payloads preserve existing print job states and old
request bodies. Package version `0.2.0-rc.1` is the proposed coordinated release;
this document does not claim publication, deployment, or production readiness.
## Discovery and routes
The primary transport is the discovered QQQ semantic extension at
`/qqq/v1/qbit/printing`, extension version `1.0.0`, using channel
`api/qqq-print-agent`. Standard QQQ session authentication and current user,
tenant, table, process and record permissions apply. Device calls additionally
send `X-QQQ-Device-Id` and `X-QQQ-Device-Token`; that secondary proof does not
replace the user session. See [QQQ authentication](QQQ-AUTHENTICATION.md).
Paths below are relative to that primary base, except `/version`, which describes
legacy wire discovery. Primary discovery uses `/qqq/v1/version` and its printing
extension advertisement. Compatibility transport retains `/printing/v1` with
host-authorized administrator scope and device bearer credentials. Compatibility
is explicit and is never a silent fallback for a missing or denied native route.
Neither request bodies nor cursor contents grant authority.
| Method and path | Request | Response |
| --- | --- | --- |
| GET `/version` | None | `ProtocolVersionResponse` |
| POST `/device/control/poll` | `DeviceControlPollRequest` | `DeviceControlPollResponse` |
| POST `/device/control/commands/{commandId}/receipt` | `DeviceCommandReceipt` | `Acknowledgment` |
| POST `/admin/devices/{deviceId}/actions` | `AdminDeviceActionRequest` | `DeviceActionRecord` |
| GET `/admin/devices/{deviceId}/commands` | Optional `limit`, `before` | `DeviceActionList` |
| GET `/admin/devices/{deviceId}/commands/{commandId}` | None | `DeviceActionRecord` |
All successful responses use HTTP 200. Version discovery adds optional
`capabilities:["device-control-v1"]`; `protocolVersion` remains integer `1`.
For explicit compatibility transport, an older version response without that capability establishes only
legacy support. A first poll returning 404 can establish legacy absence when the
server did not advertise the capability. A timeout, 401/403, 5xx, or malformed
response cannot establish legacy support. After control support is established,
a missing capability or 404 is a failure, never permission to bypass saved state.
Native printing extension `1.0.0` includes these controls; a native-route 404 is
always a configuration or compatibility failure, never legacy discovery.
The new poll reports `controlVersion:1`, `agentVersion`, `capabilities`, and
`lastAppliedRevision`. The two capability names are `REFRESH_INVENTORY` and
`CANCEL_SPOOL`; an empty list is valid for a state-only agent. A successful poll
establishes state-control support independently of these command capabilities.
An admin remote command unsupported by the last negotiated capabilities returns
409 `CONTROL_UNSUPPORTED`. A capable but offline device may have a pending
command; acceptance does not establish delivery or execution.
The optional management `Device.control` contains `supported`, `capabilities`,
optional `lastPollAt`, `lastAppliedRevision`, and `desiredState`. Missing control
metadata means support is unknown. `lastPollAt` and the observed revision must be
shown separately from the desired state; neither software version nor a saved
capability proves that an offline agent applied a new setting.
## Desired state and ongoing work
`desiredState` contains `revision`, `dispatchPaused`, and `connectionState`, whose
values are `CONNECTED` and `DISCONNECTED`. Initial state is revision 0, false,
and `CONNECTED`. Changes are committed atomically with their action record and
advance the revision. Revisions are nonnegative JSON-safe integers, at most
9007199254740991. A device reports a revision only after applying and durably
retaining that state; it cannot acknowledge a future server revision. The agent
must not replace newer persisted state with an older poll response.
The control loop runs independently of printing. Pausing or disconnecting gates
new claims and dispatch. It does not cancel CLAIMED or later work, erase local
journals, withdraw exposure selections, or discard in-flight job events. Claims
and server state changes are serialized so work claimed before the change remains
in flight. Before first synchronization or after restart, the agent retains the
last state and withholds new claims until valid state or explicit legacy support
has been established.
`DISCONNECTED` stops ordinary heartbeat, inventory publication, and new claims.
Authenticated control polling, command receipts, and in-flight job events remain
available, so `RECONNECT` can reach the device. `REFRESH_INVENTORY` waits for
`CONNECTED` or expires; it must not report success without publishing inventory.
Explicit `CANCEL_SPOOL` remains available over the control path while disconnected.
Reconnecting does not clear `dispatchPaused`; resuming does not change connection
state. These are independent controls.
Revocation invalidates the old credential permanently and blocks device routes,
including control polling. Resume and reconnect cannot restore it. Re-enrollment
uses the separate existing enrollment flow and a new local connection scope.
401/403 blocks ordinary traffic and requires authorization recovery; a generic
authentication failure is not proof of revocation. Keep the journal and receipts,
report authorization required, and do not automatically re-enroll or erase state.
## Administrator actions and replay
`AdminDeviceActionRequest` requires `requestId`, `issuedAt` (RFC 3339 UTC ending
in `Z`), and `action`. Only `CANCEL_SPOOL` accepts and requires `jobId`. No action
accepts a queue name, numeric spool ID, executable, shell text, service name, or
operating-system command. Unknown action names and request fields are rejected.
| Action | Authoritative effect |
| --- | --- |
| `PAUSE_DISPATCH` | Set desired `dispatchPaused:true` and block new server claims. |
| `RESUME_DISPATCH` | Set desired `dispatchPaused:false`; preserve connection and revocation state. |
| `DISCONNECT` | Set desired `connectionState:DISCONNECTED` and block new server claims. |
| `RECONNECT` | Set desired `connectionState:CONNECTED`; preserve dispatch pause and revocation state. |
| `REVOKE` | Permanently invalidate the old credential. |
| `CLEAR_QUEUE` | Atomically cancel only currently QUEUED jobs for this device. |
| `REFRESH_INVENTORY` | Queue an agent command to discover and publish its locally exposed queues. |
| `CANCEL_SPOOL` | Queue a cancellation request for the exact owned job identified by `jobId`. |
The first five actions and queue clear complete on the server. Their record has
`status:SUCCEEDED` and result code `DESIRED_STATE_UPDATED`, `CREDENTIAL_REVOKED`,
or `QUEUED_JOBS_CANCELLED`, respectively. These results do not assert remote
execution. Queue clear also returns `cancelledJobCount`. It preserves history,
records cancellation using the existing FAILED job status, and never modifies a
CLAIMED or later job. Replaying its action must not cancel jobs queued afterward.
Scope request IDs to the authorized tenant and device. Look up the existing
request before checking its age. Identical input, including `issuedAt`, returns
the original record without another effect. Reusing that ID with changed input
returns 409. Commit the action record and its server effect or pending command
atomically before success. A transport retry retains the entire request body.
For an absent request ID, `issuedAt` must be no more than 15 minutes old and no
more than 60 seconds in the future, using server time. A stale request returns
410; a future timestamp outside the allowance is invalid (400). Never refresh
`issuedAt` automatically after a timeout or stale response. A newly reviewed
action requires a new request ID and timestamp. IDs are never intentionally
reused. Existing exact requests continue to replay within retained history even
after their timestamp has aged out of the new-request window.
At most 100 commands may remain pending per device. Retained action metadata is
bounded to 1000 records per device, with one reserved revocation record so
capacity cannot prevent credential revocation. Reclaim eligible terminal history
before rejecting new work with 429. Terminal history may be removed after seven
days measured from server-recorded termination; preserve unexpired or unfinished
records. Old unchanged requests still fail the timestamp check after pruning and
therefore cannot execute again. These limits affect control metadata, not job
history or document retention.
## Command delivery and receipts
Remote actions initially return `PENDING`. `DeviceCommand` carries stable
`commandId`, `requestId`, `action`, `createdAt`, `expiresAt`, and, for spool cancel,
`jobId`. Server-created expiry is 15 minutes after creation. One poll returns at
most 20 commands and `serverTime`, `pollIntervalSeconds` (1–300), and the full
desired state. Pending commands may be delivered repeatedly until a receipt or
expiry; they are not new commands on every poll. Do not deliver expired commands.
The agent journals command identity and execution state before a side effect.
Use the current enrollment/connection scope. It checks expiry before beginning,
with server time available to avoid silently trusting a skewed local clock.
A completed command returns its original stored receipt on duplicate delivery.
After an interrupted cancellation whose outcome is uncertain, persist and report
`OUTCOME_UNKNOWN`; never run the cancellation again speculatively. Receipt
retransmission has no printing or cancellation side effect.
`DeviceCommandReceipt` requires stable `receiptId`, `outcome`, and `completedAt`;
optional `message` is at most 1024 characters. Persist the full receipt before
transport. Exact replay returns `{ "accepted": true }`; a conflicting terminal
receipt returns 409. The command ID in the route is the authority binding; receipts
for another device's command are refused without disclosing it. The server
validates that an outcome is meaningful for the original command.
| Outcome | Permitted command | Record status |
| --- | --- | --- |
| `INVENTORY_REFRESHED` | `REFRESH_INVENTORY` | `SUCCEEDED` |
| `CANCEL_REQUESTED` | `CANCEL_SPOOL` | `SUCCEEDED` |
| `ALREADY_ABSENT` | `CANCEL_SPOOL` | `SUCCEEDED` |
| `OWNERSHIP_MISMATCH` | `CANCEL_SPOOL` | `FAILED` |
| `UNSUPPORTED`, `FAILED` | Either | `FAILED` |
| `EXPIRED` | Either | `EXPIRED` |
| `OUTCOME_UNKNOWN` | Either | `OUTCOME_UNKNOWN` |
An expired server record without a receipt means the deadline passed without
confirmation; it does not prove that no side effect occurred. Accept the first
valid late receipt and replace this provisional EXPIRED status with its actual
outcome. Once a receipt exists it is immutable. A late receipt never authorizes
redelivery or re-execution. History responses include the receipt when present.
After `expiresAt` plus seven days, a 404 from this specific command's receipt
endpoint can mean the server pruned its record. The agent may stop retransmitting
that receipt, retaining its exact local outcome as transport-retired rather than
server-acknowledged. This must not block newer commands or cause re-execution.
A 404 before that bound, or from control polling, does not establish this case.
Cancellation resolves the job from the agent's current connection journal and
checks the exact printer, spool reference, and owned document identity before
using Windows cancellation. Numeric spool IDs may be reused. Missing ownership
proof must refuse cancellation, not fall back to a queue-wide operation. Report
`ALREADY_ABSENT` only when the exact owned job is known and its spool entry is
absent. An identity mismatch reports `OWNERSHIP_MISMATCH` without cancellation.
`CANCEL_REQUESTED` means Windows accepted the request; it does not guarantee that
paper, a label, or data already sent to hardware was recalled. Job event history
remains intact; command outcomes do not fabricate physical print completion.
## History, fixtures, and verification scope
The command list includes server actions and remote commands, newest first.
`limit` defaults to 50 and ranges from 1 to 200. `nextCursor` is null at the end;
otherwise pass the bounded opaque cursor unchanged as `before`. Both list and
detail routes enforce tenant and device scope. Responses contain control metadata
and receipts, never credentials or document bytes.
The JSON Schema defines all new messages. Synthetic fixtures cover all eight
actions, both command types, disconnected polling, server-only results, every
receipt outcome, bounded history, and legacy version discovery. `npm run check`
checks structural constraints and representative invalid inputs. Atomicity,
authorization, time-window enforcement, expiry races, crash recovery, and Windows
ownership checks additionally require the server and agent integration suites;
schema validation alone does not prove those behaviors.
+8 -1
View File
@@ -16,7 +16,14 @@ If a consumer builds outside a multi-checkout workspace, download the release ar
Wire protocol v1 is selected by the `/printing/v1` path and enrollment response `protocolVersion: 1`. Published distribution version `0.1.4-rc.1` identifies the specification/fixture bundle; it does not change that wire version. The Windows and QBit release numbers are independent of both. Current clients validate the wire major at enrollment; runtime contract minor/patch discovery, feature negotiation and a served OpenAPI definition are not implemented.
Additive optional response fields can remain v1. Required new inputs, renamed fields, removed enum values or changed semantics require explicit compatibility design and usually a new path version. Feature negotiation may be added later; consumers must not assume unadvertised capabilities.
Additive optional response fields can remain v1. Required new inputs on existing routes, renamed fields, removed enum values or changed semantics require explicit compatibility design and usually a new path version. Consumers must not assume unadvertised capabilities.
The proposed `0.2.0-rc.1` extension adds explicit `device-control-v1` discovery and
new control routes without adding required fields to existing requests. See the
[device-control contract](DEVICE-CONTROLS.md) for state, commands, receipts,
timestamped idempotency, and legacy behavior. Source fixtures do not establish
consumer implementation or installation. Pin the eventual reviewed protocol
commit with matching server and Windows compatibility tests before shipping.
Claim acknowledgment means local receipt; SPOOLED means Windows accepted the document. Neither proves physical output. Cross-language compatibility tests should assert these distinctions as well as spelling, JSON casing, formats, option defaults and content integrity.
+75
View File
@@ -0,0 +1,75 @@
# Standard QQQ authentication
The new Windows client uses the QQQ semantic UI/UX API. Package versions, wire
payload version `1` and the printing extension semantic version are separate
contracts. This document describes implementation in progress, not an accepted
Authentik deployment or a published feature release.
## Discovery and sign-in
Read public `/qqq/v1/version` before sending credentials. Require core semantic
API version `>=1.8.0 <2.0.0`, the `api` channel and printing extension version
`>=1.0.0 <2.0.0`.
Use client channel `api/qqq-print-agent` and read
`/qqq/v1/metaData/authentication`. Do not infer server authentication from a URL,
brand, a saved token or a previous deployment.
For `TABLE_BASED` authentication, sign in through standard `manageSession` with
the supplied credentials. Retain the QQQ session cookie, never the password.
For OAuth2, additionally require core API `>=1.9.0 <2.0.0`, the shared QQQ
`nativeSessionLogin` capability and its typed `AUTHORIZATION_CODE_PKCE_SESSION`
grant, S256, `prompt: "login"` and exact allowed redirect URI.
Use the system browser, random state and PKCE, then exchange the authorization
code through standard `manageSession`. Do not put a confidential OAuth client
secret in Windows, substitute a public bearer client or invent a refresh API.
Missing native grants require server configuration, not a fallback local login.
The desktop callback must match the shared grant and installer registration.
Windows uses the exact private callback
`io.qrun.qqq-print-agent:/oauth/callback`; current central candidate policy rejects
HTTP callbacks. The installer registers the callback to a non-elevated forwarding
helper, which delivers it only to an active sign-in attempt in the same Windows
user/session. State and PKCE validation remain mandatory. The implementation,
shared grant and provider deployment must be qualified before Authentik acceptance.
## User and device authority
Protected printing routes are under `/qqq/v1/qbit/printing`. Every request needs
the current QQQ session. Agent operations additionally send
`X-QQQ-Device-Id` and `X-QQQ-Device-Token` only to the configured printing origin.
Never forward these credentials to discovery, public assets or another origin.
Enrollment at `/devices/enroll` validates the facility against the current
user's allowed facilities and binds the new device to that authoritative QQQ
principal. An existing unbound device uses explicit `/device/bind` with its
device proof and a currently authorized session. `/session` returns authoritative
`principalId` and `tenantId`; it includes `deviceId` only when the request also
supplies validated secondary proof for that bound device. Client display names are not
identity. A conflicting binding fails rather than silently transferring a device.
Each subsequent agent request checks the bound principal, tenant and current
record/process permissions. Administrative controls instead authorize the current
administrator against the target device and affected jobs; administrators do not
need another device's secret or have to impersonate its enrolled user. Generic
table writes stay disabled. Domain operations perform their own permission and
concurrency checks.
## Unattended service sessions
The service protects its session using Windows DPAPI and restricts access through
the existing authenticated local service channel. Snapshots and logs must not
contain cookies, passwords, device secrets or authorization codes. A token refresh
must not discard the device journal or permit a completed job to print again.
Resume through standard `manageSession` within the server cookie lifetime.
OAuth provider refresh tokens remain encrypted on the QQQ server. Months or years
of operation depend on server and provider renewal policy; no client credential
guarantees indefinite login. Account disablement, permission changes, logout,
session expiry and device revocation must remain effective. Fresh permission
invalidation must be qualified against the shared core, not assumed from a cached
session. Authorization failure blocks new printing while preserving journals and
uncertain outcomes for explicit recovery.
Legacy `/printing/v1` bearer transport remains explicit compatibility for old
integrations. It is not the new client's primary security boundary, and failure
of native discovery or authentication never silently activates it.
+30 -1
View File
@@ -1,4 +1,33 @@
# Session state — 2026-10-30
# Session state — 2026-10-31
## Standard-session primary transport
The new Windows client uses discovered semantic extension `printing` version
`1.0.0` at `/qqq/v1/qbit/printing`, channel `api/qqq-print-agent`, with standard
QQQ sessions and secondary device proof. Legacy bearer transport is explicit
compatibility only. See `QQQ-AUTHENTICATION.md` for current-user/device binding,
server-owned refresh and the exact private desktop callback. Authentication and
consumer integration gates are pending; schema validation is not browser-login
or deployment acceptance. Root added the authoritative printing-session identity
contract and reran all42definitions/54fixtures successfully.
## Device-control contract under review
Isolated branch `feat/server-device-controls-20261031` adds the frozen wire-v1
control contract in `docs/DEVICE-CONTROLS.md`. Server and Windows owners received
the same route, action, state, and receipt names. Admin requests require stable
`requestId` plus `issuedAt`; the age check prevents stale re-execution after
terminal history expires. Permanent revocation, desired-state observation,
queued-only clear, and exact owned spool cancellation remain distinct.
Node 24.22.0 local `npm run check` passed 41 schema definitions and 52 fixtures,
including legacy discovery and invalid control targets, commands, timestamps,
capabilities, response bounds, and receipt claims. The strict schema gate first
caught a missing branch-local property declaration; it was corrected before the
passing run. These are protocol checks, not live consumer or printer acceptance.
The proposed package version is `0.2.0-rc.1`; package metadata remains at the
published version pending coordinated review. No commit, push, release, install,
network service call, or physical print occurred in this work.
## Published release checkpoint — 2026-10-31
+11
View File
@@ -1,5 +1,16 @@
# TODO
- [ ] Qualify semantic discovery, current-user authorization, device binding,
session renewal and the shared native PKCE callback in both consumers.
- [x] Freeze additive device-control routes, desired state, actions and receipts
with both consumer owners; document bounded timestamped idempotency.
- [x] Validate 41 definitions and 52 synthetic fixtures with Node 24.22.0.
- [ ] Review matching server and Windows state, authority, replay, expiry, and
exact spool-ownership tests before adopting the new immutable protocol pin.
- [ ] Coordinate proposed `0.2.0-rc.1` version and release after feature gates;
no production 1.0 or installation qualification follows from schema checks.
- [ ] Verify security-gated artifact promotion and public build provenance for 0.1.4-rc.1.
- [x] Audit PR #2 at 7f3fa6b passes all hosted checks; downloaded CI archive checksum verified. Tagged release and published attestation remain pending.
- [x] Shared wire v1 remains compatible during the retention/storage and Windows audit fixes, including optional bounded printer pagination.
@@ -0,0 +1,6 @@
{
"requestId": "action-cancel-spool-1",
"issuedAt": "2026-10-31T16:00:00Z",
"action": "CANCEL_SPOOL",
"jobId": "job-example-1"
}
+11
View File
@@ -0,0 +1,11 @@
{
"commandId": "command-cancel-1",
"requestId": "action-cancel-spool-1",
"action": "CANCEL_SPOOL",
"createdAt": "2026-10-31T16:00:00Z",
"expiresAt": "2026-10-31T16:15:00Z",
"jobId": "job-example-1",
"issuedAt": "2026-10-31T16:00:00Z",
"deviceId": "device-example-1",
"status": "PENDING"
}
+5
View File
@@ -0,0 +1,5 @@
{
"requestId": "action-clear-queue-1",
"issuedAt": "2026-10-31T16:00:00Z",
"action": "CLEAR_QUEUE"
}
+12
View File
@@ -0,0 +1,12 @@
{
"commandId": "command-clear_queue-1",
"requestId": "action-clear-queue-1",
"issuedAt": "2026-10-31T16:00:00Z",
"deviceId": "device-example-1",
"action": "CLEAR_QUEUE",
"status": "SUCCEEDED",
"createdAt": "2026-10-31T16:00:00Z",
"expiresAt": "2026-10-31T16:15:00Z",
"resultCode": "QUEUED_JOBS_CANCELLED",
"cancelledJobCount": 7
}
+5
View File
@@ -0,0 +1,5 @@
{
"requestId": "action-disconnect-1",
"issuedAt": "2026-10-31T16:00:00Z",
"action": "DISCONNECT"
}
@@ -0,0 +1,5 @@
{
"requestId": "action-pause-dispatch-1",
"issuedAt": "2026-10-31T16:00:00Z",
"action": "PAUSE_DISPATCH"
}
@@ -0,0 +1,11 @@
{
"commandId": "command-pause_dispatch-1",
"requestId": "action-pause-dispatch-1",
"issuedAt": "2026-10-31T16:00:00Z",
"deviceId": "device-example-1",
"action": "PAUSE_DISPATCH",
"status": "SUCCEEDED",
"createdAt": "2026-10-31T16:00:00Z",
"expiresAt": "2026-10-31T16:15:00Z",
"resultCode": "DESIRED_STATE_UPDATED"
}
+5
View File
@@ -0,0 +1,5 @@
{
"requestId": "action-reconnect-1",
"issuedAt": "2026-10-31T16:00:00Z",
"action": "RECONNECT"
}
@@ -0,0 +1,5 @@
{
"requestId": "action-refresh-inventory-1",
"issuedAt": "2026-10-31T16:00:00Z",
"action": "REFRESH_INVENTORY"
}
+10
View File
@@ -0,0 +1,10 @@
{
"commandId": "command-refresh-1",
"requestId": "action-refresh-inventory-1",
"action": "REFRESH_INVENTORY",
"createdAt": "2026-10-31T16:00:00Z",
"expiresAt": "2026-10-31T16:15:00Z",
"issuedAt": "2026-10-31T16:00:00Z",
"deviceId": "device-example-1",
"status": "PENDING"
}
@@ -0,0 +1,5 @@
{
"requestId": "action-resume-dispatch-1",
"issuedAt": "2026-10-31T16:00:00Z",
"action": "RESUME_DISPATCH"
}
+5
View File
@@ -0,0 +1,5 @@
{
"requestId": "action-revoke-1",
"issuedAt": "2026-10-31T16:00:00Z",
"action": "REVOKE"
}
+11
View File
@@ -0,0 +1,11 @@
{
"commandId": "command-revoke-1",
"requestId": "action-revoke-1",
"issuedAt": "2026-10-31T16:00:00Z",
"deviceId": "device-example-1",
"action": "REVOKE",
"status": "SUCCEEDED",
"createdAt": "2026-10-31T16:00:00Z",
"expiresAt": "2026-10-31T16:15:00Z",
"resultCode": "CREDENTIAL_REVOKED"
}
@@ -0,0 +1,5 @@
{
"receiptId": "receipt-already-absent-1",
"outcome": "ALREADY_ABSENT",
"completedAt": "2026-10-31T16:00:05Z"
}
@@ -0,0 +1,16 @@
{
"commandId": "command-cancel-1",
"requestId": "action-cancel-spool-1",
"action": "CANCEL_SPOOL",
"createdAt": "2026-10-31T16:00:00Z",
"expiresAt": "2026-10-31T16:15:00Z",
"jobId": "job-example-1",
"issuedAt": "2026-10-31T16:00:00Z",
"deviceId": "device-example-1",
"status": "SUCCEEDED",
"receipt": {
"receiptId": "receipt-cancel-requested-1",
"outcome": "CANCEL_REQUESTED",
"completedAt": "2026-10-31T16:00:05Z"
}
}
@@ -0,0 +1,5 @@
{
"receiptId": "receipt-cancel-requested-1",
"outcome": "CANCEL_REQUESTED",
"completedAt": "2026-10-31T16:00:05Z"
}
@@ -0,0 +1,60 @@
{
"commands": [
{
"commandId": "command-refresh-1",
"requestId": "action-refresh-inventory-1",
"action": "REFRESH_INVENTORY",
"createdAt": "2026-10-31T16:00:00Z",
"expiresAt": "2026-10-31T16:15:00Z",
"issuedAt": "2026-10-31T16:00:00Z",
"deviceId": "device-example-1",
"status": "PENDING"
},
{
"commandId": "command-cancel-1",
"requestId": "action-cancel-spool-1",
"action": "CANCEL_SPOOL",
"createdAt": "2026-10-31T16:00:00Z",
"expiresAt": "2026-10-31T16:15:00Z",
"jobId": "job-example-1",
"issuedAt": "2026-10-31T16:00:00Z",
"deviceId": "device-example-1",
"status": "PENDING"
},
{
"commandId": "command-clear_queue-1",
"requestId": "action-clear-queue-1",
"issuedAt": "2026-10-31T16:00:00Z",
"deviceId": "device-example-1",
"action": "CLEAR_QUEUE",
"status": "SUCCEEDED",
"createdAt": "2026-10-31T16:00:00Z",
"expiresAt": "2026-10-31T16:15:00Z",
"resultCode": "QUEUED_JOBS_CANCELLED",
"cancelledJobCount": 7
},
{
"commandId": "command-pause_dispatch-1",
"requestId": "action-pause-dispatch-1",
"issuedAt": "2026-10-31T16:00:00Z",
"deviceId": "device-example-1",
"action": "PAUSE_DISPATCH",
"status": "SUCCEEDED",
"createdAt": "2026-10-31T16:00:00Z",
"expiresAt": "2026-10-31T16:15:00Z",
"resultCode": "DESIRED_STATE_UPDATED"
},
{
"commandId": "command-revoke-1",
"requestId": "action-revoke-1",
"issuedAt": "2026-10-31T16:00:00Z",
"deviceId": "device-example-1",
"action": "REVOKE",
"status": "SUCCEEDED",
"createdAt": "2026-10-31T16:00:00Z",
"expiresAt": "2026-10-31T16:15:00Z",
"resultCode": "CREDENTIAL_REVOKED"
}
],
"nextCursor": null
}
@@ -0,0 +1,11 @@
{
"controlVersion": 1,
"serverTime": "2026-10-31T16:00:00Z",
"pollIntervalSeconds": 5,
"desiredState": {
"revision": 2,
"dispatchPaused": false,
"connectionState": "DISCONNECTED"
},
"commands": []
}
+5
View File
@@ -0,0 +1,5 @@
{
"receiptId": "receipt-expired-1",
"outcome": "EXPIRED",
"completedAt": "2026-10-31T16:00:05Z"
}
+5
View File
@@ -0,0 +1,5 @@
{
"receiptId": "receipt-failed-1",
"outcome": "FAILED",
"completedAt": "2026-10-31T16:00:05Z"
}
@@ -0,0 +1,5 @@
{
"receiptId": "receipt-inventory-refreshed-1",
"outcome": "INVENTORY_REFRESHED",
"completedAt": "2026-10-31T16:00:05Z"
}
@@ -0,0 +1,5 @@
{
"receiptId": "receipt-outcome-unknown-1",
"outcome": "OUTCOME_UNKNOWN",
"completedAt": "2026-10-31T16:00:05Z"
}
@@ -0,0 +1,5 @@
{
"receiptId": "receipt-ownership-mismatch-1",
"outcome": "OWNERSHIP_MISMATCH",
"completedAt": "2026-10-31T16:00:05Z"
}
+9
View File
@@ -0,0 +1,9 @@
{
"controlVersion": 1,
"agentVersion": "0.2.0-rc.1",
"capabilities": [
"REFRESH_INVENTORY",
"CANCEL_SPOOL"
],
"lastAppliedRevision": 0
}
+27
View File
@@ -0,0 +1,27 @@
{
"controlVersion": 1,
"serverTime": "2026-10-31T16:00:00Z",
"pollIntervalSeconds": 5,
"desiredState": {
"revision": 1,
"dispatchPaused": true,
"connectionState": "CONNECTED"
},
"commands": [
{
"commandId": "command-refresh-1",
"requestId": "action-refresh-inventory-1",
"action": "REFRESH_INVENTORY",
"createdAt": "2026-10-31T16:00:00Z",
"expiresAt": "2026-10-31T16:15:00Z"
},
{
"commandId": "command-cancel-1",
"requestId": "action-cancel-spool-1",
"action": "CANCEL_SPOOL",
"createdAt": "2026-10-31T16:00:00Z",
"expiresAt": "2026-10-31T16:15:00Z",
"jobId": "job-example-1"
}
]
}
@@ -0,0 +1,5 @@
{
"receiptId": "receipt-unsupported-1",
"outcome": "UNSUPPORTED",
"completedAt": "2026-10-31T16:00:05Z"
}
+20
View File
@@ -0,0 +1,20 @@
{
"deviceId": "device-example-1",
"deviceName": "Synthetic workstation",
"facilityId": "facility-example",
"agentVersion": "0.2.0-rc.1",
"control": {
"supported": true,
"capabilities": [
"REFRESH_INVENTORY",
"CANCEL_SPOOL"
],
"lastPollAt": "2026-10-31T16:00:00Z",
"lastAppliedRevision": 0,
"desiredState": {
"revision": 1,
"dispatchPaused": true,
"connectionState": "CONNECTED"
}
}
}
@@ -0,0 +1,5 @@
{
"principalId": "test-operator",
"tenantId": "test-tenant",
"deviceId": "test-device"
}
+4
View File
@@ -0,0 +1,4 @@
{
"principalId": "test-operator",
"tenantId": "test-tenant"
}
+6
View File
@@ -0,0 +1,6 @@
{
"protocolVersion": 1,
"capabilities": [
"device-control-v1"
]
}
+3
View File
@@ -0,0 +1,3 @@
{
"protocolVersion": 1
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@qrun-io/qqq-print-protocol",
"version": "0.1.4-rc.1",
"version": "0.2.0-rc.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qrun-io/qqq-print-protocol",
"version": "0.1.4-rc.1",
"version": "0.2.0-rc.1",
"license": "Apache-2.0",
"devDependencies": {
"ajv": "8.20.0",
+4 -2
View File
@@ -1,11 +1,13 @@
{
"name": "@qrun-io/qqq-print-protocol",
"version": "0.1.4-rc.1",
"version": "0.2.0-rc.1",
"private": true,
"description": "Versioned wire contract and compatibility fixtures for QQQ cloud printing.",
"license": "Apache-2.0",
"type": "module",
"engines": { "node": ">=24" },
"engines": {
"node": ">=24"
},
"scripts": {
"check": "node tools/verify-schema.mjs && node tools/verify-fixtures.mjs",
"bundle": "node tools/bundle.mjs"
+504 -5
View File
@@ -4,6 +4,16 @@
"title": "QQQ Print Protocol v1",
"description": "Select the DTO with #/$defs/Name.",
"$defs": {
"PrintingSessionIdentity": {
"type": "object",
"required": ["principalId", "tenantId"],
"properties": {
"principalId": { "type": "string", "minLength": 1, "maxLength": 200 },
"tenantId": { "type": "string", "minLength": 1, "maxLength": 200 },
"deviceId": { "$ref": "#/$defs/Id" }
},
"additionalProperties": true
},
"DeviceTestPrintRequest": {
"type": "object",
"required": [
@@ -26,13 +36,40 @@
}
},
"anyOf": [
{ "type": "object", "properties": { "format": { "const": "PDF" } } },
{ "type": "object", "properties": { "format": { "const": "ZPL" }, "testProfile": { "enum": ["LABEL_4X6_203", "LABEL_4X6_300", "LABEL_4X6_600"] } } }
{
"type": "object",
"properties": {
"format": {
"const": "PDF"
}
}
},
{
"type": "object",
"properties": {
"format": {
"const": "ZPL"
},
"testProfile": {
"enum": [
"LABEL_4X6_203",
"LABEL_4X6_300",
"LABEL_4X6_600"
]
}
}
}
]
},
"TestPrintProfile": {
"description": "Optional test-document media and label resolution. Omitted defaults: PDF LETTER, ZPL LABEL_4X6_203. This does not change a Windows queue's configured stock.",
"enum": ["LETTER", "A4", "LABEL_4X6_203", "LABEL_4X6_300", "LABEL_4X6_600"]
"enum": [
"LETTER",
"A4",
"LABEL_4X6_203",
"LABEL_4X6_300",
"LABEL_4X6_600"
]
},
"DeviceRetryRequest": {
"type": "object",
@@ -618,6 +655,9 @@
},
"profile": {
"$ref": "#/$defs/DeviceProfile"
},
"control": {
"$ref": "#/$defs/DeviceControlStatus"
}
},
"required": [
@@ -655,8 +695,13 @@
},
"nextCursor": {
"anyOf": [
{ "type": "string", "maxLength": 12000 },
{ "type": "null" }
{
"type": "string",
"maxLength": 12000
},
{
"type": "null"
}
]
}
},
@@ -749,6 +794,460 @@
},
"additionalProperties": true,
"description": "Client-authoritative labels. Null or omitted preserves metadata; empty text clears the field. Empty name falls back to the technical computer name."
},
"ControlTimestamp": {
"type": "string",
"format": "date-time",
"maxLength": 40,
"pattern": "Z$",
"description": "RFC 3339 UTC timestamp, ending in uppercase Z."
},
"ControlCapability": {
"enum": [
"REFRESH_INVENTORY",
"CANCEL_SPOOL"
]
},
"ProtocolVersionResponse": {
"type": "object",
"required": [
"protocolVersion"
],
"properties": {
"protocolVersion": {
"const": 1
},
"capabilities": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"maxItems": 32,
"uniqueItems": true
}
},
"additionalProperties": true
},
"DeviceDesiredState": {
"type": "object",
"required": [
"revision",
"dispatchPaused",
"connectionState"
],
"properties": {
"revision": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"dispatchPaused": {
"type": "boolean"
},
"connectionState": {
"enum": [
"CONNECTED",
"DISCONNECTED"
]
}
},
"additionalProperties": true
},
"DeviceControlStatus": {
"type": "object",
"required": [
"supported",
"capabilities",
"lastAppliedRevision",
"desiredState"
],
"properties": {
"supported": {
"type": "boolean"
},
"capabilities": {
"type": "array",
"items": {
"$ref": "#/$defs/ControlCapability"
},
"maxItems": 2,
"uniqueItems": true
},
"lastPollAt": {
"$ref": "#/$defs/ControlTimestamp"
},
"lastAppliedRevision": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"desiredState": {
"$ref": "#/$defs/DeviceDesiredState"
}
},
"additionalProperties": true
},
"DeviceControlPollRequest": {
"type": "object",
"required": [
"controlVersion",
"agentVersion",
"capabilities",
"lastAppliedRevision"
],
"properties": {
"controlVersion": {
"const": 1
},
"agentVersion": {
"type": "string",
"minLength": 1,
"maxLength": 128
},
"capabilities": {
"type": "array",
"items": {
"$ref": "#/$defs/ControlCapability"
},
"maxItems": 2,
"uniqueItems": true
},
"lastAppliedRevision": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
}
},
"additionalProperties": false
},
"DeviceCommand": {
"type": "object",
"required": [
"commandId",
"requestId",
"action",
"createdAt",
"expiresAt"
],
"properties": {
"commandId": {
"$ref": "#/$defs/Id"
},
"requestId": {
"$ref": "#/$defs/Id"
},
"action": {
"enum": [
"REFRESH_INVENTORY",
"CANCEL_SPOOL"
]
},
"createdAt": {
"$ref": "#/$defs/ControlTimestamp"
},
"expiresAt": {
"$ref": "#/$defs/ControlTimestamp"
},
"jobId": {
"$ref": "#/$defs/Id"
}
},
"additionalProperties": true,
"anyOf": [
{
"type": "object",
"required": [
"jobId"
],
"properties": {
"action": {
"const": "CANCEL_SPOOL"
},
"jobId": {
"$ref": "#/$defs/Id"
}
}
},
{
"type": "object",
"properties": {
"action": {
"enum": [
"REFRESH_INVENTORY"
]
},
"jobId": false
}
}
]
},
"DeviceControlPollResponse": {
"type": "object",
"required": [
"controlVersion",
"serverTime",
"pollIntervalSeconds",
"desiredState",
"commands"
],
"properties": {
"controlVersion": {
"const": 1
},
"serverTime": {
"$ref": "#/$defs/ControlTimestamp"
},
"pollIntervalSeconds": {
"type": "integer",
"minimum": 1,
"maximum": 300
},
"desiredState": {
"$ref": "#/$defs/DeviceDesiredState"
},
"commands": {
"type": "array",
"items": {
"$ref": "#/$defs/DeviceCommand"
},
"maxItems": 20
}
},
"additionalProperties": true
},
"DeviceCommandReceipt": {
"type": "object",
"required": [
"receiptId",
"outcome",
"completedAt"
],
"properties": {
"receiptId": {
"$ref": "#/$defs/Id"
},
"outcome": {
"enum": [
"INVENTORY_REFRESHED",
"CANCEL_REQUESTED",
"ALREADY_ABSENT",
"OWNERSHIP_MISMATCH",
"UNSUPPORTED",
"FAILED",
"EXPIRED",
"OUTCOME_UNKNOWN"
]
},
"completedAt": {
"$ref": "#/$defs/ControlTimestamp"
},
"message": {
"type": "string",
"maxLength": 1024
}
},
"additionalProperties": false
},
"AdminDeviceActionRequest": {
"type": "object",
"required": [
"requestId",
"issuedAt",
"action"
],
"properties": {
"requestId": {
"$ref": "#/$defs/Id"
},
"issuedAt": {
"$ref": "#/$defs/ControlTimestamp"
},
"action": {
"enum": [
"PAUSE_DISPATCH",
"RESUME_DISPATCH",
"DISCONNECT",
"RECONNECT",
"REVOKE",
"REFRESH_INVENTORY",
"CLEAR_QUEUE",
"CANCEL_SPOOL"
]
},
"jobId": {
"$ref": "#/$defs/Id"
}
},
"additionalProperties": false,
"anyOf": [
{
"type": "object",
"required": [
"jobId"
],
"properties": {
"action": {
"const": "CANCEL_SPOOL"
},
"jobId": {
"$ref": "#/$defs/Id"
}
}
},
{
"type": "object",
"properties": {
"action": {
"enum": [
"PAUSE_DISPATCH",
"RESUME_DISPATCH",
"DISCONNECT",
"RECONNECT",
"REVOKE",
"REFRESH_INVENTORY",
"CLEAR_QUEUE"
]
},
"jobId": false
}
}
]
},
"DeviceActionRecord": {
"type": "object",
"required": [
"commandId",
"requestId",
"issuedAt",
"deviceId",
"action",
"status",
"createdAt",
"expiresAt"
],
"properties": {
"commandId": {
"$ref": "#/$defs/Id"
},
"requestId": {
"$ref": "#/$defs/Id"
},
"issuedAt": {
"$ref": "#/$defs/ControlTimestamp"
},
"deviceId": {
"$ref": "#/$defs/Id"
},
"action": {
"enum": [
"PAUSE_DISPATCH",
"RESUME_DISPATCH",
"DISCONNECT",
"RECONNECT",
"REVOKE",
"REFRESH_INVENTORY",
"CLEAR_QUEUE",
"CANCEL_SPOOL"
]
},
"status": {
"enum": [
"PENDING",
"SUCCEEDED",
"FAILED",
"EXPIRED",
"OUTCOME_UNKNOWN"
]
},
"createdAt": {
"$ref": "#/$defs/ControlTimestamp"
},
"expiresAt": {
"$ref": "#/$defs/ControlTimestamp"
},
"jobId": {
"$ref": "#/$defs/Id"
},
"resultCode": {
"enum": [
"DESIRED_STATE_UPDATED",
"CREDENTIAL_REVOKED",
"QUEUED_JOBS_CANCELLED"
]
},
"cancelledJobCount": {
"type": "integer",
"minimum": 0
},
"receipt": {
"$ref": "#/$defs/DeviceCommandReceipt"
}
},
"additionalProperties": true,
"anyOf": [
{
"type": "object",
"required": [
"jobId"
],
"properties": {
"action": {
"const": "CANCEL_SPOOL"
},
"jobId": {
"$ref": "#/$defs/Id"
}
}
},
{
"type": "object",
"properties": {
"action": {
"enum": [
"PAUSE_DISPATCH",
"RESUME_DISPATCH",
"DISCONNECT",
"RECONNECT",
"REVOKE",
"REFRESH_INVENTORY",
"CLEAR_QUEUE"
]
},
"jobId": false
}
}
]
},
"DeviceActionList": {
"type": "object",
"required": [
"commands",
"nextCursor"
],
"properties": {
"commands": {
"type": "array",
"items": {
"$ref": "#/$defs/DeviceActionRecord"
},
"maxItems": 200
},
"nextCursor": {
"anyOf": [
{
"type": "string",
"minLength": 1,
"maxLength": 12000
},
{
"type": "null"
}
]
}
},
"additionalProperties": true
}
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ if(process.env.GITHUB_REF_TYPE === 'tag')
}
mkdirSync(new URL('../dist/', import.meta.url), { recursive: true });
const name = `qqq-print-protocol-${manifest.version}.tar.gz`;
const files = ['README.md', 'LICENSE', 'NOTICE', 'CHANGELOG.md', 'CONTRIBUTING.md', 'SECURITY.md', 'package.json', 'package-lock.json', 'schemas', 'fixtures', 'tools', 'docs/INTEGRATION.md'];
const files = ['README.md', 'LICENSE', 'NOTICE', 'CHANGELOG.md', 'CONTRIBUTING.md', 'SECURITY.md', 'package.json', 'package-lock.json', 'schemas', 'fixtures', 'tools', 'docs/INTEGRATION.md', 'docs/DEVICE-CONTROLS.md', 'docs/QQQ-AUTHENTICATION.md'];
const result = spawnSync('tar', ['-czf', `dist/${name}`, ...files], { cwd: root, stdio: 'inherit', shell: false });
if(result.error) throw result.error;
assert.equal(result.status, 0, 'Archive creation failed.');
+32 -1
View File
@@ -1,5 +1,34 @@
// Shared correspondence between wire examples and schema definitions.
export const fixtureTypes = {
'version-legacy.response.json': 'ProtocolVersionResponse',
'version-controls.response.json': 'ProtocolVersionResponse',
'control-poll.request.json': 'DeviceControlPollRequest',
'control-poll.response.json': 'DeviceControlPollResponse',
'control-disconnected.response.json': 'DeviceControlPollResponse',
'device-control.response.json': 'Device',
'action-pause-dispatch.request.json': 'AdminDeviceActionRequest',
'action-resume-dispatch.request.json': 'AdminDeviceActionRequest',
'action-disconnect.request.json': 'AdminDeviceActionRequest',
'action-reconnect.request.json': 'AdminDeviceActionRequest',
'action-revoke.request.json': 'AdminDeviceActionRequest',
'action-refresh-inventory.request.json': 'AdminDeviceActionRequest',
'action-clear-queue.request.json': 'AdminDeviceActionRequest',
'action-cancel-spool.request.json': 'AdminDeviceActionRequest',
'action-refresh.response.json': 'DeviceActionRecord',
'action-cancel.response.json': 'DeviceActionRecord',
'action-clear-queue.response.json': 'DeviceActionRecord',
'action-pause-dispatch.response.json': 'DeviceActionRecord',
'action-revoke.response.json': 'DeviceActionRecord',
'control-inventory-refreshed.receipt.json': 'DeviceCommandReceipt',
'control-cancel-requested.receipt.json': 'DeviceCommandReceipt',
'control-already-absent.receipt.json': 'DeviceCommandReceipt',
'control-ownership-mismatch.receipt.json': 'DeviceCommandReceipt',
'control-unsupported.receipt.json': 'DeviceCommandReceipt',
'control-failed.receipt.json': 'DeviceCommandReceipt',
'control-expired.receipt.json': 'DeviceCommandReceipt',
'control-outcome-unknown.receipt.json': 'DeviceCommandReceipt',
'control-commands-page.response.json': 'DeviceActionList',
'control-cancel-completed.response.json': 'DeviceActionRecord',
'heartbeat-profile.request.json': 'HeartbeatRequest',
'printers-profile.request.json': 'PrinterInventory',
'device-test.request.json': 'DeviceTestPrintRequest',
@@ -22,5 +51,7 @@ export const fixtureTypes = {
'submitting.event.json': 'JobEvent',
'spooled.event.json': 'JobEvent',
'ack.response.json': 'Acknowledgment',
'error.response.json': 'Error'
'error.response.json': 'Error',
'printing-session.response.json': 'PrintingSessionIdentity',
'printing-session-bound.response.json': 'PrintingSessionIdentity'
};
+17
View File
@@ -9,6 +9,11 @@ const root = new URL('../fixtures/', import.meta.url);
// Validate the schema keywords used by this contract without installing dependencies.
function validate(value, rule, path)
{
if(typeof rule === 'boolean')
{
assert(rule, `${path}: forbidden property`);
return;
}
if(rule.$ref)
{
return validate(value, schema.$defs[rule.$ref.split('/').at(-1)], path);
@@ -28,6 +33,10 @@ function validate(value, rule, path)
{
assert(value !== null && typeof value === 'object' && !Array.isArray(value), `${path}: expected object`);
for(const name of rule.required ?? []) assert(name in value, `${path}.${name}: required`);
if(rule.additionalProperties === false)
{
for(const name of Object.keys(value)) assert(name in (rule.properties ?? {}), `${path}.${name}: unknown property`);
}
for(const [name, child] of Object.entries(rule.properties ?? {}))
{
if(name in value) validate(value[name], child, `${path}.${name}`);
@@ -91,4 +100,12 @@ assert.throws(() => validate({ ...brandedTest, testProfile: 'UNKNOWN' }, schema.
assert.throws(() => validate({ ...brandedTest, testProfile: null }, schema.$defs.DeviceTestPrintRequest, 'nullTestProfile'));
assert.throws(() => validate({ format: 'PDF' }, schema.$defs.DeviceTestPrintRequest, 'missingTestIdentity'));
validate({ ...brandedTest, format: 'PDF', testProfile: 'A4' }, schema.$defs.DeviceTestPrintRequest, 'a4TestPage');
const clear = fixtures['action-clear-queue.request.json'];
assert.throws(() => validate({ ...clear, issuedAt: undefined }, schema.$defs.AdminDeviceActionRequest, 'missingIssuedAt'));
assert.throws(() => validate({ ...clear, jobId: 'job-example-1' }, schema.$defs.AdminDeviceActionRequest, 'clearCannotTargetSpool'));
assert.throws(() => validate({ ...clear, action: 'RESTART_SERVICE' }, schema.$defs.AdminDeviceActionRequest, 'arbitraryServiceControl'));
assert.throws(() => validate({ ...clear, command: 'shell' }, schema.$defs.AdminDeviceActionRequest, 'unknownCommandProperty'));
assert.throws(() => validate({ ...clear, action: 'CANCEL_SPOOL' }, schema.$defs.AdminDeviceActionRequest, 'cancelRequiresJobIdentity'));
assert.throws(() => validate({ ...fixtures['control-poll.request.json'], capabilities: ['CANCEL_SPOOL', 'CANCEL_SPOOL'] }, schema.$defs.DeviceControlPollRequest, 'duplicateCapability'));
assert.throws(() => validate({ ...fixtures['control-cancel-requested.receipt.json'], outcome: 'PRINT_RECALLED' }, schema.$defs.DeviceCommandReceipt, 'unsupportedPaperClaim'));
console.log(`Validated ${Object.keys(fixtures).length} protocol fixtures, content integrity, and invalid-input checks.`);
+46
View File
@@ -36,4 +36,50 @@ assert(validatePrinters({ ...printerPage, nextCursor: 'opaque-server-cursor' }))
assert(!validatePrinters({ ...printerPage, nextCursor: 17 }));
assert(!validatePrinters({ ...printerPage, nextCursor: 'x'.repeat(12001) }));
assert(!validatePrinters({ printers: Array(201).fill(printerPage.printers[0]) }));
const control = name => JSON.parse(readFileSync(new URL(`../fixtures/${name}`, import.meta.url), 'utf8'));
const validControl = (type, value) => ajv.validate(`${schema.$id}#/$defs/${type}`, value);
assert(!validControl('PrintingSessionIdentity', { tenantId: 'test-tenant' }), 'Display or device identity cannot replace the authoritative principal.');
assert(!validControl('PrintingSessionIdentity', { principalId: 'test-operator', tenantId: '' }));
assert(!validControl('PrintingSessionIdentity', { principalId: 'test-operator', tenantId: 'test-tenant', deviceId: null }));
assert(validControl('PrintingSessionIdentity', { principalId: 'test-operator', tenantId: 'test-tenant', futureResponseField: true }));
const action = control('action-clear-queue.request.json');
assert(validControl('AdminDeviceActionRequest', action));
const withoutIssuedAt = { ...action };
delete withoutIssuedAt.issuedAt;
assert(!validControl('AdminDeviceActionRequest', withoutIssuedAt), 'New controls require their durable freshness timestamp.');
for(const invalid of [
{ issuedAt: '2026-10-31T16:00:00-05:00' },
{ issuedAt: null },
{ action: 'RESTART_SERVICE' },
{ action: 'CANCEL_SPOOL' },
{ jobId: 'job-example-1' },
{ spoolerJobId: '17' },
{ queueName: 'Some other queue' },
{ command: 'shell' },
{ requestId: '../device' }
])
{
assert(!validControl('AdminDeviceActionRequest', { ...action, ...invalid }), JSON.stringify(invalid));
}
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, lastAppliedRevision: -1 }));
assert(!validControl('DeviceControlPollRequest', { ...poll, lastAppliedRevision: 9007199254740992 }));
const pollResult = control('control-poll.response.json');
assert(validControl('DeviceControlPollResponse', { ...pollResult, futureResponseField: true }), 'Additive response fields remain compatible.');
assert(!validControl('DeviceControlPollResponse', { ...pollResult, commands: Array(21).fill(pollResult.commands[0]) }));
assert(!validControl('DeviceControlPollResponse', { ...pollResult, desiredState: { ...pollResult.desiredState, connectionState: 'REVOKED' } }), 'Revocation is credential invalidation, not reversible desired state.');
assert(!validControl('DeviceCommand', { ...pollResult.commands[1], jobId: null }));
assert(!validControl('DeviceCommand', { ...pollResult.commands[0], jobId: 'job-example-1' }));
assert(!validControl('DeviceCommand', { ...pollResult.commands[0], action: 'CLEAR_QUEUE' }), 'Server queue clear is never an arbitrary client command.');
const receipt = control('control-cancel-requested.receipt.json');
assert(!validControl('DeviceCommandReceipt', { ...receipt, outcome: 'PRINT_RECALLED' }));
assert(!validControl('DeviceCommandReceipt', { ...receipt, message: 'x'.repeat(1025) }));
assert(!validControl('DeviceCommandReceipt', { ...receipt, completedAt: 'not-a-date' }));
assert(!validControl('DeviceCommandReceipt', { ...receipt, jobId: 'other-job' }), 'Receipt cannot override its route-bound command target.');
const history = control('control-commands-page.response.json');
assert(!validControl('DeviceActionList', { ...history, commands: Array(201).fill(history.commands[0]) }));
assert(!validControl('DeviceActionList', { ...history, nextCursor: 'x'.repeat(12001) }));
console.log(`Compiled ${Object.keys(schema.$defs).length} JSON Schema 2020-12 definitions; ${Object.keys(fixtureTypes).length} fixtures passed full schema validation.`);