Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5af4b3028
|
||
|
|
40cb6fec6d | ||
|
|
eea7f23556 | ||
|
|
f3cbc02e9b | ||
|
|
b33c7c4cf3
|
||
|
|
df3729ac63
|
||
|
|
67be986cb7
|
||
|
|
a35c083db7 | ||
|
|
45ca5483b2
|
||
|
|
b4e5b5304a |
No files matched your search
@@ -1,3 +1,6 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
name: codeql
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# Copyright 2026 Kingsrook, LLC
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
name: security
|
||||
|
||||
on:
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Published source distribution: [**0.1.4-rc.1**](https://github.com/QRun-IO/qqq-print-protocol/releases/tag/v0.1.4-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.
|
||||
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
|
||||
|
||||
Primary new-client base: `/qqq/v1/qbit/printing`, discovered as QQQ semantic extension `printing` version `1.0.0`, using standard QQQ user authentication and secondary device proof. See [QQQ authentication](docs/QQQ-AUTHENTICATION.md). The routes below describe explicit legacy compatibility at `/printing/v1`; native authentication failure never silently selects that transport. JSON uses UTF-8, camelCase properties, uppercase enum strings, and RFC 3339 UTC timestamps. IDs are opaque case-sensitive strings using URL-safe characters. Consumers ignore unknown response fields for compatible additions. Breaking changes require a new major contract. Configure the complete server origin; production requires HTTPS. Explicit localhost-only development may use HTTP.
|
||||
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.
|
||||
|
||||
@@ -53,8 +55,8 @@ The additive [device-control contract](docs/DEVICE-CONTROLS.md) defines capabili
|
||||
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 prepared for the
|
||||
`0.2.0-rc.1` distribution; the published version above remains unchanged.
|
||||
polling, and explicit receipts. These additions are included in the
|
||||
`0.2.0-rc.1` distribution, with 42 definitions and 54 validated fixtures.
|
||||
|
||||
## DTOs
|
||||
|
||||
@@ -76,7 +78,7 @@ polling, and explicit receipts. These additions are prepared for the
|
||||
|
||||
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.
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
# 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. Package version `0.2.0-rc.1` is the proposed coordinated release;
|
||||
this document does not claim publication, deployment, or production readiness.
|
||||
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
|
||||
|
||||
|
||||
+10
-3
@@ -14,15 +14,22 @@ 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`. Published distribution version `0.1.4-rc.1` identifies the specification/fixture bundle; it does not change that wire version. The Windows and QBit release numbers are independent of both. Current clients validate the wire major at enrollment; runtime contract minor/patch discovery, feature negotiation and a served OpenAPI definition are not implemented.
|
||||
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.
|
||||
|
||||
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 proposed `0.2.0-rc.1` extension adds explicit `device-control-v1` discovery and
|
||||
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 eventual reviewed protocol
|
||||
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.
|
||||
|
||||
@@ -2,14 +2,24 @@
|
||||
|
||||
The new Windows client uses the QQQ semantic UI/UX API. Package versions, wire
|
||||
payload version `1` and the printing extension semantic version are separate
|
||||
contracts. This document describes implementation in progress, not an accepted
|
||||
Authentik deployment or a published feature release.
|
||||
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 <2.0.0`.
|
||||
`>=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.
|
||||
|
||||
@@ -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.
|
||||
+29
-2
@@ -1,6 +1,33 @@
|
||||
# Session state — 2026-10-31
|
||||
|
||||
## Standard-session primary transport
|
||||
## 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
|
||||
@@ -15,7 +42,7 @@ 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.
|
||||
|
||||
## Device-control contract under review
|
||||
## 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
|
||||
|
||||
@@ -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.
|
||||
@@ -1,5 +1,24 @@
|
||||
# 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.
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "printing",
|
||||
"version": "2.0.0",
|
||||
"pathPrefix": "/qbit/printing"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"type": "ERROR",
|
||||
"messageId": "event-spooled-1",
|
||||
"payload": {
|
||||
"code": "PERMISSION_DENIED",
|
||||
"message": "Current printing permission is required.",
|
||||
"retryable": false
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"type": "PING",
|
||||
"messageId": "ping-connection-1",
|
||||
"payload": {}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"type": "PONG",
|
||||
"messageId": "pong-connection-1",
|
||||
"payload": {
|
||||
"serverTime": "2026-10-31T22:00:00Z"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@qrun-io/qqq-print-protocol",
|
||||
"version": "0.2.0-rc.1",
|
||||
"version": "0.3.0-rc.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@qrun-io/qqq-print-protocol",
|
||||
"version": "0.2.0-rc.1",
|
||||
"version": "0.3.0-rc.1",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"ajv": "8.20.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qrun-io/qqq-print-protocol",
|
||||
"version": "0.2.0-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",
|
||||
|
||||
@@ -4,6 +4,17 @@
|
||||
"title": "QQQ Print Protocol v1",
|
||||
"description": "Select the DTO with #/$defs/Name.",
|
||||
"$defs": {
|
||||
"PrintingRealtimeExtensionDiscovery": {
|
||||
"description": "The existing core extensions entry required by realtime clients; this does not redefine the core discovery document or its API version.",
|
||||
"type": "object",
|
||||
"required": ["name", "version", "pathPrefix"],
|
||||
"properties": {
|
||||
"name": { "type": "string", "const": "printing" },
|
||||
"version": { "type": "string", "pattern": "^2\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$", "maxLength": 100 },
|
||||
"pathPrefix": { "type": "string", "const": "/qbit/printing" }
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"PrintingSessionIdentity": {
|
||||
"type": "object",
|
||||
"required": ["principalId", "tenantId"],
|
||||
@@ -444,7 +455,7 @@
|
||||
"copies": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"maximum": 32767,
|
||||
"default": 1
|
||||
},
|
||||
"scale": {
|
||||
@@ -544,7 +555,8 @@
|
||||
"contentLength": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 20971520
|
||||
"maximum": 2147483647,
|
||||
"description": "Syntactic ceiling; the discovered transport's effective format policy and declared client capability impose the actual limit."
|
||||
},
|
||||
"options": {
|
||||
"$ref": "#/$defs/PrintOptions"
|
||||
@@ -1248,6 +1260,769 @@
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"RealtimeSettingsMessage": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type",
|
||||
"messageId",
|
||||
"payload"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "SETTINGS"
|
||||
},
|
||||
"messageId": {
|
||||
"$ref": "#/$defs/Id"
|
||||
},
|
||||
"payload": {
|
||||
"$ref": "#/$defs/RealtimeSettings"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RealtimeSettings": {
|
||||
"description": "Authoritative operational settings. Cross-field and declared-client-ceiling validation in REALTIME.md is also required.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"revision",
|
||||
"maxCopies",
|
||||
"maxPdfBytes",
|
||||
"maxZplBytes",
|
||||
"maxFrameBytes",
|
||||
"maxQueuedFrames",
|
||||
"maxQueuedFrameBytes",
|
||||
"maxConcurrentDownloads",
|
||||
"maxConcurrentPrints",
|
||||
"maxStagedBytes",
|
||||
"maxDownloadBytesPerSecond",
|
||||
"requestTimeoutMillis",
|
||||
"downloadConnectTimeoutMillis",
|
||||
"downloadIdleTimeoutMillis",
|
||||
"downloadTimeoutMillis",
|
||||
"webSocketHandshakeTimeoutMillis",
|
||||
"heartbeatIntervalSeconds",
|
||||
"heartbeatTimeoutSeconds",
|
||||
"reconnectMinDelayMillis",
|
||||
"reconnectMaxDelayMillis",
|
||||
"reconnectJitterPercent",
|
||||
"ackTimeoutMillis",
|
||||
"nativeSubmissionTimeoutMillis"
|
||||
],
|
||||
"properties": {
|
||||
"revision": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"maxCopies": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 32767,
|
||||
"default": 100
|
||||
},
|
||||
"maxPdfBytes": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 2147483647,
|
||||
"default": 104857600
|
||||
},
|
||||
"maxZplBytes": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 2147483647,
|
||||
"default": 20971520
|
||||
},
|
||||
"maxFrameBytes": {
|
||||
"type": "integer",
|
||||
"minimum": 8192,
|
||||
"maximum": 16777216,
|
||||
"default": 1048576
|
||||
},
|
||||
"maxQueuedFrames": {
|
||||
"type": "integer",
|
||||
"minimum": 8,
|
||||
"maximum": 4096,
|
||||
"default": 64
|
||||
},
|
||||
"maxQueuedFrameBytes": {
|
||||
"type": "integer",
|
||||
"minimum": 8192,
|
||||
"maximum": 67108864,
|
||||
"default": 4194304
|
||||
},
|
||||
"maxConcurrentDownloads": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 16,
|
||||
"default": 1
|
||||
},
|
||||
"maxConcurrentPrints": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 16,
|
||||
"default": 1
|
||||
},
|
||||
"maxStagedBytes": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 9007199254740991,
|
||||
"default": 536870912
|
||||
},
|
||||
"maxDownloadBytesPerSecond": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 2147483647,
|
||||
"default": 0,
|
||||
"description": "Aggregate content-download bandwidth; zero explicitly means unlimited."
|
||||
},
|
||||
"requestTimeoutMillis": {
|
||||
"type": "integer",
|
||||
"minimum": 1000,
|
||||
"maximum": 300000,
|
||||
"default": 30000
|
||||
},
|
||||
"downloadConnectTimeoutMillis": {
|
||||
"type": "integer",
|
||||
"minimum": 1000,
|
||||
"maximum": 300000,
|
||||
"default": 30000
|
||||
},
|
||||
"downloadIdleTimeoutMillis": {
|
||||
"type": "integer",
|
||||
"minimum": 1000,
|
||||
"maximum": 3600000,
|
||||
"default": 30000
|
||||
},
|
||||
"downloadTimeoutMillis": {
|
||||
"type": "integer",
|
||||
"minimum": 1000,
|
||||
"maximum": 86400000,
|
||||
"default": 600000
|
||||
},
|
||||
"webSocketHandshakeTimeoutMillis": {
|
||||
"type": "integer",
|
||||
"minimum": 1000,
|
||||
"maximum": 300000,
|
||||
"default": 30000
|
||||
},
|
||||
"heartbeatIntervalSeconds": {
|
||||
"type": "integer",
|
||||
"minimum": 5,
|
||||
"maximum": 300,
|
||||
"default": 20
|
||||
},
|
||||
"heartbeatTimeoutSeconds": {
|
||||
"type": "integer",
|
||||
"minimum": 10,
|
||||
"maximum": 900,
|
||||
"default": 60
|
||||
},
|
||||
"reconnectMinDelayMillis": {
|
||||
"type": "integer",
|
||||
"minimum": 100,
|
||||
"maximum": 60000,
|
||||
"default": 1000
|
||||
},
|
||||
"reconnectMaxDelayMillis": {
|
||||
"type": "integer",
|
||||
"minimum": 1000,
|
||||
"maximum": 300000,
|
||||
"default": 30000
|
||||
},
|
||||
"reconnectJitterPercent": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 50,
|
||||
"default": 20
|
||||
},
|
||||
"ackTimeoutMillis": {
|
||||
"type": "integer",
|
||||
"minimum": 1000,
|
||||
"maximum": 300000,
|
||||
"default": 15000
|
||||
},
|
||||
"nativeSubmissionTimeoutMillis": {
|
||||
"type": "integer",
|
||||
"minimum": 1000,
|
||||
"maximum": 86400000,
|
||||
"default": 120000,
|
||||
"description": "Deadline for the isolated native printer submission; timeout cannot establish that no output was submitted."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RealtimeClientLimits": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"maxCopies",
|
||||
"maxPdfBytes",
|
||||
"maxZplBytes",
|
||||
"maxFrameBytes",
|
||||
"maxQueuedFrames",
|
||||
"maxQueuedFrameBytes",
|
||||
"maxConcurrentDownloads",
|
||||
"maxConcurrentPrints",
|
||||
"maxStagedBytes"
|
||||
],
|
||||
"properties": {
|
||||
"maxCopies": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 32767
|
||||
},
|
||||
"maxPdfBytes": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"maxZplBytes": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"maxFrameBytes": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"maxQueuedFrames": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"maxQueuedFrameBytes": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"maxConcurrentDownloads": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"maxConcurrentPrints": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"maxStagedBytes": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 9007199254740991
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RealtimeEmpty": {
|
||||
"type": "object",
|
||||
"required": [],
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RealtimeHelloPayload": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"agentVersion",
|
||||
"capabilities",
|
||||
"lastAppliedRevision",
|
||||
"lastSettingsRevision",
|
||||
"limits"
|
||||
],
|
||||
"properties": {
|
||||
"agentVersion": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200
|
||||
},
|
||||
"capabilities": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"enum": [
|
||||
"realtime-v1",
|
||||
"REFRESH_INVENTORY",
|
||||
"CANCEL_SPOOL"
|
||||
]
|
||||
},
|
||||
"minItems": 1,
|
||||
"maxItems": 3,
|
||||
"uniqueItems": true,
|
||||
"contains": {
|
||||
"const": "realtime-v1"
|
||||
}
|
||||
},
|
||||
"lastAppliedRevision": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"lastSettingsRevision": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"limits": {
|
||||
"$ref": "#/$defs/RealtimeClientLimits"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RealtimeWelcomePayload": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"connectionId",
|
||||
"serverTime",
|
||||
"desiredState",
|
||||
"settings"
|
||||
],
|
||||
"properties": {
|
||||
"connectionId": {
|
||||
"$ref": "#/$defs/Id"
|
||||
},
|
||||
"serverTime": {
|
||||
"$ref": "#/$defs/ControlTimestamp"
|
||||
},
|
||||
"desiredState": {
|
||||
"$ref": "#/$defs/DeviceDesiredState"
|
||||
},
|
||||
"settings": {
|
||||
"$ref": "#/$defs/RealtimeSettings"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RealtimeStateAppliedPayload": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"lastAppliedRevision",
|
||||
"lastSettingsRevision"
|
||||
],
|
||||
"properties": {
|
||||
"lastAppliedRevision": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
},
|
||||
"lastSettingsRevision": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RealtimeJobEventPayload": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"jobId",
|
||||
"event"
|
||||
],
|
||||
"properties": {
|
||||
"jobId": {
|
||||
"$ref": "#/$defs/Id"
|
||||
},
|
||||
"event": {
|
||||
"$ref": "#/$defs/JobEvent"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RealtimeCommandReceiptPayload": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"commandId",
|
||||
"receipt"
|
||||
],
|
||||
"properties": {
|
||||
"commandId": {
|
||||
"$ref": "#/$defs/Id"
|
||||
},
|
||||
"receipt": {
|
||||
"$ref": "#/$defs/DeviceCommandReceipt"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RealtimeAckPayload": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"acknowledgedMessageId"
|
||||
],
|
||||
"properties": {
|
||||
"acknowledgedMessageId": {
|
||||
"$ref": "#/$defs/Id"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RealtimeErrorPayload": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"code",
|
||||
"message",
|
||||
"retryable"
|
||||
],
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 2048
|
||||
},
|
||||
"retryable": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RealtimePongPayload": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"serverTime"
|
||||
],
|
||||
"properties": {
|
||||
"serverTime": {
|
||||
"$ref": "#/$defs/ControlTimestamp"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RealtimeHello": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type",
|
||||
"messageId",
|
||||
"payload"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "HELLO"
|
||||
},
|
||||
"messageId": {
|
||||
"$ref": "#/$defs/Id"
|
||||
},
|
||||
"payload": {
|
||||
"$ref": "#/$defs/RealtimeHelloPayload"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RealtimeStateApplied": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type",
|
||||
"messageId",
|
||||
"payload"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "STATE_APPLIED"
|
||||
},
|
||||
"messageId": {
|
||||
"$ref": "#/$defs/Id"
|
||||
},
|
||||
"payload": {
|
||||
"$ref": "#/$defs/RealtimeStateAppliedPayload"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RealtimeJobEvent": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type",
|
||||
"messageId",
|
||||
"payload"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "JOB_EVENT"
|
||||
},
|
||||
"messageId": {
|
||||
"$ref": "#/$defs/Id"
|
||||
},
|
||||
"payload": {
|
||||
"$ref": "#/$defs/RealtimeJobEventPayload"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RealtimeCommandReceipt": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type",
|
||||
"messageId",
|
||||
"payload"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "COMMAND_RECEIPT"
|
||||
},
|
||||
"messageId": {
|
||||
"$ref": "#/$defs/Id"
|
||||
},
|
||||
"payload": {
|
||||
"$ref": "#/$defs/RealtimeCommandReceiptPayload"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RealtimeHeartbeat": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type",
|
||||
"messageId",
|
||||
"payload"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "HEARTBEAT"
|
||||
},
|
||||
"messageId": {
|
||||
"$ref": "#/$defs/Id"
|
||||
},
|
||||
"payload": {
|
||||
"$ref": "#/$defs/HeartbeatRequest"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RealtimeInventory": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type",
|
||||
"messageId",
|
||||
"payload"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "INVENTORY"
|
||||
},
|
||||
"messageId": {
|
||||
"$ref": "#/$defs/Id"
|
||||
},
|
||||
"payload": {
|
||||
"$ref": "#/$defs/PrinterInventory"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RealtimePing": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type",
|
||||
"messageId",
|
||||
"payload"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "PING"
|
||||
},
|
||||
"messageId": {
|
||||
"$ref": "#/$defs/Id"
|
||||
},
|
||||
"payload": {
|
||||
"$ref": "#/$defs/RealtimeEmpty"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RealtimeWelcome": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type",
|
||||
"messageId",
|
||||
"payload"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "WELCOME"
|
||||
},
|
||||
"messageId": {
|
||||
"$ref": "#/$defs/Id"
|
||||
},
|
||||
"payload": {
|
||||
"$ref": "#/$defs/RealtimeWelcomePayload"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RealtimeDesiredState": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type",
|
||||
"messageId",
|
||||
"payload"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "DESIRED_STATE"
|
||||
},
|
||||
"messageId": {
|
||||
"$ref": "#/$defs/Id"
|
||||
},
|
||||
"payload": {
|
||||
"$ref": "#/$defs/DeviceDesiredState"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RealtimeJobAvailable": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type",
|
||||
"messageId",
|
||||
"payload"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "JOB_AVAILABLE"
|
||||
},
|
||||
"messageId": {
|
||||
"$ref": "#/$defs/Id"
|
||||
},
|
||||
"payload": {
|
||||
"$ref": "#/$defs/RealtimeEmpty"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RealtimeCommand": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type",
|
||||
"messageId",
|
||||
"payload"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "COMMAND"
|
||||
},
|
||||
"messageId": {
|
||||
"$ref": "#/$defs/Id"
|
||||
},
|
||||
"payload": {
|
||||
"$ref": "#/$defs/DeviceCommand"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RealtimeAck": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type",
|
||||
"messageId",
|
||||
"payload"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "ACK"
|
||||
},
|
||||
"messageId": {
|
||||
"$ref": "#/$defs/Id"
|
||||
},
|
||||
"payload": {
|
||||
"$ref": "#/$defs/RealtimeAckPayload"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RealtimeError": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type",
|
||||
"messageId",
|
||||
"payload"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "ERROR"
|
||||
},
|
||||
"messageId": {
|
||||
"$ref": "#/$defs/Id"
|
||||
},
|
||||
"payload": {
|
||||
"$ref": "#/$defs/RealtimeErrorPayload"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RealtimePong": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type",
|
||||
"messageId",
|
||||
"payload"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "PONG"
|
||||
},
|
||||
"messageId": {
|
||||
"$ref": "#/$defs/Id"
|
||||
},
|
||||
"payload": {
|
||||
"$ref": "#/$defs/RealtimePongPayload"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RealtimeClientMessage": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/RealtimeHello"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/RealtimeStateApplied"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/RealtimeJobEvent"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/RealtimeCommandReceipt"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/RealtimeHeartbeat"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/RealtimeInventory"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/RealtimePing"
|
||||
}
|
||||
]
|
||||
},
|
||||
"RealtimeServerMessage": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/RealtimeWelcome"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/RealtimeDesiredState"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/RealtimeJobAvailable"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/RealtimeCommand"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/RealtimeSettingsMessage"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/RealtimeAck"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/RealtimeError"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/RealtimePong"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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', 'docs/DEVICE-CONTROLS.md', 'docs/QQQ-AUTHENTICATION.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.');
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Kingsrook, LLC
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
|
||||
|
||||
+23
-1
@@ -1,5 +1,9 @@
|
||||
// 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',
|
||||
@@ -53,5 +57,23 @@ export const fixtureTypes = {
|
||||
'ack.response.json': 'Acknowledgment',
|
||||
'error.response.json': 'Error',
|
||||
'printing-session.response.json': 'PrintingSessionIdentity',
|
||||
'printing-session-bound.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'
|
||||
};
|
||||
@@ -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';
|
||||
@@ -52,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')
|
||||
|
||||
@@ -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.');
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user