Import existing pre-cutoff GitHub work into Gitea #7

79 changed files with 3296 additions and 23 deletions

No files matched your search

+3
View File
@@ -1,3 +1,6 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
version: 2
updates:
- package-ecosystem: npm
+3
View File
@@ -1,3 +1,6 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
"""Reject missing/malformed CodeQL evidence before enforcing the severity gate."""
import json
import math
+128
View File
@@ -0,0 +1,128 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
"""Check leading notices in explicitly owned, Git-tracked source families."""
import fnmatch
import json
import re
import subprocess
import sys
from pathlib import Path
from typing import Any
def selected(name: str, policy: dict[str, Any]) -> bool:
"""Select owned paths, honoring documented exact/pattern exclusions first."""
if any(fnmatch.fnmatchcase(name, item["pattern"]) for item in policy["exclusions"]):
return False
return any(fnmatch.fnmatchcase(name, pattern) for pattern in policy["includes"])
def leading_comments(text: str, style: str) -> str:
"""Read comments only, preserving required interpreter/XML directive ordering."""
text = text.removeprefix("\ufeff")
if text.startswith("<?xml"):
declaration = re.match(r"<\?xml[^?]*\?>", text)
if style != "xml" or declaration is None:
raise ValueError("Invalid XML declaration")
text = text[declaration.end():]
elif text.startswith("#!"):
if style not in ("hash", "powershell", "slash"):
raise ValueError("Unexpected interpreter directive")
text = text.partition("\n")[2]
while re.match(r"#Requires\b", text, re.IGNORECASE):
if style != "powershell":
raise ValueError("Unexpected PowerShell directive")
text = text.partition("\n")[2]
comments = []
while text.strip():
text = text.lstrip()
if text.startswith("<?xml") or re.match(r"#!|#Requires\b", text, re.IGNORECASE):
raise ValueError("Required declaration must precede the notice")
if style in ("hash", "powershell") and text.startswith("#"):
line, _, text = text.partition("\n")
comments.append(line[1:])
elif style == "slash" and text.startswith("//"):
line, _, text = text.partition("\n")
comments.append(line[2:])
elif ((style == "slash" and text.startswith("/*"))
or (style == "xml" and text.startswith("<!--"))
or (style == "powershell" and text.startswith("<#"))):
opening, closing = {"slash": ("/*", "*/"), "xml": ("<!--", "-->"), "powershell": ("<#", "#>")}[style]
body, separator, rest = text[len(opening):].partition(closing)
if not separator:
raise ValueError("Unterminated leading comment")
comments.append(body)
text = rest
else:
break
return "\n".join(re.sub(r"^\s*[*~]\s?", "", line).strip() for line in "\n".join(comments).splitlines())
def check_notice(name: str, text: str, policy: dict[str, Any]) -> None:
"""Require an approved holder/license pair in real leading comment syntax."""
suffix = Path(name).suffix
style = policy["styles"].get(suffix)
if Path(name).name.startswith("Dockerfile."):
style = "hash"
if style is None:
raise ValueError("Selected path has no configured comment syntax")
comments = leading_comments(text, style)
approved = [item for item in policy["notices"] if any(fnmatch.fnmatchcase(name, pattern) for pattern in item["paths"])]
for notice in approved:
holder = re.escape(notice["holder"])
copyright_pattern = rf"(?m)^Copyright\s+(?:\([cC]\)\s*)?\d{{4}}(?:[-–]\d{{4}})?\.?\s+{holder}(?:\s+\(KofTwentyTwo\))?\.?(?:\s+Licensed under the MIT License \(see LICENSE\)\.)?$"
if re.search(copyright_pattern, comments) is None:
continue
spdx = re.findall(r"(?m)^SPDX-License-Identifier:\s*(\S+)\s*$", comments)
if spdx and any(value != notice["license"] for value in spdx):
continue
apache = all(phrase in comments for phrase in (
"Licensed under the Apache License, Version 2.0",
"you may not use this file except in compliance with the License.",
"https://www.apache.org/licenses/LICENSE-2.0",
"WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND",
"limitations under the License.",
))
if suffix == ".java" and not apache:
continue
if notice["license"] in spdx or (notice["license"] == "Apache-2.0" and apache):
return
raise ValueError("Missing or mismatched leading copyright/license notice")
def check_repository(root: Path, policy: dict[str, Any]) -> int:
"""Check tracked sources without scanning build outputs or executing source."""
tracked = subprocess.run(["git", "-C", str(root), "ls-files", "-z"], check=True, capture_output=True).stdout.decode("utf-8").split("\u0000")
failures = []
count = 0
for name in tracked:
if not name or not selected(name, policy):
continue
count += 1
try:
check_notice(name, (root / name).read_text(encoding="utf-8-sig"), policy)
except (ValueError, OSError) as error:
failures.append(f"{name}: {error}")
if count == 0:
raise ValueError("No owned source files selected")
if failures:
raise ValueError("\n".join(failures))
return count
def main() -> None:
"""Load the repository-specific reviewed scope and fail CI on a bad notice."""
root = Path(__file__).resolve().parents[2]
policy = json.loads((root / ".github/source-headers.json").read_text(encoding="utf-8"))
count = check_repository(root, policy)
print(f"Leading source notices verified: {count} tracked files")
if __name__ == "__main__":
try:
main()
except (ValueError, OSError, subprocess.CalledProcessError) as error:
print(str(error), file=sys.stderr)
sys.exit(1)
+3
View File
@@ -1,3 +1,6 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
"""Exercise the exact CI guards using synthetic evidence only."""
import contextlib
import copy
+156
View File
@@ -0,0 +1,156 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
"""Exercise notice parsing, provenance, scope and real Git inventory rejection."""
import importlib.util
import json
import subprocess
import tempfile
import unittest
from pathlib import Path
from typing import Any
SPEC = importlib.util.spec_from_file_location("headers", Path(__file__).with_name("check-source-headers.py"))
assert SPEC is not None and SPEC.loader is not None
HEADERS = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(HEADERS)
POLICY = json.loads(Path(__file__).parents[1].joinpath("source-headers.json").read_text(encoding="utf-8"))
MIT = "Copyright (c) 2026 James Maes\nSPDX-License-Identifier: MIT"
APACHE = """Copyright (C) 2021-2025. Kingsrook, LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License."""
def fixture_policy(holder: str = "James Maes", license_id: str = "MIT") -> dict[str, Any]:
"""Use the production styles with a small explicit owned fixture scope."""
return {"includes": ["src/*"], "exclusions": [], "styles": POLICY["styles"],
"notices": [{"holder": holder, "license": license_id, "paths": ["src/*"]}]}
class HeaderTests(unittest.TestCase):
"""Ensure policy failures cannot pass through text-only copyright searches."""
def test_each_real_comment_syntax(self) -> None:
"""Recognize hash, slash, block and XML notices in their own languages."""
for name, text in (
("src/a.py", "# " + MIT.replace("\n", "\n# ") + "\nprint(1)"),
("src/a.cs", "// " + MIT.replace("\n", "\n// ") + "\nclass A {}"),
("src/a.mjs", "/*\n * " + MIT.replace("\n", "\n * ") + "\n */\nexport {};"),
("src/a.xml", "<!--\n" + MIT + "\n-->\n<root/>"),
("src/a.ps1", "<#\n" + MIT + "\n#>\nWrite-Output 1"),
):
with self.subTest(name=name):
HEADERS.check_notice(name, text, fixture_policy())
def test_required_first_lines_and_bom(self) -> None:
"""Accept shebangs, PowerShell requirements and XML declarations first."""
for name, prefix, body in (
("src/a.sh", "#!/bin/sh\n", "# " + MIT.replace("\n", "\n# ")),
("src/a.ps1", "#Requires -Version 7.4\n", "# " + MIT.replace("\n", "\n# ")),
("src/a.xml", "<?xml version=\"1.0\"?>\n", "<!--" + MIT + "--><root/>"),
("src/a.mjs", "#!/usr/bin/env node\n", "/*" + MIT + "*/"),
):
with self.subTest(name=name):
HEADERS.check_notice(name, "\ufeff" + prefix + body, fixture_policy())
def test_misordered_required_first_lines_fail(self) -> None:
"""A notice must not displace an interpreter or XML declaration."""
for name, text in (
("src/a.sh", "# " + MIT.replace("\n", "\n# ") + "\n#!/bin/sh"),
("src/a.ps1", "# " + MIT.replace("\n", "\n# ") + "\n#Requires -Version 7.4"),
("src/a.xml", "<!--" + MIT + "--><?xml version=\"1.0\"?><root/>"),
):
with self.subTest(name=name), self.assertRaises(ValueError):
HEADERS.check_notice(name, text, fixture_policy())
def test_code_strings_and_late_comments_do_not_count(self) -> None:
"""Reject docstrings and comments after executable declarations."""
for text in ("notice = \"" + MIT.replace("\n", "\\n") + "\"",
"\"\"\"" + MIT + "\"\"\"", "import os\n# " + MIT.replace("\n", "\n# ")):
with self.subTest(text=text), self.assertRaises(ValueError):
HEADERS.check_notice("src/a.py", text, fixture_policy())
def test_wrong_syntax_and_unterminated_comments_fail(self) -> None:
"""Notice text alone is insufficient without a complete language comment."""
for text in ("# " + MIT, "/*" + MIT, "string text = \"" + MIT + "\";"):
with self.subTest(text=text), self.assertRaises(ValueError):
HEADERS.check_notice("src/a.cs", text, fixture_policy())
with self.assertRaises(ValueError):
HEADERS.check_notice("src/a.py", "<#" + MIT + "#>", fixture_policy())
def test_wrong_holder_or_license_and_conflicting_spdx_fail(self) -> None:
"""Do not conflate repository licenses or accept conflicting identifiers."""
for text in (MIT.replace("James Maes", "Someone Else"), MIT.replace("MIT", "Apache-2.0"),
MIT + "\nSPDX-License-Identifier: Apache-2.0", MIT.splitlines()[0]):
with self.subTest(text=text), self.assertRaises(ValueError):
HEADERS.check_notice("src/a.cs", "/*" + text + "*/", fixture_policy())
def test_complete_legacy_apache_without_spdx(self) -> None:
"""Retain valid legacy year ranges and complete Apache notices."""
policy = fixture_policy("Kingsrook, LLC", "Apache-2.0")
HEADERS.check_notice("src/A.java", "/*" + APACHE + "*/\nclass A {}", policy)
HEADERS.check_notice("src/a.xml", "<!--" + APACHE + "--><root/>", policy)
def test_java_requires_full_apache_block(self) -> None:
"""An abbreviated SPDX-only Java notice cannot replace scoped convention."""
policy = fixture_policy("Kingsrook, LLC", "Apache-2.0")
for notice in ("Copyright 2026 Kingsrook, LLC\nSPDX-License-Identifier: Apache-2.0",
APACHE.replace("limitations under the License.", "")):
with self.subTest(notice=notice), self.assertRaises(ValueError):
HEADERS.check_notice("src/A.java", "/*" + notice + "*/", policy)
def test_virtual_agent_provenance_is_path_scoped(self) -> None:
"""Only the owned virtual-agent family may use its existing QRun.IO holder."""
policy = fixture_policy()
policy["notices"].append({"holder": "QRun.IO, Inc.", "license": "MIT", "paths": ["tools/virtual-agent/*"]})
text = "/*Copyright 2026 QRun.IO, Inc. Licensed under the MIT License (see LICENSE).\nSPDX-License-Identifier: MIT*/"
HEADERS.check_notice("tools/virtual-agent/a.mjs", text, policy)
with self.assertRaises(ValueError):
HEADERS.check_notice("src/a.mjs", text, policy)
def test_current_scope_and_documented_exclusions(self) -> None:
"""New scripts enter the guard; data, generated and upstream files do not."""
self.assertTrue(HEADERS.selected(".github/scripts/new-tool.py", POLICY))
self.assertTrue(HEADERS.selected(".github/workflows/new-check.yml", POLICY))
for name in (".mvn/wrapper/MavenWrapperDownloader.java", "mvnw", "schemas/new.json",
"src/main/resources/openapi/new.yaml", "src/test/resources/openapi/old.yaml",
"src/generated/Example.cs", "src/Example/obj/Generated.cs", "packages.lock.json"):
with self.subTest(name=name):
self.assertFalse(HEADERS.selected(name, POLICY))
self.assertTrue(all(item["reason"].strip() for item in POLICY["exclusions"]))
def test_guard_sources_match_this_repository_provenance(self) -> None:
"""Check copied guard files against local policy even before Git staging."""
for filename in ("check-source-headers.py", "test-source-headers.py"):
path = Path(__file__).with_name(filename)
name = ".github/scripts/" + filename
self.assertTrue(HEADERS.selected(name, POLICY))
HEADERS.check_notice(name, path.read_text(encoding="utf-8"), POLICY)
def test_real_git_inventory_rejects_missing_tracked_header(self) -> None:
"""A tracked new source fails while untracked generated output is ignored."""
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
subprocess.run(["git", "init", "-q", str(root)], check=True)
root.joinpath("src").mkdir()
source = root / "src/a.py"
source.write_text("print(1)", encoding="utf-8")
subprocess.run(["git", "-C", str(root), "add", "--", "src/a.py"], check=True)
with self.assertRaisesRegex(ValueError, "src/a.py"):
HEADERS.check_repository(root, fixture_policy())
source.write_text("# " + MIT.replace("\n", "\n# ") + "\nprint(1)", encoding="utf-8")
root.joinpath("src/untracked.py").write_text("print(2)", encoding="utf-8")
self.assertEqual(1, HEADERS.check_repository(root, fixture_policy()))
if __name__ == "__main__":
unittest.main()
+98
View File
@@ -0,0 +1,98 @@
{
"description": "Scope of the reviewed 2026-11-01 owned-source notice pass. Python fnmatch patterns match slash-containing relative paths. Native C# IDE0073 and full Java Apache conventions remain in force.",
"includes": [
".github/*.yml",
".github/*.yaml",
".github/scripts/*.py",
"tools/*.mjs"
],
"exclusions": [
{
"pattern": "*.json",
"reason": "Schema, fixtures, manifests and lock data: repository LICENSE/NOTICE, no comments."
},
{
"pattern": "*.lock",
"reason": "Dependency lock data, not owned executable source."
},
{
"pattern": "*/generated/*",
"reason": "Explicit generated-code directory."
},
{
"pattern": "*/bin/*",
"reason": "Generated build output."
},
{
"pattern": "*/obj/*",
"reason": "Generated build output."
},
{
"pattern": "*/node_modules/*",
"reason": "Third-party dependency output."
},
{
"pattern": ".mvn/*",
"reason": "Upstream Maven wrapper provenance."
},
{
"pattern": "mvnw*",
"reason": "Upstream Maven wrapper provenance."
},
{
"pattern": "Assets/Legal/*",
"reason": "Byte-exact third-party legal files."
},
{
"pattern": "src/main/resources/*",
"reason": "Bundled font/legal assets and OpenAPI schema data."
},
{
"pattern": "src/test/resources/openapi/*",
"reason": "Historical contract schema fixtures."
},
{
"pattern": "*.slnx",
"reason": "Solution metadata outside the completed source-header pass; root LICENSE/NOTICE applies."
},
{
"pattern": "*.manifest",
"reason": "Application activation metadata outside this pass; root LICENSE/NOTICE applies."
},
{
"pattern": "*.runsettings",
"reason": "Test runner metadata outside this pass; root LICENSE/NOTICE applies."
},
{
"pattern": ".editorconfig",
"reason": "Style configuration outside this pass; root LICENSE/NOTICE applies."
}
],
"styles": {
".py": "hash",
".ps1": "powershell",
".psd1": "powershell",
".sh": "hash",
".yml": "hash",
".yaml": "hash",
".cs": "slash",
".java": "slash",
".mjs": "slash",
".js": "slash",
".xml": "xml",
".props": "xml",
".csproj": "xml",
".wxs": "xml",
".wixproj": "xml",
".xaml": "xml"
},
"notices": [
{
"paths": [
"*"
],
"license": "Apache-2.0",
"holder": "Kingsrook, LLC"
}
]
}
+7
View File
@@ -1,3 +1,6 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
name: Protocol CI and release
on:
@@ -23,6 +26,10 @@ jobs:
node-version: '24'
package-manager-cache: false
- run: npm ci --ignore-scripts
- name: Verify owned source notices and rejection paths
run: |
python3 .github/scripts/test-source-headers.py
python3 .github/scripts/check-source-headers.py
- run: npm run check
- run: npm run bundle
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+4 -1
View File
@@ -1,3 +1,6 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
# Local copy of KofTwentyTwo/standards at fdf5ed029603c938615a32b1beca3628b8787945 (v0.1.2).
# Pins, inputs and analysis checks preserved; report validation now fails closed.
# Static analysis (K22-CI-22): CodeQL with the security-extended suite, failing on
@@ -87,7 +90,7 @@ jobs:
global-json-file: global.json
- name: Initialize CodeQL
uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
languages: ${{ matrix.language }}
build-mode: ${{ contains(fromJSON('["actions","javascript-typescript","python","ruby"]'), matrix.language) && 'none' || inputs.build-mode }}
+3
View File
@@ -1,3 +1,6 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
name: codeql
on:
pull_request:
+3
View File
@@ -1,3 +1,6 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
# Pull request gates (K22-CI-01): Conventional Commit title, DCO sign-off, and
# dependency review, from the shared KofTwentyTwo/standards workflow pinned by SHA
# (K22-CI-11). Dependabot's github-actions ecosystem keeps the pin current.
+3
View File
@@ -1,3 +1,6 @@
# Copyright 2026 Kingsrook, LLC
# SPDX-License-Identifier: Apache-2.0
name: security
on:
+33 -1
View File
@@ -1,5 +1,38 @@
# Changelog
## 0.3.0-rc.1 — 2026-10-31
Upgrade the server before the matching Windows connector. Native printing API
2.0 requires synchronized authenticated WSS; the wire payload version remains 1.
- Define printing semantic extension 2.0 with an authenticated outbound WebSocket
for direct controls, events, receipts and job notifications; prohibit polling
fallback while retaining existing wire-v1 domain payloads.
- Mark the mandatory channel prerequisite for native claims as a breaking API
change from extension 1.0; framework `/qqq/v1` and the v1 subprotocol stay unchanged.
- Specify durable reconnect reconciliation, exact ACK replay and independent
same-origin HTTPS streaming for large documents.
- Add validated administrator settings and declared client ceilings for document
sizes, copies, queues, concurrency, bandwidth, liveness and timeouts, including
the native submission deadline.
- Add strict frame/settings schemas, synthetic fixtures and negative validation.
Consumer transport, installed UI and physical output acceptance remain separate.
## 0.2.0-rc.1 — 2026-10-31
- 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.
- Require bound devices to use native user sessions on every device route;
legacy compatibility cannot downgrade secondary proof into primary authority.
Native hosts disable legacy enrollment by default.
## 0.1.4-rc.1 — 2026-10-30
- Gate publication on security checks, promote the tested archive and verify packaged checksums.
@@ -13,7 +46,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.
+14 -5
View File
@@ -1,8 +1,8 @@
# QQQ Print Protocol v1
Shared contract for the [QQQ 4.1+ printing QBit](https://github.com/QRun-IO/qbit-printing) and [Windows AppKit connector](https://github.com/QRun-IO/qqq-print-agent). The first implementation uses outbound HTTPS polling. WebSockets are unnecessary for this happy path. This contract is authoritative for wire names; language types may use their idiomatic naming.
Shared contract for the [QQQ 4.1+ printing QBit](https://github.com/QRun-IO/qbit-printing) and [Windows AppKit connector](https://github.com/QRun-IO/qqq-print-agent). The coordinated [realtime extension 2.0](docs/REALTIME.md) uses a client-initiated authenticated WebSocket for controls, receipts and job notifications, with separate streamed HTTPS documents and no polling fallback. This contract is authoritative for wire names; language types may use their idiomatic naming.
Source distribution version: **0.1.4-rc.1** (unreleased audit update). Wire protocol: **v1**. This repository supplies specification, JSON Schema and synthetic compatibility fixtures; install the Windows connector and integrate the QBit from their own repositories to print documents.
Current release candidate: [**0.3.0-rc.1**](https://github.com/QRun-IO/qqq-print-protocol/releases/tag/v0.3.0-rc.1). Previous distribution: [**0.2.0-rc.1**](https://github.com/QRun-IO/qqq-print-protocol/releases/tag/v0.2.0-rc.1). Wire protocol: **v1**. This repository supplies specification, JSON Schema and synthetic compatibility fixtures; install the Windows connector and integrate the QBit from their own repositories to print documents.
Use Node.js 24+ to run the development checks:
@@ -23,9 +23,11 @@ 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.
The realtime implementation requires printing semantic extension `>=2.0.0 <3.0.0`; wire payload version remains `1`. Its settings, frames, replay rules and required consumer acceptance are in [REALTIME.md](docs/REALTIME.md). Upgrade the server before the matching Windows connector; installed and physical-output acceptance remain separate from this contract release. The HTTP polling sequence below documents the earlier 1.0/explicit compatibility transport, not a fallback for a realtime client.
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.
Primary native base: `/qqq/v1/qbit/printing`, discovered as QQQ semantic extension `printing`, using standard QQQ user authentication and secondary device proof. Realtime requires extension `2.0.0`; the earlier HTTP transport uses `1.0.0`. 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. The explicit legacy endpoints below use `Authorization: Bearer <token>`. Device tokens and admin credentials are distinct; a device token must never authorize `/admin` operations. Legacy enrollment uses only the single-use code in its request body. Never put credentials in query strings or log them.
The host application authenticates management requests and determines authorized tenant/facility scope. A standalone local test host may use a configured administrative bearer credential. Device ownership, tenant and facility are taken from server records, not trusted request fields.
@@ -49,6 +51,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. These additions are included in the
`0.2.0-rc.1` distribution, with 42 definitions and 54 validated fixtures.
## 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.
@@ -69,7 +78,7 @@ Device lists and job lists are scoped by host authorization. Listing pagination
Formats: `PDF` (`application/pdf`) and `ZPL` (`application/vnd.zebra-zpl`). Content downloads may use `application/octet-stream`; the authenticated job's format is authoritative. ZPL content is UTF-8 printer-ready data. Printer status is `READY`, `OFFLINE`, `PAUSED`, `ERROR`, or `UNKNOWN`. It is a driver observation, not proof that hardware is ready. Formats are advertised adapter capabilities; do not infer ZPL support for every Windows queue.
## Happy-path sequence
## Historical HTTP happy-path sequence
1. Administrator creates an enrollment and enters its code plus server URL in the connector.
2. Connector exchanges the code once and securely retains the device identity/token.
+219
View File
@@ -0,0 +1,219 @@
# Device controls — additive wire v1
For the coordinated printing extension 2.0 implementation, these domain state,
command and receipt semantics use [direct realtime frames](REALTIME.md).
The HTTP polling routes below describe the earlier 1.0/explicit compatibility
transport and are never a fallback for a realtime client.
This extension provides server dispatch controls and two bounded Windows agent
commands. Its additive payloads preserve existing print job states and old
request bodies. These domain controls were published in package `0.2.0-rc.1`;
their realtime transport is part of release candidate `0.3.0-rc.1`. This contract does
not establish 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.
Legacy device-token authentication is valid only for eligible unbound migration
identities. Account-bound devices must use native user sessions; a bound token
alone is denied even when the legacy endpoint remains registered. Native hosts
disable legacy enrollment unless a legacy-only deployment explicitly enables it.
| 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.
+16 -2
View File
@@ -14,9 +14,23 @@ If a consumer builds outside a multi-checkout workspace, download the release ar
## Versioning
Wire protocol v1 is selected by the `/printing/v1` path and enrollment response `protocolVersion: 1`. Distribution version `0.1.0-rc.1` identifies the specification/fixture bundle; it does not change that wire version.
The coordinated realtime client requires printing semantic extension
`>=2.0.0 <3.0.0` and the authenticated `qqq-printing.v1` WebSocket subprotocol.
Wire payload version remains 1. [REALTIME.md](REALTIME.md) defines the new frames,
configurable policy, streaming limits and runtime acceptance expectations.
The release candidate is [0.3.0-rc.1](https://github.com/QRun-IO/qqq-print-protocol/releases/tag/v0.3.0-rc.1).
Upgrade the server before the Windows connector; fixtures do not establish installed behavior.
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.
Historical HTTP wire protocol v1 is selected by the `/printing/v1` path and enrollment response `protocolVersion: 1`. Published distribution version `0.2.0-rc.1` identifies the specification/fixture bundle; it does not change that wire version. The Windows and QBit release numbers are independent of both. Native clients use the discovered printing extension semantic version; the realtime contract above adds its explicit version/capability gate without redefining core discovery.
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 published `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 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.
+95
View File
@@ -0,0 +1,95 @@
# 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. The standard-session contract was published in `0.2.0-rc.1`; realtime
transport is part of release candidate `0.3.0-rc.1`. This document does not establish an
accepted Authentik deployment.
## Discovery and sign-in
Require printing extension `>=2.0.0 <3.0.0` before opening the realtime channel
or dispatching work. Standard discovery and sign-in may accept compatible 1.x
and 2.x extensions; a successful 1.x login does not permit realtime dispatch or
polling fallback. Published 0.2 clients used native printing extension 1.0 and
cannot satisfy the new mandatory channel prerequisite. The standard descriptor
is unchanged; `realtime-v1` is mandatory in WebSocket HELLO. See
[realtime authentication and transport](REALTIME.md).
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 <3.0.0` for the shared authentication client. Realtime operation still
requires the stricter 2.x gate above.
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.
Once a device is bound to a QQQ principal, its token must be rejected by legacy
device routes. The same secondary proof cannot be used to bypass user permissions
through an older endpoint. Existing unbound devices may remain eligible for an
explicit migration policy until they are associated with an account.
Native hosts disable legacy enrollment by default: an enrollment code cannot
create a bearer-only identity through the old route. Legacy-only hosts may
explicitly enable that separate compatibility flow. Native enrollment continues
to require the current QQQ user, facility permissions and atomic account binding.
+291
View File
@@ -0,0 +1,291 @@
# Realtime printing — semantic extension 2.0
This is the contract for the `0.3.0-rc.1` realtime release candidate.
Contract publication does not establish installed acceptance. Existing job states, command outcomes,
current-user authorization and durable idempotency rules remain authoritative.
Native printing extension **2.0.0** makes an authenticated synchronized WebSocket
a prerequisite for claiming jobs. Published extension 1.0 clients could claim
over HTTP alone, so this changes existing behavior and requires a major version
under [Semantic Versioning](https://semver.org/#spec-item-8). A coordinated upgrade
does not make that change backward compatible. Product `0.3.0-rc.1`, framework
`/qqq/v1`, wire payload v1 and subprotocol `qqq-printing.v1` remain independent.
## Discovery and connection
Require QQQ printing semantic extension `>=2.0.0 <3.0.0`, discovered through
`/qqq/v1/version`, and the existing core API/channel/authentication gates.
A 1.x server is upgrade-required for this client. Do not infer support from a
socket accepting a connection, a saved version, or a prior enrollment.
`printing-realtime-extension.discovery.json` shows the existing core `extensions`
entry for printing 2.0.0, not a replacement schema for the core discovery document.
Its descriptor uses the unchanged name `printing` and path prefix `/qbit/printing`.
The client opens one persistent WebSocket to the configured QQQ origin:
- Route: `/qqq/v1/qbit/printing/device/realtime`.
- Subprotocol: `qqq-printing.v1`; the server must select this exact value.
- Scheme: `wss`; `ws` is allowed only for explicitly configured loopback development.
- Upgrade authentication: standard QQQ session cookie,
`X-QQQ-Device-Id`, `X-QQQ-Device-Token`, `X-QQQ-Client: api/qqq-print-agent`
and the canonical same-origin `Origin`.
- Capability: `realtime-v1` is mandatory in `HELLO`. Optional command capabilities
are the existing `REFRESH_INVENTORY` and `CANCEL_SPOOL`.
Do not add credentials to URLs, subprotocols, logs or frame payloads. Refuse
cross-origin redirects and arbitrary content URLs. The existing session is
primary authority; device proof never replaces it. See [QQQ authentication](QQQ-AUTHENTICATION.md).
Authenticate the upgrade and revalidate the current session, principal/device
binding, tenant and operation-specific permissions for each incoming operation
and outgoing delivery. Revalidate on liveness traffic too; an idle connection
cannot preserve expired or withdrawn authority indefinitely. Use the normal QQQ
session-resume flow; provider refresh stays on the server. When the session
credential changes, reconnect with the current credential while retaining the
same authoritative device/journal scope. No private WebSocket token is created.
There is one active authenticated connection per device. A new connection
supersedes the old generation. A late close, ACK or callback from an older
generation cannot close or acknowledge the replacement. A connection ID and
message order identify transport ownership, never user authority.
## Frames
Only UTF-8 JSON text messages are accepted. Each complete message has
`{ "type": "...", "messageId": "...", "payload": { ... } }`.
Use the existing URL-safe `Id` alphabet and 200-character bound; use hyphens,
not colons, in generated prefixes. Bound the reassembled message, not merely an
individual WebSocket fragment, by `maxFrameBytes`. Binary/document messages and
compression that could bypass decoded-size limits are not supported.
| Direction | Type | Payload |
| --- | --- | --- |
| Client → server | HELLO | `agentVersion`, `capabilities`, `lastAppliedRevision`, `lastSettingsRevision`, `limits` |
| Server → client | WELCOME | `connectionId`, `serverTime`, `desiredState`, `settings` |
| Client → server | STATE_APPLIED | `lastAppliedRevision`, `lastSettingsRevision` |
| Server → client | DESIRED_STATE | Existing `DeviceDesiredState` directly |
| Server → client | SETTINGS | `RealtimeSettings` directly |
| Server → client | JOB_AVAILABLE | Empty object; a coalescible wake-up, not a job or claim |
| Server → client | COMMAND | Existing `DeviceCommand` directly |
| Client → server | JOB_EVENT | `jobId`, existing `JobEvent` as `event` |
| Client → server | COMMAND_RECEIPT | `commandId`, existing `DeviceCommandReceipt` as `receipt` |
| Client → server | HEARTBEAT | Existing `HeartbeatRequest` directly |
| Client → server | INVENTORY | Existing `PrinterInventory` directly |
| Client → server | PING | Empty object; liveness only |
| Server → client | PONG | `serverTime` |
| Server → client | ACK | `acknowledgedMessageId` |
| Server → client | ERROR | `code`, safe `message`, `retryable` |
The settings payload schema is `RealtimeSettings`; its envelope schema is
`RealtimeSettingsMessage`. The directional unions are `RealtimeClientMessage`
and `RealtimeServerMessage`. An ERROR responding to a frame uses that frame's
`messageId`; connection-level errors use a fresh ID. Never treat an ERROR as
a successful ACK or erase the corresponding durable event/receipt.
Canonical error codes include AUTHORIZATION_REQUIRED, CONFIGURATION_REQUIRED,
INVALID_MESSAGE and OPERATION_FAILED. Preserve domain NOT_FOUND, EXPIRED and
CONFLICT distinctions for 404/410/409 outcomes, including the existing bounded
pruned-receipt retirement rule. Only transient failures set `retryable:true`;
unknown codes remain explicit errors, never permission to fall back or drop work.
HEARTBEAT is periodic small status/profile traffic over this socket. INVENTORY
runs on initial synchronization, actual local changes or an explicit refresh
command; it is not an HTTP polling loop. PING/PONG may maintain liveness without
refreshing inventory or querying jobs/controls. Only one serialized writer owns
socket sends; the receive/control path must not await document download,
rendering, printer submission or UI work.
## Ordering, synchronization and replay
1. Authenticate the connection, then require HELLO before other application work.
Its revision values report previously persisted observations, not authority.
Subscribe to committed changes before taking the initial durable snapshot.
2. Send WELCOME with current settings and desired state. The client validates
policy and its declared ceilings, persists both revisions and values, applies
them, and sends STATE_APPLIED. Neither revision may roll back or exceed the
authoritative revision.
3. Acknowledge STATE_APPLIED only after recording that observation for the active
connection. New claims require that active authorized connection and both
current revisions. The client also waits for that matching ACK before claims.
Remember an early JOB_AVAILABLE until this gate is satisfied.
4. Deliver pending unexpired commands and a coalesced JOB_AVAILABLE for durable
queued work. Committed desired-state, settings, job and command changes trigger
delivery. Use a transaction/outbox or post-commit synchronization mechanism
that cannot lose a change between subscription, snapshot and the empty claim.
5. JOB_AVAILABLE triggers a finite authenticated HTTPS claim drain until empty
or policy/concurrency capacity is reached. Keep a pending wake while at capacity;
lane completion resumes that drain. Reaching empty atomically clears the wake
relative to later committed enqueues. Returning to eligible state after
resume/reconnect or settings application also wakes the drain.
6. Persist exact JOB_EVENT and COMMAND_RECEIPT envelopes before sending.
ACK means the same existing authorized service operation committed durably.
Replay identical message IDs, domain event/receipt IDs and payloads until ACK.
Lost ACKs must return success for the original committed result; changed
content for an existing identity conflicts.
7. After network loss, reconnect using configured bounded exponential backoff
and jitter, repeat HELLO/WELCOME/current-state reconciliation, and replay the
durable outbox. The server replays pending commands from its durable store;
notifications and per-connection ordering are not a durable work ledger.
No job, control, heartbeat or receipt HTTP timer is permitted in this transport.
No timeout, 404, socket failure, empty response or unsupported version enables
HTTP polling or bearer fallback. Existing compatibility routes remain explicit
diagnostics for eligible identities, not this client's recovery path.
An implementation that uses timed database polling as its notification source
does not satisfy this contract. Multi-node hosts need shared committed-change
delivery; an in-process event bus qualifies only its documented single-node scope.
Job claims/content stay on their existing authenticated HTTPS routes at the same
origin. A lost claim response must be reconciled with the authoritative assigned
job and local journal on reconnect before claiming unrelated work; it cannot
strand a CLAIMED job or authorize a second physical submission. Initial or
event-triggered bounded HTTPS reconciliation snapshots are allowed, never timers.
The existing command journal decides whether a repeated command is unexecuted,
in progress or terminal. Persist execution intent before a native cancellation;
after uncertain execution return OUTCOME_UNKNOWN, never blindly invoke it again.
The existing receipt-retention and pruned-history rules still apply. A missing
expired server receipt is not fabricated server acceptance.
DISCONNECTED and dispatch pause gate new claims. DISCONNECTED retains the socket,
desired-state updates, commands, receipts and in-flight job events, but suppresses
ordinary HEARTBEAT/inventory publication; PING/PONG still maintains liveness.
REFRESH_INVENTORY waits until CONNECTED or expires. RECONNECT does not clear pause,
and neither control restores revoked credentials. No command restarts Windows,
the service or a printer. A reported spool cancellation cannot recall paper.
## Operational settings and client ceilings
The server is authoritative. Administrators edit validated operational policy
through native QQQ permissions; agents cannot set it. All 23 properties below,
including the server-generated `revision`, are required in each complete settings
snapshot. Never infer an omitted value from a compiled default.
| Property | Default | Inclusive range |
| --- | --- | --- |
| `revision` | Server-generated | 0–9007199254740991 |
| `maxCopies` | 100 | 1–32767 |
| `maxPdfBytes` | 104857600 | 1–2147483647 |
| `maxZplBytes` | 20971520 | 1–2147483647 |
| `maxFrameBytes` | 1048576 | 8192–16777216 |
| `maxQueuedFrames` | 64 | 8–4096 |
| `maxQueuedFrameBytes` | 4194304 | 8192–67108864 |
| `maxConcurrentDownloads` | 1 | 1–16 |
| `maxConcurrentPrints` | 1 | 1–16 |
| `maxStagedBytes` | 536870912 | 1–9007199254740991 |
| `maxDownloadBytesPerSecond` | 0 | 0–2147483647 |
| `requestTimeoutMillis` | 30000 | 1000–300000 |
| `downloadConnectTimeoutMillis` | 30000 | 1000–300000 |
| `downloadIdleTimeoutMillis` | 30000 | 1000–3600000 |
| `downloadTimeoutMillis` | 600000 | 1000–86400000 |
| `webSocketHandshakeTimeoutMillis` | 30000 | 1000–300000 |
| `heartbeatIntervalSeconds` | 20 | 5–300 |
| `heartbeatTimeoutSeconds` | 60 | 10–900 |
| `reconnectMinDelayMillis` | 1000 | 100–60000 |
| `reconnectMaxDelayMillis` | 30000 | 1000–300000 |
| `reconnectJitterPercent` | 20 | 0–50 |
| `ackTimeoutMillis` | 15000 | 1000–300000 |
| `nativeSubmissionTimeoutMillis` | 120000 | 1000–86400000 |
Bytes are exact integers; 100 MiB PDF and 20 MiB ZPL are recommendations, not
immutable content limits. `maxDownloadBytesPerSecond:0` explicitly means
unlimited aggregate download bandwidth. Jitter is applied within the configured
minimum/maximum reconnect bounds, including at the cap. No setting authorizes
an arbitrary URL, OS operation or widening of a user's permissions.
Validate these relationships before committing or applying policy:
- `maxQueuedFrameBytes >= maxFrameBytes`.
- `maxStagedBytes >= max(maxPdfBytes, maxZplBytes)`.
- `downloadTimeoutMillis >= downloadConnectTimeoutMillis` and
`downloadTimeoutMillis >= downloadIdleTimeoutMillis`.
- `heartbeatTimeoutSeconds >= 2 * heartbeatIntervalSeconds`.
- `reconnectMaxDelayMillis >= reconnectMinDelayMillis`.
- Each corresponding setting is at most the declared client ceiling.
HELLO `limits` declares the client's actual supported maxima for
`maxCopies`, `maxPdfBytes`, `maxZplBytes`, `maxFrameBytes`, `maxQueuedFrames`,
`maxQueuedFrameBytes`, `maxConcurrentDownloads`, `maxConcurrentPrints`,
and `maxStagedBytes`. It is not a client request to lower server policy.
Reject incompatible policy with CONFIGURATION_REQUIRED; never silently clamp or
use a lower intersection. Concurrency must be an actual configurable bounded
pool, with separate per-printer serialization, not a permanently serial
implementation advertising a configurable pool.
A committed policy revision is pushed as SETTINGS. Apply and durably acknowledge
it before new work under that revision. Existing transfers retain their admitted
resource reservation and a consistent timeout/size snapshot; reducing limits
blocks further admission until usage fits, rather than discarding receipts or
reclassifying already submitted output. Queue/frame limits apply to both count
and bytes. Reserve capacity for acknowledgments/liveness; coalesce replaceable
state/settings/job notifications, but never drop durable events or receipts.
An overfull queue produces bounded backpressure/reconnect and durable replay.
Existing admission and retention policies keep their established names and
scopes; do not duplicate them as per-device transport defaults. Host/tenant
admission comprises `deviceOutstandingJobs`, `tenantOutstandingJobs`,
`deviceOutstandingBytes`, `tenantOutstandingBytes`, `deviceRetainedBytes`,
`tenantRetainedBytes`, `deviceJobsPerHour`, `tenantJobsPerHour`,
`devicePrinters` and `tenantPrinters`. Retention uses `enabled`, `days` and
`batchSize`. Their effective values and scope belong in authorized settings
metadata. Intrinsic protocol, renderer, pipe and operating-system ceilings must
be disclosed separately from editable operational policy. No implementation
may claim that this realtime settings object alone exposes every existing host
or Windows setting.
## Document transfer and failure boundaries
Large documents never enter WebSocket messages, JSON/base64 control buffers or
whole-file memory buffers. Stream the existing same-origin HTTPS content route
to a restricted temporary file with incremental SHA-256, actual byte counting,
format validation and a staging reservation. Validate declared length and format
against the effective PDF/ZPL policy before transfer; enforce actual bytes while
streaming, including absent or false Content-Length and decompression behavior.
Only verified complete content may be submitted to Windows.
Enforce configured connect, idle and total deadlines and aggregate bandwidth
across the bounded download pool. Dispose/cancel streams and remove only owned
temporary files on failure, retaining job identity/outcome evidence. A throttled
download must not consume the control reader, writer or command worker. Do not
start another download without its byte reservation or release reserved space
while a renderer still uses the file. No automatic retry may duplicate a
possibly submitted print.
Apply `nativeSubmissionTimeoutMillis` to the isolated native printer submission
using the admitted job's policy snapshot. A timeout does not prove that Windows
accepted no bytes; preserve uncertain output and never automatically submit it
again. A larger configured document limit must not leave a hidden fixed native
submission deadline in the consumer.
Authentication/permission failure blocks new work and preserves journal state.
A generic denial is not proof that the device was revoked. Protocol errors,
oversized messages and overload must fail explicitly; never treat them as an
empty queue. Close/reconnect transport without silently switching authentication.
Native HTTP calls continue to revalidate current user and device authority.
## Required consumer acceptance
Schema/fixture checks establish spelling, bounds and examples, not runtime or
hardware qualification. Both implementations must demonstrate these behaviors
with isolated real transports before release:
| Scenario | Required observation |
| --- | --- |
| Slow large PDF | Hold an HTTPS response mid-body; receive/apply a new desired-state revision and complete an unrelated owned command/receipt ACK before releasing the body. Liveness continues; resident memory does not scale with document length. |
| Concurrent transfers | Set download/print concurrency above one; observe bounded actual parallel transfers, aggregate bandwidth/staging limits and per-printer serialization. Restore the configured default of one without dropping admitted work. |
| Lost command receipt ACK | Commit receipt, drop ACK, reconnect and replay exact envelope; one native cancellation invocation, one durable result, replay ACK succeeds. |
| Interrupted cancellation | Lose the connection/process after durable execution intent but before a known result; preserve OUTCOME_UNKNOWN and do not issue the native operation twice. |
| Queued work while offline | Commit jobs and controls while the socket is absent; reconnect snapshot/pending-command replay and one job wake drain all eligible queued work without a timer or duplicate print. |
| Empty-claim/enqueue race | Enqueue after the last empty query while ending a drain; a committed wake is retained and the new job is claimed without polling. |
| Lost claim response | Disconnect after server claim commit; reconcile assigned work and the journal before further claims, without stranding or reprinting it. |
| Lost event ACK | Commit SPOOLED then drop ACK; the identical replay fixture is acknowledged without another spool submission. |
| Policy change | Push validated new settings during a stalled transfer; control remains responsive, new claims wait for both revisions, and existing reservations remain bounded. |
| Policy/ceiling refusal | Reject missing/fractional/out-of-range/cross-field-invalid values and a policy above declared client capability; no silent defaults or clamping. |
| Authorization loss | Revoke session/device or withdraw current permission while idle and busy; reject further delivery/claims, preserve pending evidence and never use bearer fallback. |
| Connection replacement | Replace a socket, then deliver old-generation ACK/close callbacks; replacement ownership and pending messages remain correct. |
| Inventory/frame bounds | Exercise the maximum configured inventory and fragmented/oversized control messages; fail explicitly before unbounded allocation, never interpret partial inventory as withdrawal. |
| Notification source | Demonstrate after-commit enqueue/control/settings wake-ups and reconnect reconciliation with no periodic HTTP or database job/control polling. |
Physical output still requires user/hardware confirmation. SPOOLED and
SPOOLER_COMPLETED retain their existing meanings.
+67 -1
View File
@@ -1,4 +1,70 @@
# Session state — 2026-10-30
# Session state — 2026-10-31
## Realtime contract implementation
Isolated branch `feat/realtime-printing-20261031` defines printing extension 2.0,
authenticated client-initiated WSS, direct controls/events/receipts and streamed
same-origin HTTPS documents without job/control polling or fallback. The frozen
settings include a default single configurable download lane, 100 MiB PDF,
20 MiB ZPL, 100 copies and a configurable 120-second native submission deadline,
with declared client ceilings and explicit validation.
Slow-transfer control delivery, lost-connection replay, claim reconciliation and
no-duplicate-submission checks are required consumer acceptance, not claims made
by schema validation. Release candidate `0.3.0-rc.1` is described in
[the complete contract](REALTIME.md); this source checkpoint records no live changes.
The native API major is 2 because an authenticated synchronized WebSocket is now
mandatory for claims that 1.0 clients could previously make over HTTP alone.
Product 0.3, framework `/qqq/v1`, wire payload v1 and the v1 subprotocol remain
unchanged. A new fixture covers the standard printing extension descriptor;
historical legacy discovery fixtures remain unchanged.
Locked offline dependency restore, `npm run check` and `npm run bundle` passed
for this source: 71 JSON Schema definitions and 73 synthetic fixtures, including
the final native submission deadline, all 23 required settings, invalid policy
bounds, directional frames and exact replay. The eight changed Markdown documents
passed Markdown lint; `git diff --check` passed. The local archive is preparation
only; both consumers pin reviewed protocol commit `df3729ac632d4a34a3fb8423acef465042a624fc`.
Tag publication and asset provenance must be checked on the release workflow.
## Historical 0.2 implementation checkpoint — standard-session 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 verified all 42 definitions and 54 fixtures successfully. The native
authentication boundary also rejects bound device tokens on legacy routes and
disables legacy enrollment by default in native hosts; only eligible unbound
identities retain explicit migration compatibility. This clarification changes
no schema, fixture or consumer pin.
## Historical 0.2 implementation checkpoint — device-control 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
The compatible contract bundle [v0.1.4-rc.1](https://github.com/QRun-IO/qqq-print-protocol/releases/tag/v0.1.4-rc.1) is published. Earlier candidate and publication-pending entries below describe their original checkpoint. The coordinated Windows and QBit audit prereleases are also published; machine installation, local-host acceptance, physical output and production storage qualification have their own evidence and must not be inferred from this contract release.
The wire remains `/printing/v1` with integer protocolVersion validation. Distribution SemVer does not provide runtime feature negotiation. No API behavior or schema changed in this documentation follow-up.
## Audit candidate 0.1.4-rc.1
+17
View File
@@ -0,0 +1,17 @@
# Owned source notice check
Run these commands from the repository root with the existing Python 3 interpreter:
```text
python3 .github/scripts/test-source-headers.py
python3 .github/scripts/check-source-headers.py
```
The standard-library checker enforces the scope of the reviewed 2026-11-01 header pass, not an estate-wide licensing or formatting audit. `.github/source-headers.json` names the owned source/config path families, comment syntax, accepted holder/license pairs and explicit exclusion reasons. Python `fnmatch` patterns match slash-containing relative Git paths; new files in those families are covered after they are tracked. Empty selections fail. The checker does not scan untracked build output or modify files.
Only leading language comments count. Copyright text in a string, Python docstring or comment after code is rejected. UTF-8 BOMs, initial shebangs, PowerShell `#Requires` directives and XML declarations are handled before the notice. These declarations cannot be displaced beneath a new notice. PowerShell block comments are recognized only for PowerShell files. Existing years and holders are not rewritten. Complete canonical Apache notices are valid without redundant SPDX; Java still requires the full Apache block. A contradictory SPDX identifier fails.
JSON/schema/fixture/lock data, explicit generated output directories, vendor legal assets and upstream Maven wrappers are excluded with reasons in the policy. Solution/activation/test-runner metadata and `.editorconfig` remain outside this completed pass; their applicable root LICENSE/NOTICE remains authoritative. No broad claim about third-party licensing follows from this check. New owned file types or provenance must be reviewed before changing the allowlist; do not add blanket exclusions to make a failure disappear.
The implementation and its negative tests are intentionally maintained copies across the three printing repositories, with each repository's own copyright/license header and independently reviewed scope. They do not fetch policy or execute source. CI invokes them in existing jobs using the runner's available Python, with no new package or workflow context. Existing native C# IDE0073 and Java Checkstyle conventions remain unchanged.
This repository accepts Kingsrook, LLC / Apache-2.0 for its reviewed source/build/config families. The existing CI verify job runs the guard.
+30
View File
@@ -1,5 +1,35 @@
# TODO
## Realtime 0.3.0-rc.1 release candidate
- [x] Freeze printing extension 2.0, authenticated realtime frames and independent
streamed HTTPS documents with no polling fallback.
- [x] Define all 23 required settings, including configurable native submission
timeout, client ceilings and explicit policy refusal.
- [x] Pass 71 schema definitions, 73 fixtures, local bundle creation, Markdown
lint and whitespace checks without changing runtime or publishing assets.
- [x] Review and commit the protocol, then pin that immutable commit in both
consumer CI workflows.
- [ ] Complete consumer transport/replay, slow-transfer control, concurrency and
authorization acceptance before release; schema checks do not establish them.
- [ ] Complete installed UI and physical output acceptance separately.
## Historical 0.2 and earlier implementation checkpoints
The entries below retain their original checkpoint status. They do not describe
the current realtime release's completion or publication.
- [ ] 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 @@
{
"name": "printing",
"version": "2.0.0",
"pathPrefix": "/qbit/printing"
}
@@ -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"
}
+7
View File
@@ -0,0 +1,7 @@
{
"type": "ACK",
"messageId": "ack-event-spooled-1",
"payload": {
"acknowledgedMessageId": "event-spooled-1"
}
}
@@ -0,0 +1,12 @@
{
"type": "COMMAND_RECEIPT",
"messageId": "receipt-cancel-1",
"payload": {
"commandId": "command-cancel-1",
"receipt": {
"receiptId": "receipt-cancel-1",
"outcome": "CANCEL_REQUESTED",
"completedAt": "2026-10-31T22:00:01Z"
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"type": "COMMAND",
"messageId": "command-delivery-1",
"payload": {
"commandId": "command-cancel-1",
"requestId": "action-cancel-spool-1",
"action": "CANCEL_SPOOL",
"createdAt": "2026-10-31T22:00:00Z",
"expiresAt": "2026-10-31T22:15:00Z",
"jobId": "job-example-1"
}
}
@@ -0,0 +1,9 @@
{
"type": "DESIRED_STATE",
"messageId": "state-revision-1",
"payload": {
"revision": 1,
"dispatchPaused": true,
"connectionState": "CONNECTED"
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"type": "ERROR",
"messageId": "event-spooled-1",
"payload": {
"code": "PERMISSION_DENIED",
"message": "Current printing permission is required.",
"retryable": false
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"type": "HEARTBEAT",
"messageId": "heartbeat-connection-1",
"payload": {
"agentVersion": "0.3.0-rc.1",
"profile": {
"name": "Example workstation",
"description": "Synthetic contract fixture",
"location": "Example facility"
}
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"type": "HELLO",
"messageId": "hello-connection-1",
"payload": {
"agentVersion": "0.3.0-rc.1",
"capabilities": [
"realtime-v1",
"REFRESH_INVENTORY",
"CANCEL_SPOOL"
],
"lastAppliedRevision": 0,
"lastSettingsRevision": 0,
"limits": {
"maxPdfBytes": 536870912,
"maxZplBytes": 104857600,
"maxFrameBytes": 16777216,
"maxQueuedFrames": 4096,
"maxQueuedFrameBytes": 67108864,
"maxConcurrentDownloads": 16,
"maxConcurrentPrints": 16,
"maxStagedBytes": 4294967296,
"maxCopies": 32767
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"type": "INVENTORY",
"messageId": "inventory-connection-1",
"payload": {
"printers": [
{
"printerId": "laser-example",
"name": "Local Laser",
"driverName": "Example Laser Driver",
"portName": "USB001",
"status": "READY",
"formats": [
"PDF"
]
}
]
}
}
@@ -0,0 +1,5 @@
{
"type": "JOB_AVAILABLE",
"messageId": "jobs-wake-1",
"payload": {}
}
@@ -0,0 +1,13 @@
{
"type": "JOB_EVENT",
"messageId": "event-spooled-1",
"payload": {
"jobId": "job-example-1",
"event": {
"eventId": "event-spooled-1",
"status": "SPOOLED",
"message": "Windows accepted the job; physical output is not confirmed.",
"spoolerJobId": "17"
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"type": "JOB_EVENT",
"messageId": "event-spooled-1",
"payload": {
"jobId": "job-example-1",
"event": {
"eventId": "event-spooled-1",
"status": "SPOOLED",
"message": "Windows accepted the job; physical output is not confirmed.",
"spoolerJobId": "17"
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"jobId": "large-pdf-example",
"deviceId": "device-example",
"printerId": "laser-example",
"title": "Synthetic 100 MiB metadata example; no document included",
"format": "PDF",
"contentSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"contentLength": 104857600,
"options": {
"copies": 1,
"scale": "ACTUAL_SIZE"
},
"createdAt": "2026-10-31T22:00:00Z",
"status": "CLAIMED"
}
+5
View File
@@ -0,0 +1,5 @@
{
"type": "PING",
"messageId": "ping-connection-1",
"payload": {}
}
+7
View File
@@ -0,0 +1,7 @@
{
"type": "PONG",
"messageId": "pong-connection-1",
"payload": {
"serverTime": "2026-10-31T22:00:00Z"
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"type": "SETTINGS",
"messageId": "settings-revision-2",
"payload": {
"revision": 2,
"maxPdfBytes": 104857600,
"maxZplBytes": 20971520,
"maxFrameBytes": 1048576,
"maxQueuedFrames": 64,
"maxQueuedFrameBytes": 4194304,
"maxConcurrentDownloads": 2,
"maxConcurrentPrints": 1,
"maxStagedBytes": 536870912,
"maxDownloadBytesPerSecond": 0,
"requestTimeoutMillis": 30000,
"downloadConnectTimeoutMillis": 30000,
"downloadIdleTimeoutMillis": 30000,
"downloadTimeoutMillis": 600000,
"webSocketHandshakeTimeoutMillis": 30000,
"heartbeatIntervalSeconds": 20,
"heartbeatTimeoutSeconds": 60,
"reconnectMinDelayMillis": 1000,
"reconnectMaxDelayMillis": 30000,
"reconnectJitterPercent": 20,
"ackTimeoutMillis": 15000,
"maxCopies": 100,
"nativeSubmissionTimeoutMillis": 120000
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"revision": 1,
"maxPdfBytes": 104857600,
"maxZplBytes": 20971520,
"maxFrameBytes": 1048576,
"maxQueuedFrames": 64,
"maxQueuedFrameBytes": 4194304,
"maxConcurrentDownloads": 1,
"maxConcurrentPrints": 1,
"maxStagedBytes": 536870912,
"maxDownloadBytesPerSecond": 0,
"requestTimeoutMillis": 30000,
"downloadConnectTimeoutMillis": 30000,
"downloadIdleTimeoutMillis": 30000,
"downloadTimeoutMillis": 600000,
"webSocketHandshakeTimeoutMillis": 30000,
"heartbeatIntervalSeconds": 20,
"heartbeatTimeoutSeconds": 60,
"reconnectMinDelayMillis": 1000,
"reconnectMaxDelayMillis": 30000,
"reconnectJitterPercent": 20,
"ackTimeoutMillis": 15000,
"maxCopies": 100,
"nativeSubmissionTimeoutMillis": 120000
}
@@ -0,0 +1,8 @@
{
"type": "STATE_APPLIED",
"messageId": "applied-connection-1",
"payload": {
"lastAppliedRevision": 0,
"lastSettingsRevision": 1
}
}
+38
View File
@@ -0,0 +1,38 @@
{
"type": "WELCOME",
"messageId": "welcome-connection-1",
"payload": {
"connectionId": "connection-example-1",
"serverTime": "2026-10-31T22:00:00Z",
"desiredState": {
"revision": 0,
"dispatchPaused": false,
"connectionState": "CONNECTED"
},
"settings": {
"revision": 1,
"maxPdfBytes": 104857600,
"maxZplBytes": 20971520,
"maxFrameBytes": 1048576,
"maxQueuedFrames": 64,
"maxQueuedFrameBytes": 4194304,
"maxConcurrentDownloads": 1,
"maxConcurrentPrints": 1,
"maxStagedBytes": 536870912,
"maxDownloadBytesPerSecond": 0,
"requestTimeoutMillis": 30000,
"downloadConnectTimeoutMillis": 30000,
"downloadIdleTimeoutMillis": 30000,
"downloadTimeoutMillis": 600000,
"webSocketHandshakeTimeoutMillis": 30000,
"heartbeatIntervalSeconds": 20,
"heartbeatTimeoutSeconds": 60,
"reconnectMinDelayMillis": 1000,
"reconnectMaxDelayMillis": 30000,
"reconnectJitterPercent": 20,
"ackTimeoutMillis": 15000,
"maxCopies": 100,
"nativeSubmissionTimeoutMillis": 120000
}
}
}
+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.3.0-rc.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qrun-io/qqq-print-protocol",
"version": "0.1.4-rc.1",
"version": "0.3.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.3.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"
File diff suppressed because it is too large. Load diff
+4 -1
View File
@@ -1,3 +1,6 @@
// Copyright 2026 Kingsrook, LLC
// SPDX-License-Identifier: Apache-2.0
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
@@ -13,7 +16,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', 'docs/REALTIME.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.');
+3
View File
@@ -1,3 +1,6 @@
// Copyright 2026 Kingsrook, LLC
// SPDX-License-Identifier: Apache-2.0
import { createHash } from 'node:crypto';
import { writeFileSync } from 'node:fs';
+54 -1
View File
@@ -1,5 +1,38 @@
// Copyright 2026 Kingsrook, LLC
// SPDX-License-Identifier: Apache-2.0
// Shared correspondence between wire examples and schema definitions.
export const fixtureTypes = {
'printing-realtime-extension.discovery.json': 'PrintingRealtimeExtensionDiscovery',
'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 +55,25 @@ 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',
'realtime-settings.payload.json': 'RealtimeSettings',
'realtime-hello.message.json': 'RealtimeHello',
'realtime-welcome.message.json': 'RealtimeWelcome',
'realtime-state-applied.message.json': 'RealtimeStateApplied',
'realtime-desired-state.message.json': 'RealtimeDesiredState',
'realtime-job-available.message.json': 'RealtimeJobAvailable',
'realtime-command.message.json': 'RealtimeCommand',
'realtime-command-receipt.message.json': 'RealtimeCommandReceipt',
'realtime-job-event.message.json': 'RealtimeJobEvent',
'realtime-job-event-replay.message.json': 'RealtimeJobEvent',
'realtime-settings.message.json': 'RealtimeSettingsMessage',
'realtime-ack.message.json': 'RealtimeAck',
'realtime-error.message.json': 'RealtimeError',
'realtime-heartbeat.message.json': 'RealtimeHeartbeat',
'realtime-inventory.message.json': 'RealtimeInventory',
'realtime-ping.message.json': 'RealtimePing',
'realtime-pong.message.json': 'RealtimePong',
'realtime-large-pdf.response.json': 'PrintJob'
};
+28
View File
@@ -1,3 +1,6 @@
// Copyright 2026 Kingsrook, LLC
// SPDX-License-Identifier: Apache-2.0
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
@@ -9,6 +12,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 +36,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}`);
@@ -43,6 +55,14 @@ function validate(value, rule, path)
if(rule.minItems !== undefined) assert(value.length >= rule.minItems, path);
if(rule.maxItems !== undefined) assert(value.length <= rule.maxItems, path);
if(rule.uniqueItems) assert.equal(new Set(value.map(item => JSON.stringify(item))).size, value.length, path);
if(rule.contains)
{
assert(value.some(item =>
{
try { validate(item, rule.contains, path); return true; }
catch { return false; }
}), `${path}: required array member missing`);
}
value.forEach((item, index) => validate(item, rule.items, `${path}[${index}]`));
}
if(rule.type === 'string')
@@ -91,4 +111,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.`);
+113
View File
@@ -0,0 +1,113 @@
// Copyright 2026 Kingsrook, LLC
// SPDX-License-Identifier: Apache-2.0
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import Ajv2020 from 'ajv/dist/2020.js';
import addFormats from 'ajv-formats';
const schema = JSON.parse(readFileSync(new URL('../schemas/protocol.schema.json', import.meta.url), 'utf8'));
const ajv = new Ajv2020({ strict: true, allErrors: true });
addFormats(ajv);
ajv.addSchema(schema);
const fixture = name => JSON.parse(readFileSync(new URL(`../fixtures/${name}`, import.meta.url), 'utf8'));
const valid = (type, value) => ajv.validate(`${schema.$id}#/$defs/${type}`, value);
const settings = fixture('realtime-settings.payload.json');
const hello = fixture('realtime-hello.message.json');
const extension = fixture('printing-realtime-extension.discovery.json');
assert(valid('PrintingRealtimeExtensionDiscovery', extension), ajv.errorsText());
assert.equal(extension.version, '2.0.0');
assert(valid('PrintingRealtimeExtensionDiscovery', { ...extension, version: '2.7.3' }));
for(const version of ['1.0.0', '1.1.0', '3.0.0', '2.0', '2.01.0', '2.0.0-rc.1', null])
{
assert(!valid('PrintingRealtimeExtensionDiscovery', { ...extension, version }), 'Only the compatible stable native major is accepted.');
}
assert(!valid('PrintingRealtimeExtensionDiscovery', { ...extension, name: 'another-extension' }));
assert(!valid('PrintingRealtimeExtensionDiscovery', { ...extension, pathPrefix: '/printing/v1' }));
/*******************************************************************************
** Cross-field policy relationships require semantic checks beyond JSON Schema.
*******************************************************************************/
function assertPolicy(policy, limits)
{
assert(valid('RealtimeSettings', policy), ajv.errorsText());
assert(valid('RealtimeClientLimits', limits), ajv.errorsText());
assert(policy.maxQueuedFrameBytes >= policy.maxFrameBytes, 'Frame queue must hold one permitted message.');
assert(policy.maxStagedBytes >= Math.max(policy.maxPdfBytes, policy.maxZplBytes), 'Staging must admit one permitted document.');
assert(policy.downloadTimeoutMillis >= policy.downloadConnectTimeoutMillis);
assert(policy.downloadTimeoutMillis >= policy.downloadIdleTimeoutMillis);
assert(policy.heartbeatTimeoutSeconds >= 2 * policy.heartbeatIntervalSeconds);
assert(policy.reconnectMaxDelayMillis >= policy.reconnectMinDelayMillis);
for(const name of schema.$defs.RealtimeClientLimits.required)
{
assert(policy[name] <= limits[name], `${name}: policy exceeds declared client capability`);
}
}
assertPolicy(settings, hello.payload.limits);
assert.equal(settings.maxConcurrentDownloads, 1, 'Initial deployment defaults to one configurable download lane.');
assert.equal(settings.maxCopies, 100);
assert.equal(settings.nativeSubmissionTimeoutMillis, 120000);
for(const [name, rule] of Object.entries(schema.$defs.RealtimeSettings.properties))
{
if(rule.default !== undefined) assert.equal(settings[name], rule.default, `${name}: fixture/schema default mismatch`);
const missing = { ...settings };
delete missing[name];
assert(!valid('RealtimeSettings', missing), `${name}: missing value cannot inherit a default`);
for(const value of [rule.minimum - 1, rule.maximum + 1, 1.5, null])
{
assert(!valid('RealtimeSettings', { ...settings, [name]: value }), `${name}: invalid bound/type accepted`);
}
}
for(const change of [
{ maxQueuedFrameBytes: 8192 },
{ maxStagedBytes: 1 },
{ downloadTimeoutMillis: 1000 },
{ heartbeatTimeoutSeconds: 10 },
{ reconnectMinDelayMillis: 60000, reconnectMaxDelayMillis: 1000 }
])
{
assert.throws(() => assertPolicy({ ...settings, ...change }, hello.payload.limits));
}
for(const name of schema.$defs.RealtimeClientLimits.required)
{
assert.throws(() => assertPolicy(settings, { ...hello.payload.limits, [name]: settings[name] - 1 }), name);
}
assertPolicy({ ...settings, maxConcurrentDownloads: 16, maxConcurrentPrints: 16, maxCopies: 32767 }, hello.payload.limits);
assert(!valid('RealtimeHello', { ...hello, payload: { ...hello.payload, capabilities: [] } }));
assert(!valid('RealtimeHello', { ...hello, payload: { ...hello.payload, capabilities: ['CANCEL_SPOOL'] } }));
assert(!valid('RealtimeHello', { ...hello, payload: { ...hello.payload, limits: {} } }));
assert(!valid('RealtimeHello', { ...hello, messageId: 'hello:unsafe-prefix' }));
assert(!valid('RealtimeHello', { ...hello, token: 'not-a-real-token' }));
assert(!valid('RealtimeClientMessage', { type: 'RUN_SHELL', messageId: 'unsupported-1', payload: {} }));
const clientFixtures = ['hello', 'state-applied', 'job-event', 'command-receipt', 'heartbeat', 'inventory', 'ping'];
const serverFixtures = ['welcome', 'desired-state', 'job-available', 'command', 'settings', 'ack', 'error', 'pong'];
for(const [files, accepted, rejected] of [
[clientFixtures, 'RealtimeClientMessage', 'RealtimeServerMessage'],
[serverFixtures, 'RealtimeServerMessage', 'RealtimeClientMessage']
])
{
for(const name of files)
{
const message = fixture(`realtime-${name}.message.json`);
assert(valid(accepted, message), `${name}: ${ajv.errorsText()}`);
assert(!valid(rejected, message), `${name}: wrong-direction frame accepted`);
assert(Buffer.byteLength(JSON.stringify(message), 'utf8') <= settings.maxFrameBytes);
const missing = { ...message };
delete missing.payload;
assert(!valid(accepted, missing), `${name}: missing payload accepted`);
assert(!valid(accepted, { ...message, contentUrl: 'https://unrelated.invalid/document' }));
}
}
const event = fixture('realtime-job-event.message.json');
assert.deepEqual(fixture('realtime-job-event-replay.message.json'), event, 'Lost ACK replay preserves the exact envelope.');
assert.equal(fixture('realtime-ack.message.json').payload.acknowledgedMessageId, event.messageId);
assert.equal(fixture('realtime-error.message.json').messageId, event.messageId, 'Frame errors retain request correlation.');
const largeJob = fixture('realtime-large-pdf.response.json');
assert(valid('PrintJob', largeJob), ajv.errorsText());
assert.equal(largeJob.contentLength, settings.maxPdfBytes);
assert(largeJob.contentLength > 20 * 1024 * 1024);
assert(valid('PrintOptions', { copies: 32767 }));
assert(!valid('PrintOptions', { copies: 32768 }));
console.log('Realtime directional frames, defaults/ranges, policy/ceiling refusal and exact replay fixtures passed; runtime concurrency acceptance remains consumer-owned.');
+50
View File
@@ -1,8 +1,12 @@
// Copyright 2026 Kingsrook, LLC
// SPDX-License-Identifier: Apache-2.0
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import Ajv2020 from 'ajv/dist/2020.js';
import addFormats from 'ajv-formats';
import { fixtureTypes } from './fixture-types.mjs';
import './verify-realtime.mjs';
const schema = JSON.parse(readFileSync(new URL('../schemas/protocol.schema.json', import.meta.url), 'utf8'));
const ajv = new Ajv2020({ strict: true, allErrors: true });
@@ -36,4 +40,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.`);