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:
45 files changed
+1255
-15
No files matched your search
+12
-1
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"requestId": "action-clear-queue-1",
|
||||
"issuedAt": "2026-10-31T16:00:00Z",
|
||||
"action": "CLEAR_QUEUE"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"requestId": "action-revoke-1",
|
||||
"issuedAt": "2026-10-31T16:00:00Z",
|
||||
"action": "REVOKE"
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"receiptId": "receipt-expired-1",
|
||||
"outcome": "EXPIRED",
|
||||
"completedAt": "2026-10-31T16:00:05Z"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"controlVersion": 1,
|
||||
"agentVersion": "0.2.0-rc.1",
|
||||
"capabilities": [
|
||||
"REFRESH_INVENTORY",
|
||||
"CANCEL_SPOOL"
|
||||
],
|
||||
"lastAppliedRevision": 0
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"principalId": "test-operator",
|
||||
"tenantId": "test-tenant"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"protocolVersion": 1,
|
||||
"capabilities": [
|
||||
"device-control-v1"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"protocolVersion": 1
|
||||
}
|
||||
Generated
+2
-2
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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'
|
||||
};
|
||||
@@ -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.`);
|
||||
@@ -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.`);
|
||||
Reference in new issue
Block a user