Compare commits

..
Author SHA1 Message Date
james.maesandClaude Sonnet 5.5 090e3c1135 docs: correct scanner and Echo setup instructions
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 15s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 1m43s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 11s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Successful in 1m59s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
Describe EMDK and optional AIM evidence, public QQQ RC.21 with qualified
owner inputs, current Gitea review links, and implemented profile tooling.
Remove stale test counts while retaining the physical acceptance limits.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 16:20:49 -06:00
james.maesandClaude Sonnet 5.5 c0e27b36fd build: adopt published QQQ RC.21 across scanner modules
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Failing after 44s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 28s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Failing after 15s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Failing after 1m0s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 6s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
Align the optional Record Files tests with BOM-managed JUnit engine and
parameterized support, avoiding the older API/newer engine mismatch.

Verified 182 scanner/demo/files/Echo tests with public QQQ dependencies,
179 Generation tests and 16 bridge tests with qualified owner inputs,
and all 15 real admin browser scenarios. Owner QBit release inputs and
physical acceptance remain open as documented.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 15:57:37 -06:00
james.maesandClaude Sonnet 5.5 45f9cbddb1 feat(echo): add per-scan copies and photo capture
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 14s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 1m25s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Successful in 1m36s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 3s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
Ask for an optional copy quantity at setup, defaulting to one, and bind it to every scan and retry through document generation and printing. Preserve barcode symbology/AIM context, add scanner-fidelity document support, and compose authorized Echo scan photo capture.

Verified against the qualified dependency cohort: 377 unique server/owner tests across module runs. Native TC22 Echo acceptance passed ten stages and 116 semantic requests with simulated input and printing. Public dependency publication and physical output validation remain open.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 14:57:43 -06:00
e2ebc21ab2 feat(scanners): preserve raw scan metadata in semantic context
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 14s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 1m44s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Successful in 1m55s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 4s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
Add optional AIM and canonical raw-byte fields to mobile-scanners 1.5 with nullable H2 storage, exact event retry binding, input consumption and generated backward-compatible schemas. Preserve barcode text and server-derived attribution. Verify 135 scanner, native HTTP, Echo and composed demo tests with Checkstyle. Correct stale demo status without claiming physical output or full barcode qualification.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 10:56:58 -06:00
e3b775ec3f fix(echo): align generation and renderer output limits
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 19s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 1m20s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Successful in 1m32s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
Propagate each demo renderer cap into Generation so requests do not exceed the bridge limit. Bound the composed acceptance renderer and verify a nondefault cap. Old wiring reproduces three failures; all 74 library/demo tests pass with the fix. A real local synthetic Echo event reached the Windows spooler; physical trigger, paper output and rescan remain unconfirmed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 09:59:56 -06:00
james.maesandCodex 341b058725 docs(echo): record verified barcode rendering and test images
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 1m18s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 4m18s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 11s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Successful in 4m36s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 4s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
Record real renderer output and independent pixel decoding for the published 4x1 template. Physical print acceptance and release gates remain open.

Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 09:04:55 -06:00
james.maesandClaude Sonnet 5.5 b337d01eac test(echo): generate inert credentials for demo fixtures
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 21s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 1m48s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 10s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Successful in 2m11s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
Replace five synthetic token literals with generated test values and retain exact historical Gitleaks fingerprints without broad exclusions. Production validation is unchanged. Verified redacted history and staged scans plus all27 composed demo tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 08:08:51 -06:00
james.maesandClaude Sonnet 5.5 f0f4731cc0 docs: distinguish ready Echo setup from remaining release gates
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Failing after 4m18s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 5m47s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Failing after 10s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Failing after 5m58s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 4s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 3s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 07:58:07 -06:00
james.maesandClaude Sonnet 5.5 ca6e4dd20c fix(echo): run composed qbits in the local scanner server
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 1m20s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 3s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Failing after 8s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Failing after 1m30s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Failing after 15s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
Reuse the existing local development database without resetting accounts or device approval. Accept valid loopback ports forwarded over USB while retaining origin and cross-site checks. Verify 27 demo tests and live forwarded-host acceptance.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 06:56:17 -06:00
james.maes 16755f68cb docs(echo): record native document-choice acceptance
QRun-IO/ci-policy: ci / java-docker-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-4 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Failing after 20s
QRun-IO/ci-policy: ci / java-maven-2 (pull_request) Failing after 5m22s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 1m51s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Failing after 13s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / node-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 6s
QRun-IO/ci-policy: ci / source-config-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
2026-11-01 21:24:53 -06:00
james.maesandClaude Sonnet 5.5 8da274e52b feat(echo): select and retain a document for each scan run
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-4 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Failing after 16s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 1m34s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Failing after 8s
QRun-IO/ci-policy: ci / java-maven-2 (pull_request) Failing after 1m44s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 5s
QRun-IO/ci-policy: ci / gradle-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 21:15:07 -06:00
james.maesandClaude Sonnet 5.5 101efc846b fix(mobile): scope device binding to the requesting extension
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-3 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Failing after 17s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 1m31s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Failing after 10s
QRun-IO/ci-policy: ci / java-maven-2 (pull_request) Failing after 1m43s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 4s
QRun-IO/ci-policy: ci / gradle-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-5 (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 20:36:22 -06:00
james.maes a1e1cd47f1 docs(echo): record verified tests and remaining physical gate
QRun-IO/ci-policy: ci / node-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-4 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 1m22s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Failing after 13s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Failing after 9s
QRun-IO/ci-policy: ci / java-maven-2 (pull_request) Failing after 1m33s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 3s
QRun-IO/ci-policy: ci / gradle-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
2026-11-01 19:48:03 -06:00
james.maesandClaude Sonnet 5.5 912040dfbc fix(echo): preserve print receipts and safely retry unpinned scans
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Failing after 15s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Canceled after 8s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 1m25s
QRun-IO/ci-policy: ci / ci-result (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / java-maven-2 (pull_request) Canceled after 1m35s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / node-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 19:46:29 -06:00
james.maesandClaude Sonnet 5.5 4bc88a404a feat(echo): compose retained scan-to-print application
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-4 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Failing after 19s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 1m38s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Failing after 9s
QRun-IO/ci-policy: ci / java-maven-2 (pull_request) Failing after 3m47s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 3s
QRun-IO/ci-policy: ci / gradle-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
Use owner printer/media, generation, retained-file and cold renderer contracts. Add runnable local host and authenticated native HTTP acceptance for two scans, retained outputs, accepted jobs, idempotent retry and leaving the process. Preserve actor locks and direct-write denial while supporting core READ probes.

Verified Echo33 and full demo22 with Checkstyle; physical output and public dependency/CI release gates remain open.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 18:33:55 -06:00
james.maesandClaude Sonnet 5.5 707e1ab8ba fix(scanner): compare effective security key membership
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 10s
QRun-IO/ci-policy: ci / java-maven-2 (pull_request) Successful in 20m44s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-4 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 1m48s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 16s
QRun-IO/ci-policy: ci / gradle-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
Initialize Collaboration keys during authentication before verified device capture. Preserve rejection of actual authority changes. Verified scanner50, demo7 and native Record Files18 with Checkstyle.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 17:32:09 -06:00
james.maesandClaude Sonnet 5.5 bbc1fbf080 fix(scanner): retain verified session with unchanged authority
Preserve verified session identity across repeated capture while rechecking current approval, binding, session reference and authority before returning. Reject changed credential ceilings or grants, including mutations during real table hooks.

Validation: full scanner 49 and demo HTTP 7 tests pass with zero Checkstyle violations against the private process-identity core. Strict files-owner finalize acceptance remains pending.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 16:00:25 -06:00
james.maesandClaude de8ba6315c fix(ci): avoid analyzer crash in verification output
Simplify verification hashtable expressions and report each analyzed file while preserving fatal analysis errors. All six scripts pass PSScriptAnalyzer 1.25.0 with zero findings; parser and isolated path regression pass.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-01 15:39:01 -06:00
james.maesandGPT-6 Codex a5706dac6d fix(echo): preserve authorized retained source reads
Allow current operators to read their own Echo scan sources after capture ends while retaining approved-device checks for capture and process actions. Preserve retry identity across stored timestamp precision and add an isolated semantic HTTP fixture.

Validated exact source manifest: 18 tests, zero failures, strict Java compilation and Checkstyle. Native Echo setup and physical printing remain open.

Co-Authored-By: GPT-6 Codex <noreply@openai.com>
2026-11-01 13:22:15 -06:00
james.maesandGPT-6 Codex 040329e110 feat(scanner): publish semantic Echo presentation hints
Add optional extension1.4 acknowledgmentLabel and manualEntryEnabled metadata with existing defaults. Echo declares Print queued and hides manual input without changing hardware capture or receipt predicates.

Validation:69 server/demo/files cases plus2 canonical contract cases pass; released baselines unchanged. Echo15 strict tests pass with the qualified1.4 scanner companion, and Android167 JVM/lint/debug checks pass. Actual owner adapters and physical printing remain pending.

Co-Authored-By: GPT-6 Codex <noreply@openai.com>
2026-11-01 12:39:35 -06:00
james.maesandCodex e1e6a21c89 feat(examples): add Echo scan-to-print process
Add ordinary QQQ printer/media setup and sequential scan-to-accepted-print loop with protected run identity, canonical event idempotency, read-only uncertainty recovery and explicit cancel behavior.

Validation:15 real QQQ/H2 state/process tests, Checkstyle0, strict compilation and packaged license/schema byte checks pass. Production rendering/printer adapters remain explicitly pending the owning qbits qualified pinned-revision/media/receipt handoffs; no physical output is claimed.

Co-Authored-By: Codex <noreply@openai.com>
2026-11-01 12:25:23 -06:00
89 changed files with 13949 additions and 45 deletions

No files matched your search

+2
View File
@@ -23,7 +23,9 @@ jobs:
run: |
Install-Module PSScriptAnalyzer -RequiredVersion 1.25.0 -Scope CurrentUser -Force
$scriptPaths = @(Get-ChildItem -LiteralPath scripts, integrations/record-files -File -Filter '*.ps1')
# An analyzer crash is not ignored: the failing file is named and the step still fails.
$findings = @($scriptPaths | ForEach-Object {
Write-Output "Analyzing $($_.FullName)" | Out-Host
Invoke-ScriptAnalyzer -Path $_.FullName -Settings scripts/PSScriptAnalyzerSettings.psd1
})
$findings | Format-Table -AutoSize
+13
View File
@@ -0,0 +1,13 @@
# Exact Gitleaks fingerprints (commit:file:rule:line) for historical commits only.
# Each is an inert, patterned placeholder used as a test-only administrator token
# fixture in the Echo demo tests. It is not a credential for any system. Current
# sources generate the token at runtime, so nothing here covers new findings.
# EchoDemoAdoptionTest
ca6e4dd20c8d111f641f35faad5cc64828fe600d:examples/echo/demo/src/test/java/com/kingsrook/examples/echo/demo/EchoDemoAdoptionTest.java:generic-api-key:59
# EchoDemoAcceptanceTest, EchoDemoSettingsTest, TrustedLocalRendererSettingsTest, EchoDemoStartupTest
4bc88a404a0a39b82fcf36f7c9d038984be0d060:examples/echo/demo/src/test/java/com/kingsrook/examples/echo/demo/EchoDemoAcceptanceTest.java:generic-api-key:65
4bc88a404a0a39b82fcf36f7c9d038984be0d060:examples/echo/demo/src/test/java/com/kingsrook/examples/echo/demo/EchoDemoSettingsTest.java:generic-api-key:26
4bc88a404a0a39b82fcf36f7c9d038984be0d060:examples/echo/demo/src/test/java/com/kingsrook/examples/echo/demo/TrustedLocalRendererSettingsTest.java:generic-api-key:31
4bc88a404a0a39b82fcf36f7c9d038984be0d060:examples/echo/demo/src/test/java/com/kingsrook/examples/echo/demo/EchoDemoStartupTest.java:generic-api-key:47
+4 -3
View File
@@ -1,7 +1,7 @@
# QQQ Mobile Scanners
This is an application QBit and a separate local demo, using Java 21 and QQQ 4.1.0-RC.20.
The Android host is maintained in [QRun-IO/qqq-android](https://github.com/QRun-IO/qqq-android).
This is an application QBit and a separate local demo, using Java 21 and QQQ 4.1.0-RC.21.
The Android host is maintained in [QRun-IO/qqq-android on Gitea](https://git.kof22.com/QRun-IO/qqq-android) (reviews: pulls/8 and pulls/10).
## Build and verify
@@ -25,6 +25,7 @@ The Android host is maintained in [QRun-IO/qqq-android](https://github.com/QRun-
## Repository workflow
- `develop` is the development default; make feature/fix branches and pull requests into it. `main` is reserved for reviewed releases.
- CircleCI uses the central qbit manifest profile on the managed `0.8` track. Keep the three version declarations identical. Extra demo verification belongs in its dedicated GitHub workflow, not the canonical setup caller.
- The primary forge for source, reviews and checks is Gitea (`git.kof22.com/QRun-IO`); GitHub is a shadow/archive, so make no ordinary GitHub writes.
- The CircleCI manifest (central qbit profile, managed `0.8` track; keep the three version declarations identical) and the GitHub demo workflow are retained legacy definitions: preserve the existing managed files, but primary CI changes follow current Gitea policy and should not be directed into GitHub.
- All publication switches start disabled. Creating a repo or pushing a branch is not permission to publish Maven artifacts or cut a release.
- Preserve unrelated work. Update `docs/SESSION-STATE.md` and `docs/TODO.md` when project state changes. Commit, push, release, deployment and external communication require authorization in the active task.
+5 -5
View File
@@ -1,6 +1,6 @@
# QQQ Mobile Scanners
Server support for approved Android handhelds running native QQQ apps. The first app, **Scan Capture**, stores barcode events in a QQQ table. The companion Android application is [QQQ Android's scanner host](https://github.com/QRun-IO/qqq-android/tree/feature/mobile-scanner-host/scanner-host), currently in [pull request #8](https://github.com/QRun-IO/qqq-android/pull/8).
Server support for approved Android handhelds running native QQQ apps. The first app, **Scan Capture**, stores barcode events in a QQQ table. The companion Android application is [QQQ Android's scanner host](https://git.kof22.com/QRun-IO/qqq-android) (`scanner-host` directory), reviewed in [pull request 8](https://git.kof22.com/QRun-IO/qqq-android/pulls/8) and [pull request 10](https://git.kof22.com/QRun-IO/qqq-android/pulls/10). Gitea is primary; the GitHub copy is a shadow/archive.
Version one is online only. Devices require an authenticated user and administrator approval. Android renders existing QQQ app, process, frontend-step and widget metadata; this qbit adds the scanner widget and enrollment lifecycle.
@@ -16,7 +16,7 @@ App discovery and process execution use the versioned semantic UI/UX API at `/qq
## Build
Use Java 21 and Maven 3.9+. The implementation targets QQQ `4.1.0-RC.20`; QQQ `4.0.0` does not provide the password session exchange needed by this demo.
Use Java 21 and Maven 3.9+. The implementation targets QQQ `4.1.0-RC.21`; QQQ `4.0.0` does not provide the password session exchange needed by this demo.
```powershell
.\mvnw.cmd verify install
@@ -37,7 +37,7 @@ The app footer checks server reachability while foregrounded. Tap it to inspect
## Contributing and CI
Development pull requests target `develop`. CircleCI uses the shared QRun-IO QBit pipeline; GitHub Actions runs the demo HTTP contract, browser E2E and CodeQL. All Maven publication switches start disabled. See [contributing](CONTRIBUTING.md), [CI and release operation](docs/CI-CD.md), [security reporting](SECURITY.md) and the [code of conduct](CODE_OF_CONDUCT.md). This project is licensed under [Apache 2.0](LICENSE).
The primary forge is Gitea (`git.kof22.com/QRun-IO`); GitHub is a shadow/archive and receives no ordinary writes. Development pull requests target `develop`. The CircleCI and GitHub workflow files in this repository describe the legacy shared QBit pipeline and demo HTTP/browser/CodeQL checks; see [CI and release operation](docs/CI-CD.md) for how to run the same checks locally. All Maven publication switches start disabled. Current progress and dependency status live in [session state](docs/SESSION-STATE.md), not here. See [contributing](CONTRIBUTING.md), [CI and release operation](docs/CI-CD.md), [security reporting](SECURITY.md) and the [code of conduct](CODE_OF_CONDUCT.md). This project is licensed under [Apache 2.0](LICENSE).
## Embed in a QQQ application
@@ -57,8 +57,8 @@ The app must show server acknowledgement before reporting a save. A network time
## Scope
The tested handheld path uses single-barcode Zebra DataWedge delivery and QQQ table-based login. The Android client also implements metadata-advertised native OAuth2 login for servers providing the explicit UI API 1.9 contract; configured-provider acceptance and production scanner-ingress hardening remain release gates. See the client's [authentication guide](https://github.com/QRun-IO/qqq-android/blob/feature/mobile-scanner-host/docs/AUTHENTICATION.md).
The tested handheld path uses single-barcode capture through the Zebra EMDK service on the Android host (no DataWedge profile) and QQQ table-based login. The host conditionally configures AIM output and reads back the configuration before extracting an AIM identifier. SDK raw bytes can accompany either plain or AIM capture (mobile-scanners 1.5). Actual hardware AIM output, print and rescan fidelity is not yet qualified, so no exact-AIM or all-symbology claim is made. The Android client also implements metadata-advertised native OAuth2 login for servers providing the explicit UI API 1.9 contract; configured-provider acceptance and production scanner-ingress hardening remain release gates. See the client repository's authentication guide (`docs/AUTHENTICATION.md` in [the Gitea repository](https://git.kof22.com/QRun-IO/qqq-android)).
Device/session records, online activity, administrator session termination, own-app screen viewing and optional foreground location are implemented. Extension1.2 adds optional [operator-consented device-screen viewing](docs/DEVICE-SCREEN-VIEWING.md); actual Android projection acceptance remains open. Camera/gallery record uploads use the optional Record Files bridge and require its compatible dependency tuple; production publication and actual camera acceptance remain open. Additional apps use QQQ's native process/widget metadata, and their server-side permission rules must explicitly protect their actions.
A visual mobile app designer, offline workflows, camera-based barcode decoding and applied MDM rollout remain outside this version. Company profile/wallpaper options are researched in the client's provisioning guide. The supplied SQL schema targets H2; other database migrations belong to the host application.
A visual mobile app designer, offline workflows, camera-based barcode decoding and applied MDM rollout remain outside this version. The Android host implements company managed configuration, a profile generator and a live wallpaper (see `docs/` in the client repository); applied MDM/device-owner rollout of them is not yet qualified. The supplied SQL schema targets H2; other database migrations belong to the host application.
+1 -1
View File
@@ -7,7 +7,7 @@
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<qqq.version>4.1.0-RC.20</qqq.version>
<qqq.version>4.1.0-RC.21</qqq.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -182,6 +182,129 @@ class MobileHttpAcceptanceTest
/*******************************************************************************
** Extension 1.5 optional metadata reaches the row and context byte-for-byte,
** while older clients and malformed values keep the existing semantics.
*******************************************************************************/
@Test
void exactCaptureMetadataRoundTripsOverHttp() throws Exception
{
String database = "jdbc:h2:mem:exactScans" + UUID.randomUUID().toString().replace("-", "") + ";DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=FALSE";
String scannerPassword = UUID.randomUUID().toString();
String adminPassword = UUID.randomUUID().toString();
MobileScannersDemo application = new MobileScannersDemo(database);
application.initializeDatabase(scannerPassword, adminPassword);
Integer port;
try(ServerSocket socket = new ServerSocket(0))
{
port = socket.getLocalPort();
}
origin = "http://127.0.0.1:" + port;
QApplicationJavalinServer server = application.start(port);
try
{
assertSemanticDiscovery(false);
String scanner = login("scanner", scannerPassword);
String admin = login("admin", adminPassword);
JSONObject device = new JSONObject(request("POST", "/qqq/v1/qbit/mobile-scanners/devices/enroll", scanner,
new JSONObject().put("installationId", UUID.randomUUID().toString()).toString(), false).body());
deviceId = device.getString("deviceId");
deviceToken = device.getString("deviceToken");
runAdminFromRecord("mobileDeviceApprove", admin);
String processId = launchCapture(scanner);
byte[] raw = new byte[] { 0, 'A', 0, (byte) 0xFF, (byte) 0x80, 0x1D, '0', '0' };
String encoded = Base64.getEncoder().encodeToString(raw);
JSONObject exact = new JSONObject().put("barcode", "0001234").put("symbology", "CODE128").put("scanSource", "datawedge")
.put("eventId", UUID.randomUUID().toString()).put("capturedAt", "2026-11-01T12:00:00Z")
.put("aimIdentifier", "]C0").put("rawDataBase64", encoded);
JSONObject first = complete(processStep(MobileScannersQBitProducer.CAPTURE, processId, "scan", scanner, exact, true), MobileScannersQBitProducer.CAPTURE, scanner, true);
JSONObject context = first.getJSONObject("values").getJSONObject("scanContext");
assertSchema("MobileScannersScanContextV1", context);
assertEquals("0001234", context.getString("barcode"));
assertEquals("]C0", context.getString("aimIdentifier"));
assertEquals(encoded, context.getString("rawDataBase64"));
assertEquals(List.of(0, 65, 0, 255, 128, 29, 48, 48), unsignedBytes(Base64.getDecoder().decode(context.getString("rawDataBase64"))));
JSONObject retry = complete(processStep(MobileScannersQBitProducer.CAPTURE, processId, "scan", scanner, exact, true), MobileScannersQBitProducer.CAPTURE, scanner, true);
assertEquals(context.getString("scanId"), retry.getJSONObject("values").getString("scanId"));
assertEquals(encoded, retry.getJSONObject("values").getJSONObject("scanContext").getString("rawDataBase64"));
assertEquals(1, scanCount(database));
JSONObject legacy = new JSONObject().put("barcode", "0001234").put("eventId", UUID.randomUUID().toString())
.put("capturedAt", "2026-11-01T12:00:00Z");
JSONObject unknown = complete(processStep(MobileScannersQBitProducer.CAPTURE, processId, "scan", scanner, legacy, true), MobileScannersQBitProducer.CAPTURE, scanner, true);
JSONObject legacyContext = unknown.getJSONObject("values").getJSONObject("scanContext");
assertSchema("MobileScannersScanContextV1", legacyContext);
assertFalse(legacyContext.has("aimIdentifier"), "A legacy event must not inherit the previous event's AIM identifier.");
assertFalse(legacyContext.has("rawDataBase64"), "A legacy event must not inherit the previous event's raw bytes.");
assertEquals(2, scanCount(database));
JSONObject changed = new JSONObject(exact.toString()).put("rawDataBase64", Base64.getEncoder().encodeToString(new byte[] { 0, 'A' }));
complete(processStep(MobileScannersQBitProducer.CAPTURE, launchCapture(scanner), "scan", scanner, changed, true), MobileScannersQBitProducer.CAPTURE, scanner, true, true);
JSONObject changedAim = new JSONObject(exact.toString()).put("aimIdentifier", "]Q1");
complete(processStep(MobileScannersQBitProducer.CAPTURE, launchCapture(scanner), "scan", scanner, changedAim, true), MobileScannersQBitProducer.CAPTURE, scanner, true, true);
JSONObject absent = new JSONObject(exact.toString());
absent.remove("aimIdentifier");
absent.remove("rawDataBase64");
complete(processStep(MobileScannersQBitProducer.CAPTURE, launchCapture(scanner), "scan", scanner, absent, true), MobileScannersQBitProducer.CAPTURE, scanner, true, true);
for(JSONObject invalid : List.of(new JSONObject(exact.toString()).put("aimIdentifier", ""),
new JSONObject(exact.toString()).put("aimIdentifier", "]c"),
new JSONObject(exact.toString()).put("aimIdentifier", "]C10"),
new JSONObject(exact.toString()).put("rawDataBase64", ""),
new JSONObject(exact.toString()).put("rawDataBase64", "AEEA/4AdMDA"),
new JSONObject(exact.toString()).put("rawDataBase64", encoded + "\n"),
new JSONObject(exact.toString()).put("rawDataBase64", Base64.getEncoder().encodeToString(new byte[8193])),
new JSONObject(exact.toString()).put("rawDataBase64", 12)))
{
invalid.put("eventId", UUID.randomUUID().toString());
complete(processStep(MobileScannersQBitProducer.CAPTURE, launchCapture(scanner), "scan", scanner, invalid, true), MobileScannersQBitProducer.CAPTURE, scanner, true, true);
}
assertEquals(2, scanCount(database), "Conflicting or malformed metadata must never persist another scan.");
try(var connection = DriverManager.getConnection(database, "sa", "");
var query = connection.prepareStatement("SELECT rawDataBase64, aimIdentifier FROM mobileScan WHERE eventId = ?"))
{
query.setString(1, exact.getString("eventId"));
try(var rows = query.executeQuery())
{
assertTrue(rows.next());
assertEquals(encoded, rows.getString(1));
assertEquals("]C0", rows.getString(2));
}
}
}
finally
{
server.stop();
}
}
/*******************************************************************************
** Start a fresh capture process so one rejected event cannot hide another.
*******************************************************************************/
private String launchCapture(String scanner) throws Exception
{
return complete(processInit(MobileScannersQBitProducer.CAPTURE, scanner, true), MobileScannersQBitProducer.CAPTURE, scanner, true).getString("processUUID");
}
/*******************************************************************************
** Compare raw bytes without sign-extension surprises.
*******************************************************************************/
private static List<Integer> unsignedBytes(byte[] bytes)
{
List<Integer> values = new java.util.ArrayList<>();
for(byte value : bytes)
{
values.add(Byte.toUnsignedInt(value));
}
return values;
}
/*******************************************************************************
** A pending/revoked handheld must not save, even with a valid user session.
*******************************************************************************/
@@ -650,7 +773,7 @@ class MobileHttpAcceptanceTest
}
}
assertNotNull(mobile);
assertEquals("1.3.0", mobile.getString("version"));
assertEquals("1.5.0", mobile.getString("version"));
assertEquals("/qbit/mobile-scanners", mobile.getString("pathPrefix"));
var openApiResponse = request("GET", "/qqq/v1/openapi.json", null, null, device);
assertEquals(200, openApiResponse.statusCode());
+3 -1
View File
@@ -1,6 +1,8 @@
# CI and release operation
This repository follows the current QRun-IO QBit pipeline structure: a canonical CircleCI setup caller and a small `.qqq-ci.yml` manifest. The default development branch is `develop`; `main` is reserved for reviewed releases.
The primary forge for source, reviews and checks is Gitea (`git.kof22.com/QRun-IO`); GitHub is a shadow/archive and receives no ordinary writes. The CircleCI and GitHub Actions material below describes the legacy shared QBit pipeline files kept in this repository and is not a statement of which hosted checks currently gate Gitea changes; confirm the checks a pull request actually runs on Gitea. Run the commands in Verification locally either way.
This repository follows the QRun-IO QBit pipeline structure: a canonical CircleCI setup caller and a small `.qqq-ci.yml` manifest. The default development branch is `develop`; `main` is reserved for reviewed releases.
## Verification
+46
View File
@@ -0,0 +1,46 @@
# CI helper crash handoff (Windows helper job)
Scope: PSScriptAnalyzer 1.25.0 throws NullReferenceException on
`integrations/record-files/Verify-Isolated.ps1` only (root reproduced; the other
five analyzed scripts report zero findings). Run 41337798368 was the hosted failure.
## Changes
- `integrations/record-files/Verify-Isolated.ps1`: the final two statements built
one-line hashtables containing a `$(if ... else ...)` subexpression and several
nested `(Get-FileHash ...)` calls, piped directly to `ConvertTo-Json`. That is
the only construct in the repository's scripts combining those features, and
the alignment/whitespace rules inspect hashtable key/value extents. The
values are now computed into plain variables first and the two hashtables
are multi-line with aligned `=`. Output keys, values and file names are
unchanged. No rule, setting, version pin or test was relaxed.
- `.github/workflows/demo-acceptance.yml`: each file is announced before it is
analyzed. A crash still terminates the step (no try/catch), and the last
`Analyzing ...` line names the file.
## Confidence
The exact crashing AST node was not reproduced by me (no shell). The two
rewritten statements are the strongest candidate, not a proven cause. If the
analyzer still crashes, the next suspects in the same file are the one-line
hashtables at the `$manifest` and `$compiled` loops and the
`foreach($x in $a | Where-Object ...)` headers; extract them into variables the
same way. Pin a different analyzer version only if a minimal reproduction
shows a bug that source changes cannot avoid.
## Verification for the coordinator (read-only analysis)
```powershell
# from R:\Git.Local\QRunIO\qbit-mobile-scanners
pwsh -NoProfile -Command "Get-ChildItem -LiteralPath scripts,integrations/record-files -File -Filter *.ps1 | ForEach-Object { try { $r=@(Invoke-ScriptAnalyzer -Path $_.FullName -Settings scripts/PSScriptAnalyzerSettings.psd1); '{0}: {1} findings' -f $_.Name,$r.Count; $r | Format-Table RuleName,Line,Message -AutoSize } catch { '{0}: CRASH {1}' -f $_.Name,$_.Exception.GetType().Name } }"
# parser check of the edited script
pwsh -NoProfile -Command "$e=$null;$t=$null;[void][Management.Automation.Language.Parser]::ParseFile((Resolve-Path integrations/record-files/Verify-Isolated.ps1),[ref]$t,[ref]$e);$e.Count"
# existing path regression
pwsh -NoProfile -File integrations/record-files/Test-IsolatedBuildPaths.ps1
```
Expected: every file reports 0 findings with no CRASH, the parser prints 0, and
the path regression passes. Any new rule findings in the rewritten block must be
fixed in source, not suppressed.
+40
View File
@@ -0,0 +1,40 @@
# Claude scanner-auth handoff (unbuilt, uncommitted)
## Source facts (core qqq-wt-native-provider-logout, read-only)
- QSession is fully mutable; `QContext.setQSession` aside, any code holding the session can change permissions, user, uuid, channel, security keys, variants, credentialScope in place. Nothing in core guarantees a retained wrapper still has the authority that was checked.
- QCredentialScope has no equals; its getters return unmodifiable views; `null` set = unrestricted (distinct from empty).
- `QSession.hasPermission` ignores credential scope, so `MobilePermissions.require(SCAN)` does not evaluate ceilings.
## Change
- (first round; see follow-up below for the current API) `VerifiedDeviceSession`: at construction stores an immutable `Authority` snapshot (userId, uuid, clientChannel, permissions, security keys, backend variants, and a `Scope` copy: credentialId, readOnly, apps/tables/processes/reports/widgets/operationIds, null preserved). `authorityUnchanged()` re-derives it and compares by value.
- `MobileDeviceAuthorization.captureCurrent`: an existing wrapper whose authority differs from its snapshot throws `QPermissionDeniedException` (fail closed) before any other use; otherwise all existing checks run and the same wrapper is retained (no `setQSession`). First capture still wraps. Values/valuesForFrontend are deliberately excluded (non-grant data).
- Tests (`MobileScannersTest`): `repeatedCaptureRetainsSameVerifiedSession`, `retainedSessionRejectsChangedAuthority` (device header, user, uuid/binding, permissions, approval revoke), `retainedSessionRejectsCredentialScopeAndSelectorChanges` (each ceiling set widened/nulled/narrowed, readOnly, credentialId, scope removed, channel, security key, backend variant, extra permission; each rejected with wrapper kept, then passes after restore).
## Follow-up round (post-review; unbuilt, so the 35-pass result predates it)
- `captureCurrent` now computes the expected authority once at the start (wrapper: its checked snapshot; plain session: a fresh snapshot) and calls `requireCurrent` twice: at the start, and again after ALL checks (approval, observe, binding re-read) immediately before retaining or wrapping. `requireCurrent` fails closed unless `QContext.getQSession()` is still the very same reference AND its authority equals the expected one. Core replacing the session or a host hook changing grants/ceilings during validation is rejected; first capture is not wrapped in that case.
- Immutability assessment (core source): `QSession.clone` copies security-key lists and backend variants one level (elements shared); `QCredentialScope.clone` copies its sets (String elements). Core offers nothing deeper. So `authority()` copies collections and treats element values as safe only if they are known immutable types (String, boxed numerics, BigInteger/BigDecimal, Boolean, Character, enum, UUID, java.time Temporal). Any other element makes the snapshot carry a random marker so it never equals anything: fail closed, no custom serialization.
- New tests: `authorityChangedDuringChecksIsNotRetained` (scoped first capture copies exact ceilings/grants/channel; mid-check ceiling widening, grant addition, session replacement on a retained wrapper; mid-check widening on first capture leaves the plain session unwrapped; a test-only POST_QUERY_RECORD hook on the device table fires during the approval read and each case asserts the hook was consumed) and `mutableSessionValuesFailClosed`. If the hook never fires, the `assertNull(hook)` lines fail loudly rather than pass vacuously.
- Serialized-session compatibility: `VerifiedDeviceSession` declares no `serialVersionUID`, so its computed UID already changed with any prior class edit and changes again now (new fields, nested records). Evidence searched: no ObjectOutputStream/serialVersionUID use in this repo; core `StateSerialization` (Java serialization with an ObjectInputFilter allowing `com.kingsrook.*`) has no QSession reference in the `state` package. I found no source showing a VerifiedDeviceSession is persisted across deployments, so no migration is claimed or added; the wrapper is created per request. If a host persists sessions through a state provider, in-flight old wrappers would fail to deserialize after upgrade and re-authenticate. This is unverified for hosts outside this repo.
## Verification fix
- `authorityChangedDuringChecksIsNotRetained` failed with "second customizer with role postQueryRecord" because the device table already registers `MobileDeviceOnline`. The test now asserts that production customizer is registered, then replaces the map entry with `SessionMutator` (a subclass that runs the one-shot hook and then `super.apply`, i.e. the real behavior). Production code is unchanged. The `assertNull(SessionMutator.hook)` lines after each case still prove the hook ran during the checks (a hook that never fired would leave it non-null and fail).
- Unverified: not rebuilt after this edit (no shell). `MobileBrandingTest` loopback error is environmental per root.
## Security-key set semantics (RF interop fix; unbuilt)
- `VerifiedDeviceSession.Authority.securityKeys` is now `Map<String, Set<Serializable>>`: core row security uses `List.contains`, so membership is the authority; order and repeated identical appends no longer count as change. A null list stays distinct from empty; a new value, a new key, a removed key, or a different user/scope/permission still rejects. Credential-ceiling null-vs-empty handling is unchanged. Immutable-element fail-closed rule unchanged.
- Test `securityKeysCompareAsSets`: repeated same-value append and reorder accepted with the same wrapper; new tenant, different tenant set, new key, and a different actor rejected.
- DONE (later, unbuilt): RF fixture `Authentication.grant(instance, session)` now ends with `CollaborationSecurity.applyToSession(instance, session)` (import `com.kingsrook.qbits.collaboration.CollaborationSecurity`), called from both `createSession` and `isSessionValid`; `integrations/record-files/README.md` documents the call order. The entry below is superseded.
- (superseded) the RF fixture `Authentication.grant` (`integrations/record-files/.../MobileRecordFilesIntegrationTest.java` ~line 705) was not changed to call `CollaborationSecurity.applyToSession(instance, session)`. I could not find that class's package in any readable source and will not guess an import. Edit for the owner/root: in `grant`, after the existing `withSecurityKeyValue("tenant","a")`/admin permission lines and before returning, add `CollaborationSecurity.applyToSession(instance, session)` with the real import (this static method needs the `instance`, so pass it into `grant(instance, session)` from both `createSession` and `isSessionValid`). It must run in authentication, before the first Mobile capture, so no late wrapper mutation or exemption is needed. README note for the optional bridge: authentication applies `CollaborationSecurity.applyToSession` during createSession/revalidation before capture.
## Limits / blockers
- Not compiled or run (no shell). Scope test assumes a scope listing all instance tables/apps lets enroll/bind/capture work; if core denies for another reason, adjust the baseline.
- Behavior: a legitimate in-place refresh that changes permissions now rejects until re-verified rather than being silently adopted (owner-required fail closed).
- Credential ceilings are still not independently enforced by `captureCurrent` (SCAN uses hasPermission); they are only preserved/unchanged. Needs owner decision.
- Out of scope, same bug: `MobilePermissions.checkPermissionsThrowing` (~line 69) still replaces the wrapper on RunProcessInput for scanner processes. Needs root coordination.
## Gate (private frozen caches, run by coordinator)
1. `./mvnw.cmd -Dtest=MobileScannersTest verify`
2. `./mvnw.cmd verify install`
3. `./mvnw.cmd -f demo/pom.xml verify`
Coordinator verification: fullscanner49tests and realdemoHTTP7tests pass, zeroCheckstyleviolations, privatePU/f708 core. ExactRFowner18 remains pending. No productioncache/runtimeadoption.
+13
View File
@@ -0,0 +1,13 @@
# Scoped Echo release review — November 1, 2026
An independent Claude agent reviewed the Echo implementation at4bc88a4 and the Android semantic process-choice changes retained in a2f0080. This was a read-only code review, not a new exhaustive repository or deployment security certification. It reported no confirmed high or critical finding in the reviewed happy path.
The review identified one medium availability issue and two concrete low issues, corrected in this checkpoint:
- Failures before a durable print intent was pinned left the same event unable to recover. An unpinned event can now retry through a single atomic pin; a missing or competing pin prevents generation. Pinned/uncertain attempts remain read-only reconciliation, never a replacement print.
- The pin update now requires exactly one affected row. A concurrent loser cannot call the generator or mark the winning attempt as failed.
- Accepted owner receipts are recorded before the final authority check. A late revocation still denies acknowledgment and new work, while preserving the already accepted job in the audit state. The pre-accept and pre-acknowledgment authority checks remain.
Five new regressions cover pre-pin recovery, missing/competing intent, simultaneous retries, and authority loss after acceptance and during lookup. All38 example tests and all22 composed-demo tests pass with Checkstyle0 against the matched private dependency cohort. These tests use controlled output and a simulated printer; they do not prove physical output.
The reviewer also noted H2-only qualification and a hypothesis about extended Code39 behavior. Other database vendors and the broader AIM/GS1/EAN/raw/decoder-mode fidelity contract are not qualified by this review. Supported-profile boundaries remain explicit; no unsupported payload is silently converted. Source publication, a qualified public Maven cohort, current primary-forge CI, physical/provider/lifecycle checks and organization signing/distribution are separate release gates.
+9 -1
View File
@@ -2,7 +2,15 @@
Status: not release-ready. The user requested a full client/server audit and production-ready 1.0. This is an evidence ledger; a numerical quality score does not replace the gates below.
## Latest verified checkpoint — November 1, 17:34 UTC
## Current checkpoint — November 2
Server `e3b775e` (Gitea PR 7) fixes the Generation/renderer output-limit mismatch and passes 45 Echo library plus 29 composed demo tests with zero Checkstyle violations. Android implementation `fb3cc5d`, published with documentation at `ffc5b49` (Gitea PR 10), passes 174 JVM tests, lint with zero errors and warnings, and the debug APK build. One local server on host port 57832 runs Echo, Record Files, Document Generation and Printing together (API 1.10.0, mobile 1.4.0, printing 2.3.0). The normal TC22 and the installed print client work against it, and the real 4x1 stock, media revision and Echo document are published and selectable. The earlier native acceptance (7 stages, 112 requests) used simulated printing and inputs.
The synthetic HTTP event `ECHO-HTTP-TEST-001` has one matching scan and one actual ZPL print job in `SPOOLED`, with a Windows spooler job assigned and its process closed. A read-only recheck at 16:31 UTC confirmed that receipt, server HTTP 200, the running print service, and the TC22 showing Echo / Scanner ready / Connected. No Windows administrator prompt or printer pairing is pending. Paper output and physical TC22 print/rescan remain unconfirmed; see the [delivery receipt](../examples/echo/demo/docs/echo-delivery-acceptance.json).
Still not release-ready. Open: physical Code128 and leading-zero QR scan, print and rescan; extended barcode fidelity beyond the currently admitted types; full provider, camera, GPS, projection and device qualification; public coherent artifacts; primary Gitea CI; and final signing and distribution, which is deliberately last. Core PR 1337 was reviewed with its 57 focused tests verified, but normal CI and publication are not established and default dependencies are not repinned. Where dated checkpoints below describe the same-origin fix, printer connection, media, document or server composition as pending, this section supersedes them.
## Superseded checkpoint — November 1, 17:34 UTC
Server standards checkpoint0b53f34 is signed/pushed and all eight hosted checks pass; a fresh private API1.10 cohort passes39 server, six demo HTTP and18 Record Files consumer cases with Checkstyle. Client strict compilation and163 JVM tests pass; final TC22 native acceptance is running and the Query artwork density warning is being corrected before installation. About includes bundled Query artwork and the exact public Dogfooding/Query links.
+91 -1
View File
@@ -1,3 +1,93 @@
## November 3 — current checkpoint (supersedes everything below)
- **Server source:** `45f9cbddb14999d28add02dd9a37e4ce018e282e` is signed and pushed on Gitea PR 7 (earlier "uncommitted" statements below are historical). The five `qqq.version` POM pins to public **QQQ 4.1.0-RC.21** (root, demo, Echo, Echo demo, record-files) and the record-files POM test fix below are included in this verified dependency update.
- **Final RC.21 gate: PASS** (`C:/Users/james/.cache/qqq-rc21-copies-gate-076d059d8d0641d9a85874ec64008def/verified-summary.json`). 377 unique tests with zero failures, errors or skips and Checkstyle PASS: Generation 179 and Printing bridge 16 (`verify-recovery.log`); scanner 53, demo 8, mobile Record Files 18, Echo 68 and Echo demo 35 (`verify-consumers-junit-fixed.log`). Plus 15 real admin-browser E2E cases on public RC.21 (single, checked and query selection, first-N approve, revoke, end session, display name/description, location/online, screenshot widget). The three earlier MySQL network timeouts did not recur in the focused 31 or the full 179 runs; the original failure reports are retained.
- **Owned source fix:** `integrations/record-files/pom.xml` drops the aggregate Jupiter 5.11.4 and uses the BOM-managed engine and params; the XML test classpath shows API, engine, params and platform all at 6.0.2.
- **Dependency honesty:** the five owned scanner consumer modules resolve only public QQQ artifacts (core, rdbms, middleware, filesystem, openapi RC.21; frontend-next RC.35), and Generation's core is RC.21 too, so all 182 scanner-consumer tests use public QQQ modules. The 16 Printing-bridge owner tests still resolve three private `pu507` QQQ libraries (filesystem, Javalin, OpenAPI) transitively through the existing owner Printing POM, and the other owner qbits remain qualified private inputs. So: **377 pass with public core RC.21 plus qualified owner inputs; this is not a fully public or released cohort.** No normal-runtime core upgrade was performed.
- **Generation copies:** canonical Git source is now `Website-Backend/third-party-src/qbit-document-generation`. Signed `aac68907e67906aeef4e0cd687d28bc98b738f8c` is pushed on `feature/echo-generation-copies`, based on the actual PR 343 head `8f473fa`. The isolated clone `C:/Users/james/.cache/qqq-generation-copies-git-20261103` has 13 paths (including the PostgreSQL migration and provenance); the 21/21 module manifest matches. Qualified: 179 Generation + 16 bridge tests (prior cohort) and a fresh real PostgreSQL 17 migration pass. Owner integration and public artifact publication are not acknowledged and remain pending. No peer active checkout was edited.
- **Android:** `f72b90d08cf29d2785cef1d5c5e7eeaf75f9afd2` is signed and pushed to the existing AGit PR 10 head (read back independently over SSH). Gitea CI 3480 **succeeded** on it (unit-test, device-test, package, secrets, gate; root read the UI); root also verified a clean git merge-tree of f72b90d with actual `main` b75fdac and merged nothing. Pull requests #8 and #10 remain open; nothing is released. The updated normal APK (SHA-256 `4AAB6951D3B0ABDDD4F5A8DC6790DCD84233EE08179B68DB108BF77909AB850E`) is installed with the same signer and no data clear; Connected, Apps, Settings, portrait, location on and the system-wallpaper brand-only preview were verified. No wallpaper or profile was applied. Fresh: 222 JVM tests, lint 0 errors/0 warnings (2 info), APK builds, 23 Node tests, 255-file standards pass. A fresh disposable TC22 Echo ten-stage run passed 109/109 semantic requests (simulated inputs and printing).
- **Authentik:** a fresh real desktop acceptance passed against the same isolated TLS fixture (nonce, A login, logout with old-cookie replay rejected, fresh B without clearing cookies, logout). The opt-in native harness is integrated and compiled and its 8 launcher guards pass. Native TC22 execution still needs the temporary CA and PIN interaction (pending); no trust bypass. The normal server on 57832 is unchanged and ready.
- **Still open (not claimed):** Generation canonical owner integration and public reproducible inputs for the owner qbits; primary source CI, merge and release; the manual TC22 trigger, value/type, AIM, label count, rescan, photo, GPS, projection and profile checks (the operator is still remote); native Authentik CA, PIN and callback; and signing/distribution, deliberately last by user decision. The normal TC22 currently shows Echo setup, Copies 1, Connected.
## November 3 — publication preparation (historical; superseded by the current checkpoint above)
The scanner/Echo/demo sources for copies per scan, AIM/EAN/UPC fidelity and photo composition are in the working tree of `feature/echo-example`, ready for one signed commit and review (not yet committed). They declare no private artifact coordinates: Echo's POM states that copies need a Generation build with the print-copies change, and `integrations/record-files` now takes `qqq.version`, `mobile.version`, `recordfiles.version` and `collaboration.version` with the demo's defaults. The Generation copies change is ten hash-checked files; its eight existing files match the Kof22 release reactor's `third-party-src/qbit-document-generation` byte for byte (SHA-256 listed in `qqq-finish-server-ready-20261103.md`). No Git repository for Generation exists in these local directories: adoption belongs to the Kof22 release repository, so the change is a hash-verified file set, not a commit here. Nothing was built or pushed in this step.
## November 2 — Echo copies adopted in the normal local server
Optional **Copies per scan** defaults to 1 and accepts 1–100. Omitted/null/blank setup uses 1. The run pins the quantity; scan payloads cannot change it. Each scan creates one retained job with options.copies, with retries preserving quantity and job identity. Existing records migrate to 1. The normal server on port 57832 now runs this feature, mobile-scanners 1.5 and mobile-record-files 1.0 using the original database, approval, accounts and printer configuration. The unchanged normal TC22 APK is connected and shows the setup field with 1.
Verified gates: Generation 179, Printing bridge 16, scanner 53, base demo 8, mobile Record Files 18, Echo 68 and composed demo 35 tests (377 unique tests across module runs, no failures/errors/skips). Native TC22 acceptance passed ten stages and 116/116 semantic requests, including default 1, range rejection, chosen 3 across two scans, recreation and Leave. Scanner inputs/printing are simulated; physical count remains unconfirmed. Android source also passes 222 JVM tests, lint and APK builds; 15 Node tests pass.
Generation's ten-file copies contract is verified in C:/Users/james/.cache/qqq-generation-copies-20261102 with handoff qqq-generation-copies-handoff.md; canonical owner adoption/publication is still pending. Echo photo composition is running and its authorization tests pass; actual capture/upload is unqualified. Native Authentik and physical trigger/print/rescan, camera/GPS/projection checks are deferred while the operator is remote. No certificate installation is confirmed. Public coherent artifacts/CI and final signing/distribution remain open. Earlier checkpoints below are historical.
## November 2 — current checkpoint
Source candidate: mobile-scanners 1.5 adds optional `aimIdentifier` and exact SDK `rawDataBase64` to persisted scans and backend `scanContext`. Changed metadata on an existing event is rejected; identical retries retain the original row, and omitted metadata cannot leak from the preceding event. The generated semantic contract remains compatible with the unchanged 1.0, 1.1 and newly preserved 1.4 baselines. The isolated four-module reactor passes 135 tests (53 scanner, 8 native HTTP demo, 45 Echo, 29 composed demo), zero failures/errors/skips and Checkstyle clean. Independent Claude diff review found no actionable correctness, security or compatibility defect; a whitespace-only review correction followed verification. This candidate is not installed in the running demo; hardware AIM extraction and extended Echo fidelity remain open.
The first actual Echo native HTTP event exposed a composition defect: Generation requested its default 20 MiB allowance from a renderer capped at 1 MiB. Both demo launchers now propagate their configured cap into Generation. Restoring the old wiring produces three regression failures; the fixed library/demo reactor passes all 74 tests (45 + 29), with no failures, errors or skips and Checkstyle clean. The preserving local server restart uses these tested artifacts. A new, clearly synthetic `ECHO-HTTP-TEST-001` Code128 event completed real rendering, retention and printing: one job reached `SPOOLED` with a spooler job assigned, and native Cancel closed the process. The earlier failed event remains `ATTENTION` without a print job and was also cancellable. Neither event is a physical TC22 scan. Paper output and rescan confirmation remain pending; see [delivery receipt](../examples/echo/demo/docs/echo-delivery-acceptance.json).
The real trusted-local renderer has produced Code128 `QQQ-TEST-123456` and QR `001234567890` from the actual published 4x1 revision. Independent ZXing pixel decoding and stock/margin/fit checks pass; a fresh native read confirms the same published revision and stock. See [rendering evidence and test images](../examples/echo/demo/docs/ECHO-RENDERER-ACCEPTANCE.md). That earlier rendering-only check submitted no printer job; the subsequent synthetic delivery is recorded above. Physical acceptance remains open.
The primary CI secret-scan failure was reproduced locally as five findings in inert test-token literals. The fixtures now generate tokens at runtime; `.gitleaksignore` names only the five exact historical commit/file/rule/line fingerprints. There are no rule or path exclusions and no production validation changes. Historical and staged Gitleaks scans pass, and all 27 composed demo tests pass with zero failures, errors or skips and zero Checkstyle violations. Current hosted CI still needs verification after publication.
Completed and verified:
- Server source `e3b775e` is signed and published as Gitea PR 7. The Echo library and composed demo pass 74 tests with zero Checkstyle violations. Host forwarding accepts `localhost` and `127.0.0.1` with a valid explicit port, and foreign Host and Origin values are denied.
- One local QQQ demo server on host port 57832 runs Mobile Scanners/Echo, Collaboration/Record Files, Document Generation and Printing (API 1.10.0, mobile 1.4.0, printing 2.3.0). On this workstation, device port 8080 forwards to host port 57832. The original accounts, enrollment and approval are retained.
- Android `fb3cc5d` (Gitea PR 10) adds the native same-origin fix. The normal TC22 was updated without clearing data, and existing login and approval, heartbeat, real printer, media and document selection, and Cancel then Leave were verified. The emulator is off because it stalls this workstation.
- The installed print client (RC4) is signed in, enrolled and connected to this server, and its native-approved ZPL printer is available. The Printing owner has aligned the loaded 4x1 stock (101600 x 25400 microns at 300 dpi) and updated the real media revision. No UAC prompt or printer setup is pending.
- A real Echo 4x1 barcode document is published through native authoring, with a matching immutable revision 1. The TC22 selected the actual printer, the fresh 4x1 media and that document. Next succeeded and the device shows Scanner ready and Connected.
Not yet done:
- No physical Echo print or rescan has been proven. The synthetic event has exactly one matching scan and one actual ZPL job in `SPOOLED`, rechecked at 16:31 UTC. The same read-only check confirmed server HTTP 200, print service Running, and the normal TC22 showing Echo / Scanner ready / Connected. One physical scan of QQQ-TEST-123456 has been requested from the user; no UAC or setup action is pending.
- The earlier native end-to-end run (7 stages, 112 semantic requests) used simulated printing and inputs. It is not physical evidence.
- Still open for 1.0: physical Code128 and leading-zero QR scan, print and rescan; the full original provider, camera, GPS, projection and device qualification; public coherent artifacts; primary Gitea CI; and final signing and distribution. Signing is deliberately last, by the user's decision. A working first demo does not collapse these requirements, and nothing here claims release readiness.
- Core native API merge continuation PR 1337 (a42f9e5) was reviewed with no actionable conflict regression, and its 57 focused tests were verified. Normal CI and publication are not established, and default dependencies are not repinned.
The entries below are historical. Where they describe same-origin login, printer connection, media, document publication or server composition as pending, this checkpoint supersedes them.
## November 2 — one local QQQ server and installed print client connected
The canonical local server on57832 now runs Mobile Scanners/Echo, Collaboration/Record Files, Document Generation and Printing together, using the existing development database. Both original accounts authenticated. Live discovery advertises API1.10/mobile1.4/printing2.3; authenticated administration metadata exposes the composed modules. This is greenfield local development: no backup, cutover or cross-server bridge is required.
The full demo gate passes27 tests with zero failures/errors/skips and zero Checkstyle violations. It includes file-backed existing-data acceptance and the USB-forwarded loopback Host regression. Actual running HTTP checks accept Host127.0.0.1:8080 through57832 and reject foreign hosts/origins. The TC22 retains its original app data, approved device and8080 USB connection; no emulator runs.
The existing RC4 print client is now natively signed in, enrolled and connected to57832. It reports five printers/two exposed. The sole available ZPL printer was approved through the ordinary printingEnablePrinter process; the persisted native query confirms enabled/available. No physical job was sent and printer defaults were unchanged.
(Superseded by the current checkpoint above: the same-origin header, 4x1 media report and document publication are done. Only the physical TC22 scan, print and rescan remains for the demo, and the full production release gates remain open. Earlier separate 53118 pairing and Windows-prompt status below are historical.)
## November 1 — corrected setup verified on TC22 and published
Server source8da274e and Android test-only source47a923a are signed/pushed; Android's actual Gitea PR10 review topic was advanced to47a923a via SSH AGit (the source feature branch alone does not advance an AGit review ref). Required printer/media/INTEGER Document selection, dependency clearing, two accepted synthetic scans with the same selections, recreation and Apps/Leave all passed on real TC22:7 stages,112/112 published semantic requests,15 operations. Normal app/data preserved; emulator off; printing/scanner qualification flags remain false. Server45/library24/demo and Android99focused/host tests+8Nodeguards/lint0errors0warnings pass. Live private server53118 is still101efc8/EchoRetry pending document-choice rollout, and actual printer pairing awaits the current Windows consent in existing session73437. No root physical print has occurred. Normal57832 stays unchanged.
## November 1 — operator chooses the Document at setup (server verified)
User-confirmed flow: choose printer, Document template and paper/label size once, then repeat scan -> server render -> accepted print job until Leave. The Android client remains a generic semantic QQQ process client; it has no printer-device integration. `templateId` is required INTEGER with an authorized dependent PVS, not a hidden server default. The run pins selected template/revision; changed documents are refused rather than substituted. Migration preserves existing rows. Root:45 Echo library/24 full composed demo tests PASS, Checkstyle0. Tests include two document choices, forged retarget refusal, republish refusal with native Leave/CLOSED and repeated scan rendering. Expected errors use existing output.setException to retain process ownership after core step failure. Native Android tests99 + Node8 + lint0errors0warnings/testAPK compile pass; updated TC22 UI acceptance remains in progress. No physical print claim. Private live53118 remains filtered source101efc8/EchoRetry until this rollout, and printer retry awaits Windows consent; normal57832/TC22data are preserved.
## November 1 — shared device headers scoped per extension registry (verified)
`MobileScannersRouteProvider` interpreted every `X-QQQ-Device-*` header as a scanner credential, so a native Printing agent's own proof got 403 on `/qqq/v1/qbit/printing/*`. It now leaves a request to another extension's own authenticator only when the path is another extension's `/qqq/v1/qbit/*` route (not scanner routes, metadata, process or widget calls) and QQQ already validated a registered, non-`android`, non-`web`, non-token client channel. Android and unmarked requests keep scanner verification everywhere, ended-session and approval checks are unchanged, and no scanner device is bound for the foreign route, so the marker grants nothing. New composed demo regression `printerDeviceProofIsAuthorizedByPrintingAndNeverByScannerAuthentication` passed against the new immutable private scanner artifact. Root verification:50 scanner tests,7 base-demo HTTP tests and23 composed Echo-demo tests, Checkstyle0. The corrected regression fails403 at printer binding on the old artifact and passes on the fixed artifact; wrong proof/actor and scanner-boundary denials remain enforced. Live update and physical printing are separate.
## November 1 — independent review corrections verified
The scoped independent review of Echo4bc88a4/Androida2f0080 found a pre-pin recovery gap, an unchecked atomic pin result and a late-revocation receipt-audit gap. The fixes preserve authorization checks and lookup-only recovery after a pin. Five meaningful regressions now pass: Echo38 and full composed demo22, Checkstyle0. See ECHO-RELEASE-REVIEW-2026-11-01.md. The running private backend remains on the earlier qualified Echo artifact until an owned rollout; no running SDK or normal operator database was overwritten. Physical print/rescan and public release gates remain open.
## November 1 — Echo composed pipeline verified and private backend live
Echo's 33 tests and the runnable demo's full22 tests pass with zero failures/errors/skips and Checkstyle0. The clean native HTTP acceptance uses the real Generation, Record Files and Printing owners: two scans create two retained originals and two accepted jobs; retry preserves one job, and Leave closes the run. Renderer output is controlled and printer inventory simulated in this test, so physical printing remains unqualified. The Echo source checker now accepts core's exact bare READ capability probe while preserving user/permission checks, actor row locks and concrete write denial. The demo enriches complete QQQ metadata, establishes attachment capability, and derives the printing tenant idempotently. Host tests reset JVM-local RDBMS providers between fixtures.
The local launcher uses the renderer owner's explicit TrustedLocalDockerExchange public factory (unique private renderer0.1.0-echo.gen64c9pu507.tl1); no copied transport or production fallback remains. Owner52 tests and real Docker smoke qualify development use only. A fresh private instance is live with successful scanner/admin semantic login and metadata; no printer or template is configured yet. Normal operator data/runtime remain preserved.
Next required path: native Windows printing connection/current4x1stock handoff, publish and pin a real media-matched template, actual TC22 scan/print/rescan, and primary Gitea CI/public dependency publication. Full1.0 physical provider/GPS/photo/screen gates and broader AIM/EAN/UPC/GS1/raw barcode fidelity remain open. Production signing/distribution is deferred as requested. Passing private SDK/source gates is not a production release claim.
## November 1 — coherent scanner and Record Files regression verified
Security-key snapshots compare effective membership as sets, allowing identical repeated grants and reordering while rejecting new or changed authority. Optional Record Files authentication applies the owner's CollaborationSecurity.applyToSession during login/revalidation before the first verified capture. Scanner50 and native Record Files18 (16 integration +2 contract) pass with zero failures/errors/skips and Checkstyle0 against the complete private gen64c9pu507/PU dependency cohort. This supersedes the prior pending/failed native18 checkpoint; it does not declare private dependencies published or production-qualified.
Echo32 and runnable demo12 also pass on that cohort. The demo startup test uses a refusing renderer; actual live cold renderer configuration and physical print/rescan remain pending. Android169 JVM and five-stage TC22 native semantic Echo acceptance passed with simulated printing. Gitea is primary for further source publication; previous GitHub PR links are historical shadow references. Preserve the normal scanner/server/data and printer installation. Signing/distribution remains deferred.
## November 1 — verified capture identity retained without authority drift
Claude's scanner fix preserves the same already-verified session only after current approval/binding/authentication and unchanged session reference/authority checks. First capture still wraps. Tests cover repeated object identity, current permission/credential ceilings/selectors, device and user binding, revocation, and real post-query-hook mutations during validation. Mutable opaque authority values refuse retention. Full scanner49 and real demoHTTP7 cases pass with Checkstyle0 against the frozen privatePU/f708 core.
The files owner still must qualify its strict native upload/finalize18 against this exact patch; no per-instance enrichment policy or global registry is adopted. Normal server/data remain unchanged. Echo native TC22 now passes all5stages/86documentedrequests with client3bec46d; real renderer/printer output and final host integration remain open. Production signing is deferred.
# QQQ Mobile Scanners session state
## November 1 typed scan context implementation
@@ -134,7 +224,7 @@ Active plan: publish the companion Android checkpoint pinned to verified server
## Baseline and ownership
- QQQ baseline: 4.1.0-RC.20. The local 4.0.0 middleware does not support Basic sign-in through `manageSession`; RC.20 does.
- QQQ baseline: 4.1.0-RC.21 (public Maven Central; adopted 2026-11-03, gate pending). The local 4.0.0 middleware does not support Basic sign-in through `manageSession`; RC.20 and later do.
- Backend agent: qbit main source, unit/integration tests and root POM.
- Android agent: `qqq-android` source and scanner application module.
- Toolchain agent: Android SDK installed after explicit user approval of required Google terms.
+49 -1
View File
@@ -1,6 +1,51 @@
# QQQ Mobile Scanners TODO
- [ ] Deliver Echo example: choose authorized printer and media once, then one scan/render/accepted-print job at a time until leave; reproduce exact value and symbology with worker-owned integer-module fitting and quiet zones.
Current checkpoint (November 3; supersedes the notes below where they differ):
- [x] Server `45f9cbd` is signed and pushed (Gitea PR 7). Android `f72b90d` is signed and pushed to the existing AGit PR 10 head and Gitea CI 3480 succeeded on it (pull requests #8 and #10 still open; not merged, not released). Updated normal APK installed without clearing data; fresh disposable TC22 Echo run passed 109/109 semantic requests (simulated inputs/printing).
- [x] Final RC.21 gate PASS: 377 unique tests (Generation 179, Printing bridge 16, scanner 53, demo 8, mobile Record Files 18, Echo 68, Echo demo 35), zero failures/errors/skips, Checkstyle PASS, plus 15 real admin-browser E2E on public RC.21. The earlier MySQL timeouts did not recur (focused 31, full 179). Evidence: `C:/Users/james/.cache/qqq-rc21-copies-gate-076d059d8d0641d9a85874ec64008def/verified-summary.json`.
- [x] Verified dependency update: five RC.21 POM pins and the record-files POM test fix (BOM-managed Jupiter engine/params, all 6.0.2).
- [ ] This is public core RC.21 plus qualified owner inputs, not a fully public or released cohort: the 16 Printing-bridge tests still resolve three private `pu507` QQQ libraries through the owner Printing POM, and the other owner qbits remain qualified private inputs. Public reproducible owner inputs, primary source CI, merge and release remain open. No normal-runtime core upgrade was done.
- [x] Generation copies: signed `aac68907e67906aeef4e0cd687d28bc98b738f8c` on `feature/echo-generation-copies` (base PR 343 head `8f473fa`) in `Website-Backend/third-party-src/qbit-document-generation`; 13 paths, 21/21 manifest match; real PostgreSQL 17 migration passes. [ ] Owner integration and public artifact publication not yet acknowledged.
- [x] Fresh real Authentik desktop acceptance passes; native harness integrated (8 guards pass). [ ] Native TC22 run needs the temporary CA and PIN (pending); no trust bypass.
- [ ] Manual physical checks: trigger, AIM, fidelity, label count, photo, GPS, projection. [ ] Signing and distribution last, by user decision.
Previous checkpoint (November 2; historical, superseded by the current checkpoint above):
- [x] Optional Copies per scan defaults to 1, accepts 1–100 and remains server-pinned for every scan/retry. Live on the original local server57832; normal TC22 setup shows 1.
- [x] Verify 377 unique server/owner tests across the module runs, plus real TC22 ten-stage/116-request semantic acceptance with simulated scans/printing.
- [x] Adopt mobile-scanners1.5 and actual mobile-record-files1.0/Echo photo composition locally, preserving the original database and printer settings. Photo authorization tests pass.
- [ ] Confirm physical label count/fidelity and real camera/GPS/projection/provider behavior; operator currently remote. Temporary Authentik CA installation is unconfirmed.
- [ ] Obtain canonical owner adoption/publication of the verified Generation copies handoff: ten hash-checked files for the Kof22 release repository's `third-party-src/qbit-document-generation` (base verified identical), then a published Generation coherent with Echo's `generation.version`; complete coherent artifacts/CI. Signing/distribution stays last.
Current demo path (November2; supersedes historical runtime/pairing notes below):
- [x] Implement and verify additive mobile1.5 raw-byte/AIM transport, exact retry binding, optional context and preserving H2 migration:135 scanner/demo/Echo tests pass. Android counterpart is implemented; native capture and installed-runtime adoption remain open.
- [ ] Qualify actual TC22 raw-byte delivery, AIM extraction and decoder settings; finish renderer scanner-mode/extended-symbology integration. Transport support alone does not establish same-symbol reproduction.
- [x] Fix and adopt the Generation/renderer byte-limit mismatch in both demo launchers; three failures with old wiring, 74 passing library/demo tests with the fix. One clearly synthetic native HTTP event now reaches the actual Windows printer spooler through Echo, with process cancellation verified after failure and acceptance. Paper output, TC22 trigger capture and rescan remain separate open checks.
- [x] Render the actual published 4x1 document through the real renderer and independently decode its Code128 and leading-zero QR pixels; both preserve exact value/symbology and fit the configured stock. See [evidence and test images](../examples/echo/demo/docs/ECHO-RENDERER-ACCEPTANCE.md). Physical printing remains unverified.
- [x] Correct the five inert test-token secret-scan findings using generated fixtures and exact historical fingerprints; local historical/staged scans and all 27 demo tests pass. Current primary CI remains an open gate.
- [x] Run all real scanner/Echo, files, document and printing qbits in the single local server57832; original accounts work; full demo27 and live forwarded-host checks pass.
- [x] Connect the existing installed print client to57832 and approve its available ZPL printer through the native QQQ process; no job/default changes.
- [x] Verify the Android same-origin header (Android `fb3cc5d`, 174 JVM tests) and TC22 sign-in, heartbeat, printer/media/document selection and Cancel/Leave against the strict local host, preserving data.
- [x] Resolve the earlier 4x6 media report versus loaded 4x1 stock with the Printing owner (101600 x 25400 microns, 300 dpi) and publish the real Echo 4x1 barcode document with its matching immutable revision 1.
- [ ] Complete one physical Code128 (QQQ-TEST-123456) scan, print and rescan on the TC22, then leading-zero QR. One synthetic HTTP test job reached `SPOOLED`; physical paper output and handheld acceptance are not yet proven.
- [ ] Finish the existing production-readiness matrix and publication gates; local demo acceptance alone does not complete 1.0. Public coherent artifacts, primary Gitea CI and final signing remain open, with signing last by user decision.
- [x] Implement explicit required INTEGER Document choice with printer/media dependency, server-pinned revision and recoverable errors:45 library/24 composed tests and real TC22 seven-stage/112-request semantic acceptance pass (simulated printing).
- [x] Roll out the document-selection feature and connect the installed print client to the canonical local server. Real4x1 publication and physical scan/print/rescan remain tracked above.
- [x] Verify the per-extension device-header scope fix (50 base,7 base-demo and composed HTTP regressions), then complete actual native printer enrollment/binding on the local server.
- [x] Publish reviewed Echo retry/receipt fixes as signed Gitea commit912040d;38 library/22 demo tests pass. (Historical: the then-pending Windows approval of printer pairing is superseded; the print client is now connected to the single local server.) Physical output and public CI/artifacts are still open.
- [x] Verify real composed Echo native HTTP pipeline:38example/22demo, retained files, accepted jobs, retry identity and Leave closure; simulated printer/controlled renderer only.
- [x] Complete native printer connection on the current loaded stock (done on the single local server). Physical scan/print/rescan is tracked above; published dependencies and primary CI remain open.
- [x] Verify effective security-key set comparison and early Collaboration authentication initialization: scanner50/native Record Files18, Checkstyle0 on private coherent cohort.
- [x] Compose the live Echo renderer, Printing and document publication in the single local server. Physical TC22 print/rescan is tracked above; startup smoke and simulated printing do not close that gate.
- [ ] Deliver Echo example: choose an authorized printer, media and Document (all three required) once, then one scan/render/accepted-print job at a time until leave; reproduce exact value and symbology with worker-owned integer-module fitting and quiet zones. Source, composition, Document choice and native setup are done. Still open: the physical print and rescan above, and full-fidelity work beyond the six admitted printable-ASCII types (EAN/UPC, GS1, AIM identifier and raw payloads), tracked in `examples/echo/README.md`.
- [ ] Coordinate qualified renderer fit, authorized printer/media discovery, least-privilege submit and retained-print adapter contracts with their owners via shared communications.
- [x] Verify scanContext 1.3 with root44/demo7/optional18 and Checkstyle: following-step QR/Code 128 branching, legacy omission isolation, trusted attribution and exact retry binding; physical scanner acceptance remains separate.
@@ -64,3 +109,6 @@ Future scope (not version one): offline queue, camera-based barcode decoding, ki
- [ ] Complete the optional semantic API1.10 provider-logout capability and client AppAuth end-session handling; keep local cleanup reliable on provider failure, qualify real A-to-B handoff, and apply the user's pending sign-out policy. Existing prompt-only retained-SSO handoff is not accepted.
- [ ] Validate native OIDC ID-token claims and original request nonce before creating the QQQ session. Review confirmed request continuity and S256 PKCE exist, but expected nonce is not currently carried to the server exchange. Add explicit semantic capability/wire compatibility and rejection tests; retain real-provider nonce acceptance as pending.
- [x] Retain unchanged verified capture session identity with explicit current authority/reference checks;49root+7demo tests andCheckstylepass.
- [ ] Qualify exact ownerRF18 upload/finalize using the frozen identity patch before final coherent host adoption.
+23 -2
View File
@@ -1,6 +1,6 @@
# Mobile Scanners semantic UI API
Mobile Scanners is an independently versioned QQQ UI API extension. `MobileScannersQBitProducer` registers the `mobile-scanners` contract, currently **1.3.0**, through `MiddlewareSpecExtensionsMetaData`. The framework generates its routes, discovery advertisement and merged OpenAPI from the same endpoint specs. Optional operator-consented device-screen viewing is described in [DEVICE-SCREEN-VIEWING.md](DEVICE-SCREEN-VIEWING.md); app-content remains the default. There is no separate `/mobile/v1` API or legacy alias.
Mobile Scanners is an independently versioned QQQ UI API extension. `MobileScannersQBitProducer` registers the `mobile-scanners` contract, currently **1.4.0**, through `MiddlewareSpecExtensionsMetaData`. The framework generates its routes, discovery advertisement and merged OpenAPI from the same endpoint specs. Optional operator-consented device-screen viewing is described in [DEVICE-SCREEN-VIEWING.md](DEVICE-SCREEN-VIEWING.md); app-content remains the default. There is no separate `/mobile/v1` API or legacy alias.
The host must also install `MobileScannersRouteProvider` on the same server. This provider binds verified device headers to the current request; it does not register additional business routes. Keep the versioned QQQ routes enabled. The qbit registers the `android` client channel while preserving the host's existing channel policies. The demo disables legacy unversioned middleware routes.
@@ -57,9 +57,26 @@ Host-owned processes use `MobileScannerActions.capture(input, appName)` with the
Raw event fields and old receipts are consumed before capture, including rejected attempts, so the next submission cannot inherit a preceding type. Following steps use the nested accepted context. Historical NULL type/source values normalize for comparison and returned context without rewriting those rows or accepting a newly claimed known type on retry. Pre-1.3 process state containing a receipt without context fails closed: leave/cancel and reopen the scanner process. Logout and device re-enrollment are unnecessary.
### Exact capture metadata in 1.5
Extension 1.5.0 adds two optional flat string values to `MobileScannersScanEventV1` and, when present, to `MobileScannersScanContextV1`. Neither is a required key and old required fields are unchanged. Missing values are valid for older clients and manual capture.
| Value | Rule |
| --- | --- |
| `aimIdentifier` | Exactly 3 characters matching `^\][A-Za-z][0-9A-Za-z]$`: the original AIM identifier from the scanner SDK, never guessed from `symbology`. |
| `rawDataBase64` | Canonical standard padded Base64 of 1 to 8192 decoded bytes (at most 10924 characters): the byte-for-byte SDK `getRawData` result. The server does not decode it, strip prefixes or assume it equals the UTF-8 of `barcode`. It may include SDK code-ID framing, so it is exact SDK output, not symbol codewords and not evidence of attested scanner configuration. |
A present value that is blank, not a string, malformed, non-canonical (missing or extra padding, whitespace, URL alphabet, non-zero trailing bits) or oversize is rejected by the server with no scan saved. A JSON `null` is treated as absent. Stored values are nullable columns on `mobileScan`; historical rows keep `NULL`, which means absent.
Exact equality of both values, including absent versus present, participates in event retry binding: an identical retry returns the original row and scan ID, while changed bytes, a changed AIM identifier, or adding or dropping either value for an existing `eventId` is rejected like any other changed scan. The context contains these keys only when the accepted scan has them. Both input keys are consumed before capture (including rejected attempts) and any submitted `scanContext` is replaced, so the next event cannot inherit them and a client cannot forge them. They carry no identity and no trust elevation, and there is no decoder profile or trust field in this version.
A client sends these fields only when the current server's `mobile-scanners` version is `>=1.5.0 <2.0.0`, the registered path is advertised and the server's process metadata permits them. Older servers receive exactly the legacy five keys (`barcode`, `symbology`, `scanSource`, `eventId`, `capturedAt`). Hardware qualification of AIM and raw-byte fidelity is separate follow-up work; this transport does not by itself establish exact barcode reproduction.
Schema migration (H2, idempotent, nullable): `ALTER TABLE mobileScan ADD COLUMN IF NOT EXISTS aimIdentifier VARCHAR(3)` and `... rawDataBase64 VARCHAR(10924)` in `mobile-scanners-h2.sql`. Other backends must add equivalent nullable columns before deploying 1.5.0 servers; do not assume `ADD COLUMN IF NOT EXISTS` or the type sizes exist elsewhere.
## Contract checks and compatibility
The published fragment is [mobile-scanners.yaml](../src/main/resources/openapi/qqq-v1/mobile-scanners.yaml); the initial release baseline is [mobile-scanners-1.0.0.yaml](../src/test/resources/openapi/mobile-scanners-1.0.0.yaml). `MobileScannersApiContractTest` rejects generated-document drift, unresolved contract structure, incompatible baseline changes and missing version bumps using QQQ's shared contract checker. Object required fields and scanner numeric bounds are standard OpenAPI keywords, added by small Schema subclasses because RC20's base model does not expose those setters.
The published fragment is [mobile-scanners.yaml](../src/main/resources/openapi/qqq-v1/mobile-scanners.yaml); the initial release baseline is [mobile-scanners-1.0.0.yaml](../src/test/resources/openapi/mobile-scanners-1.0.0.yaml), with additional frozen baselines such as [mobile-scanners-1.4.0.yaml](../src/test/resources/openapi/mobile-scanners-1.4.0.yaml) (the contract immediately before the 1.5.0 capture metadata). `MobileScannersApiContractTest` rejects generated-document drift, unresolved contract structure, incompatible baseline changes and missing version bumps using QQQ's shared contract checker. Object required fields and scanner numeric bounds are standard OpenAPI keywords, added by small Schema subclasses because RC20's base model does not expose those setters.
For an intentional contract edit, update specs/schemas and the extension semantic version, then regenerate only the current fragment:
@@ -70,3 +87,7 @@ For an intentional contract edit, update specs/schemas and the extension semanti
Do not refresh the release baseline to hide a compatibility failure. Additive compatible changes require a minor version increment; fixes require a patch increment. Breaking contracts require the corresponding new major contract and an explicit client migration. A server can change supported metadata/screens without rebuilding customer APKs; introducing a new required native component needs a shared client update and compatible contract negotiation.
Root `verify` checks the committed fragment/baseline and public stale-credential behavior. Demo `verify` runs the real HTTP/authentication/process/H2 lifecycle, checks discovery and merged operation IDs/security/schema references, validates actual enrollment/device/SCANNER/event/ack payloads against served schemas, and checks structured errors and rejection of legacy routes. It writes sanitized core and extension discovery fixtures under `demo/target/android-contract` for Android contract tests. Browser/native end-to-end suites provide the final rendered UI and persistence checks.
## Scanner presentation hints in 1.4
SCANNER values may include acknowledgmentLabel (default Saved scan) and manualEntryEnabled (default true). Labels are bounded trimmed single-line plain text of 1–80 characters; explicitly invalid labels or nonboolean manual flags are rejected by the native renderer. Echo uses Print queued and disables manual entry because a manually typed value has no authoritative symbology. These hints do not change scanSaved, scanId or matching-event acceptance requirements; Print queued is not physical output confirmation. Older 1.x clients may ignore the optional hints.
+69
View File
@@ -0,0 +1,69 @@
# Echo — scan a barcode and print a copy
Echo is an optional QQQ example application, using the shared Android scanner host. Choose a currently authorized printer and its supported paper/label profile once, then scan repeatedly. Each scan preserves its string value and actual symbology and uses the existing Document Generation → retained original → native Printing path. One event is admitted per run at a time. A successful acknowledgment means **queued**, not confirmed physical paper.
The source implements ordinary QQQ metadata and processes; no Echo-specific Android screen, business endpoint, barcode encoder or print queue is added. There is deliberately no fabricated printer, hardcoded stock, administrative-permission shortcut or in-process renderer fallback.
**Status.** The owner adapters and host composition are source-complete and pass `verify` (Checkstyle clean) against the qualified owner inputs described under Build and acceptance, and the [runnable demo](#runnable-demo) passes its tests, including a complete composed acceptance (current counts and progress: [session state](../../docs/SESSION-STATE.md)): an authenticated approved scanner, two scans, two retained originals and two accepted Printing jobs on one printer, an idempotent retry and leaving. That automated acceptance uses a controlled synthetic renderer and a simulated Printing queue.
The composed demo runs as one local server with a real Printing client, printer and 4x1 media, and a published Echo barcode document. A TC22 completed setup against it (printer, media and document, then Next) and Cancel then Leave. A separate, explicitly synthetic native HTTP event now completes the real render/retain/print pipeline: one `ECHO-HTTP-TEST-001` Code128 job reached `SPOOLED` with a Windows spooler job assigned, and its Echo process closed successfully. This is printer delivery evidence, not physical trigger, paper output or rescan confirmation. See the [delivery receipt](demo/docs/echo-delivery-acceptance.json). Public Maven publication and full production acceptance remain open.
The actual renderer's Code128 and leading-zero QR outputs now pass independent pixel decoding and 4x1 stock/fit checks. See the [rendering evidence and scannable test images](demo/docs/ECHO-RENDERER-ACCEPTANCE.md). This no-print check does not replace physical acceptance.
## Host composition
1. Install/configure the existing Mobile Scanners, Document Generation and retained-source Printing integration under their owner-qualified dependency tuple. Use the same backend/data source for Echo metadata and its state schema. Apply [`echo-h2.sql`](src/main/resources/db/echo-h2.sql) through host migrations; registration performs no DDL.
2. Configure Document Generation to allow `echoScan` as a source table. Publish appropriate immutable barcode templates through its ordinary definition service and isolated worker. Stock measurements are micrometres with explicit DPI. Do not generate or publish a new template on every scan.
3. `EchoPrintingDestinations` implements the destination seam from Printing's public operator authorization (`PrintingOperatorAuthorization`) and reported media profiles. `authorize` checks the current actor, printer row, device and ZPL format and pins the exact reported media profile id and revision. `plan` reauthorizes, then reads the published template revision with enforced permissions and requires its stock to equal that media. The setup step's printer field uses Printing's own table possible values (enabled and available printers); `EchoMediaChoices` is a semantic possible-value provider that returns only the sizes of the printer already selected. App-selected document dimensions do not imply changing Windows printer stock.
4. `EchoDocumentGenerator` calls Generation's public `generate` and read-only `lookupAcceptedPrint` with the exact template, revision, stock and selected media profile, under the stable key `echo:<scanId>`. `QUEUED` is returned only for a durable, available retained receipt with a job id and the same media profile. Absent or unavailable lookup evidence refuses. It never uses the side-effecting `reconcile` call or plain byte submission.
5. `EchoHost.install(instance, deployment)` composes the above from the owners' public classes: Record Files retained output and print source, `PrintingDispatch.forOwnerMediaEnforcement(source)` (the owner's explicit adoption factory), Generation with its durable audit, then Echo. Registration order matters: Printing, then Echo's `echoScan` table, then Generation, which validates its source tables. The isolated renderer, `PrintingService` and template map are deployment inputs; absent ones are refused. Grant the operator `echo.use`, ordinary mobile scanning and the owner-approved generation and print-submit permissions. **Do not grant `printing_admin` to make Echo work.** `EchoTemplatePublisher.publish` is the one-time operator action that publishes the ZPL barcode template for one reported media profile.
Supply the renderer's configured byte limit as `Deployment.maximumOutputBytes`. Both demo launchers propagate `ECHO_RENDERER_MAXIMUM_BYTES` to Generation and the renderer. Leaving Generation at its 20 MiB owner default with a 1 MiB renderer rejects every render request before the worker starts. The composed acceptance uses a bounded renderer and a nondefault 256 KiB cap. Existing six-argument `Deployment` callers retain the owner default and must only use it with a renderer that accepts that allowance.
The process begins with server-owned run state, shows printer/media setup, then loops only between the semantic SCANNER frontend and the print backend. Its native Cancel step closes the run, including cancellation during setup. Already processing or accepted work may still print; leaving prevents new admission and retains receipts. Protected framework process identity, rather than an echoed run ID alone, binds the selected printer to that process.
## Copies per scan
Setup asks **Copies per scan**: an optional ordinary integer form field, default 1, registered with core `ValueRangeBehavior` limits of 1 and 100 (inclusive, error) so the native client's `validation.minValue/maxValue` match the server. Omitted, null or blank means 1; fractions, 0, 101 and non-numbers are rejected at setup. The server validates it, pins it on the run (`echo_run.copies`, a non-null additive column that defaults existing runs to 1) and uses that pinned count for every scan until Leave. A repeated setup can confirm but not change it, and a differing scan-stage `copies` value is refused before any event row exists; the value never decides what is printed. The scanner instructions state the count when it is above one. Bounds are Printing's own: a whole number from 1 to 100, expressed by that owner as the `copies` option of **one** print job, so Echo never queues one job per copy or renders per copy, and retries and lookup reuse the same pinned count.
`EchoDocumentGenerator` builds `DocumentGenerationService.Request(..., mediaProfile, copies)` with the run's pinned count, so Generation binds it to the `echo:<scanId>` key and its printing bridge submits one job with `options.copies`; the accepted-print lookup compares the committed count with the pinned one before Echo adopts a job. This needs the Generation copies cohort (`GenerationPorts.PrintRequest`/`Request` with the appended `copies`, the request-table `copies` migration `20261102-print-copies-h2.sql`, and a Printing owner that honors `options.copies`); an owner without `supportsCopies()` refuses a count above 1 before any row, render or submit. Physical copy counts are not verified.
## Fidelity and retries
The first explicit mapping covers CODE128, QRCODE, CODE39, Data Matrix, PDF417 and Aztec scanner identifiers and their documented legacy label aliases. Leading zeros are never parsed as numbers. EAN13/EAN8/UPCA/UPCE0 are admitted only with the scanner's AIM identifier (`]E0`, EAN-8 `]E4`), a full-length value of the exact length with a valid GS1 check digit (UPC-E number system 0 only), and a document published with `EchoTemplatePublisher.publishScannerFidelity`. That separate, clearly named **scanner-fidelity** document passes the recorded scanner label and AIM identifier (`echoScan.symbology`, new nullable `echoScan.aimIdentifier`) to the renderer's scanner mode, which verifies them again and converts the value. It requires an AIM identifier on every scan, so the original plain document stays the choice for older scanners and servers; an EAN/UPC scan on a plain document, or a scan without AIM on a scanner-fidelity document, is refused before any print and the operator leaves Echo to choose the other document. The AIM column is an additive, nullable migration in `echo-h2.sql` (other databases need the equivalent); a retry is bound to the stored AIM. UNKNOWN, manual input, unsupported variants and control/binary/GS1 payloads are refused until their exact metadata/encoder profile is qualified. When the canonical scan context carries the optional mobile-scanners 1.5 evidence, Echo checks it before admission: an `aimIdentifier` other than the renderer's plain-mode identifier for that type (for example `]C1`, `]Q3`, `]A4`) is refused as a GS1/ECI/structured mode, and `rawDataBase64` must decode to exactly the ASCII bytes of the display value. Absent evidence (legacy 1.4 servers, older clients, or a Zebra read where the host did not confirm its AIM output profile by configuration read-back) is admitted as before and proves nothing, and physical AIM output on the handheld is not yet qualified; no fidelity is inferred from the display string. No symbology is inferred from value or substituted with QR/Code128.
Each canonical mobile scan has one durable `echoScan` source with the same ID and a stable `echo:<scanId>` generation request key across every run. A duplicate accepted event in the same run returns its existing job; moving that event into another run is rejected. A deliberate new trigger event with the same value is a new print intent. A changed value/type/source/timestamp cannot reuse the event. An uncertain/crashed call never automatically renders or prints again: recovery requires read-only exact owner acceptance evidence; absent/unavailable evidence remains visibly unconfirmed and the user may leave. Independent decoding and physical scanner/printer acceptance remain mandatory for any claimed barcode profile.
## Build and acceptance
From the repository root, use Java 21 and the included wrapper:
```text
./mvnw -f examples/echo/pom.xml verify
```
On Windows, run `mvnw.cmd -f examples/echo/pom.xml verify` from the repository root. From `examples/echo`, use `../../mvnw -f pom.xml verify` (Windows: `..\..\mvnw.cmd -f pom.xml verify`). Both forms use the checked-in wrapper; Checkstyle and resource paths resolve from the module directory.
This optional module is outside the root lifecycle. The POM's defaults are `qqq.version` 4.1.0-RC.21 (public), `mobile.version` and `generation.version` 0.1.0-SNAPSHOT, and `printing.version` 0.4.0-SNAPSHOT. The owner qbits (Generation and bridges, Printing, Collaboration, Record Files) are still qualified private inputs, not a fully public or released dependency cohort, so these snapshot defaults alone may not resolve or match what was verified. Copies per scan also needs the Generation copies change described above. Use a dedicated Maven repository and one coherent owner cohort; never install cross-chat snapshots over the shared cache. The source requires the narrow protected-process-identity API and current `MobileScannerActions.capture(input, appName)` API. These private artifacts are not published to a public Maven repository and are not a production handoff. The primary forge is Gitea; GitHub is a shadow/archive.
Tests: core and HTTP-fixture tests (real QQQ process state, H2 persistence and the mobile QBit's approval and binding, with synthetic destination, layout and queue fixtures) and adapter tests (controlled owner facades for exact pins, receipts, lookup, media filtering, the published scene geometry and the absent-deployment refusals). They qualify orchestration, event identity, permission boundaries and adapter mapping, not render output or physical printing. The adapter tests do not run the renderer worker.
## Runnable demo
[`demo/`](demo/README.md) is a separate optional module that composes the real owners into a startable server (tested, Checkstyle clean). Its production `EchoDemo` keeps the owner's cold bridge, which defaults to the `runsc` Docker runtime and fails closed on Windows Docker Desktop (`runc`). `TrustedLocalEchoDemo` is the explicit developer launcher over the renderer owner's public trusted-local factory (owner tests pass; the demo copies no transport) and is not a production sandbox qualification. An earlier local run on an ephemeral loopback port (November 1, historical) had no printer or template. A real combined setup now exists: one local server runs Echo, Record Files, Document Generation and Printing with a real Printing client, printer, 4x1 media and a published Echo barcode document, and a TC22 completes setup against it. Physical output remains unproven. Operator prerequisites, from its README: a Printing tenant and administration token, a Windows printing agent that reports printer media, an approved scanner enrolled through the mobile flow, published Echo-compatible ZPL templates for the reported media, chosen by the operator during process setup, and Record Files' generated-file release switch.
What remains before any print claim: a TC22 scan against the real server that produces a physical print, independent decode of the output and a rescan of Code 128 and leading-zero QR labels. Beyond that: full AIM, EAN/UPC, GS1 and raw fidelity (the six admitted types are printable ASCII only, and the rest fail explicitly as unsupported), then public immutable SDK publication, Gitea CI and distribution, and signing. The source pull request is on Gitea (PR 7); earlier GitHub references are historical.
## License packaging
Echo uses Apache License 2.0. Packaging includes the existing repository `LICENSE` and `NOTICE` unchanged under `META-INF/qqq-echo/`, alongside Echo's SQL resources. This namespace preserves dependency notices. No duplicate license source files or modified license text are maintained in the example.
## Isolated native UI fixture
`com.kingsrook.examples.echo.fixture.EchoHttpFixture` is a test-scope HTTP host using the existing demo TABLE authentication and registered QQQ semantic routes. It brands itself **Echo UI E2E — simulated printing**. The simulated printer/media and acceptance recorder exercise navigation and receipts only; they never render or print.
Launch its test classpath with one unused loopback port argument and private environment variables `ECHO_FIXTURE_TYPE=echo-ui-e2e`, `ECHO_FIXTURE_SCANNER_PASSWORD`, and `ECHO_FIXTURE_ADMIN_PASSWORD`. Use generated secrets of at least 24 characters. Accounts are `scanner` and `admin`. The process initializes a fresh random in-memory H2 database, refuses known production/test-peer ports, and emits `ECHO_UI_E2E_READY port=<port> simulatedPrinting=true`. Closing it stops only its own server. Keep its credentials in private run descriptors, never this repository.
The printer/media choices are `fixture-printer` / `fixture-4x6`, labeled “Simulated printer” / “Simulated 4 x 6 in — 300 dpi”. Standard native queries of `echoScan` and test-only `echoFixtureRun` use the scanner session and approved device credentials, retaining actor locks. The HTTP smoke covers login, real enrollment/admin approval, metadata, setup, two symbologies, same-event retry, queued receipts, row inspection and cancellation. Android device acceptance remains a separate test; none of these fixture results qualify physical output.
Retained Echo source reads use a separate read-only permission: the current authenticated actor must still have `echo.use`, and native actor row locks restrict results to that actor. Desktop source reads after leaving Echo do not reconstruct a scanner session or grant capture authority. Echo app/process/widget/capture operations still require the currently approved device. Direct source-table writes are denied. Accepted print delivery/retry also requires the printing owner's reviewed original-actor source-reauthorization policy; this table read rule alone does not qualify delayed byte release.
+29
View File
@@ -0,0 +1,29 @@
# Echo runnable demo checkpoint
Date: 2026-11-02. Scope: `examples/echo/demo/**`. Source checkpoint, not a release.
## Verified status
- Demo module `verify`: **24 tests pass, Checkstyle 0** (acceptance 3, settings 5, startup 1, object files 6, trusted-local renderer settings 9), against the real new Echo read checker and the renderer owner's TL1 factory. Parent `examples/echo` `verify`: 45 tests pass.
- The renderer owner's own tests for the TL1 trusted-local exchange pass (52 owner tests). The demo uses the owner's public factory and copies no transport.
- **Complete composed acceptance (`EchoDemoAcceptanceTest`) passes** over a fresh private file database: an authenticated, approved scanner runs the real `echoPrint` process through real Generation, Record Files retention and Printing job acceptance. Two scans (Code 128 `001234567890`, QR `QR-VALUE-0099`) give exactly two retained originals and two accepted Printing jobs on the one selected printer and media; a retry of the same event returns the same job and renders nothing again; leaving closes the run (`CLOSED`).
- **Controlled inputs, not physical output:** the renderer returns fixed synthetic ZPL built from the exact context Generation passes it, and the "printer" is a Printing device and queue enrolled through the owner's public `PrintingService` API. No Docker, agent, printer or device is touched.
- A private fresh-JVM run of the demo was observed live on a local ephemeral loopback port (take the URL from the startup log line `ECHO_DEMO_READY port=<port>`, `http://127.0.0.1:<port>`): scanner and admin native login and metadata returned 200. No printer or template exists on it, and no physical print has been attempted.
## What the demo does
`EchoDemo` extends `MobileScannersDemo` (database, `scanner`/`admin` accounts, native server) and composes Collaboration, Record Files, Printing, Document Generation and `EchoHost.install` from their public classes on one private H2 file (`WRITE_DELAY=0`). Composition runs under a scoped system context that is restored afterwards, then `QInstanceEnricher` enriches the instance, because the server only validates and the enricher registers Collaboration's record-feature permissions. `LocalFilesystemAttachmentStorage` implements the attachment owner's storage SPI over `PrivateObjectFiles` (hashed keys, temp-file-then-link, no overwrite) and adds no policy. `TrustedLocalEchoDemo` is the explicit developer launcher; the production `EchoDemo` default fails closed. See `README.md` for build, run and environment.
Authentication is the existing demo password/session module plus exact grants. `scanner`: `echo.use`, `printing_operator`, `document-generation.view|generate|print`, `record-files.view|manage`, `echoScan.attach` and the `printing_tenant` row key (replaced, never appended, on each validation); then `CollaborationSecurity.applyToSession`. `admin` additionally: `printing_admin`, `document-generation.configure|publish`. No Record Files release/configure/audit for anyone.
## Test isolation
The RDBMS connection providers are cached per backend name for the whole JVM and every composed host here names its backend `mobileDb`. The startup and acceptance tests reset them before and after each test so neither depends on test order.
## Remaining (not done, not claimed)
- Actual native Printing client (Windows agent) connection to the composed server and the current stock/media handoff.
- A physical scan, print and rescan of Code 128 and QR labels, with independent decode.
- Full AIM, EAN/UPC, GS1 and raw-payload fidelity. Today the six admitted types are printable ASCII only; EAN13/EAN8/UPCA/UPCE0, UNKNOWN, manual, control/binary and GS1 payloads fail explicitly as unsupported before admission.
- Public immutable SDK publication, Gitea CI and distribution, and signing, last. The cohort is private and unpublished. Source repository PR 7 is on Gitea; earlier GitHub references are historical.
- Not claimed: production readiness, a published Maven artifact, or any physical output.
+98
View File
@@ -0,0 +1,98 @@
# Echo runnable local demo
Optional module (`qqq-echo-demo`) that turns the Echo example into a server you can start. The library module `examples/echo` does not depend on it. It reuses `MobileScannersDemo` (database, `scanner`/`admin` accounts, native server) and adds Collaboration, Record Files, Printing, Document Generation and Echo from their public production classes. Nothing is simulated: the renderer is the owner's cold Docker bridge, output is retained by Record Files, and printing is Printing's own service. The server binds `127.0.0.1` only.
Status: the demo and Echo library tests pass with Checkstyle clean (current counts and progress: [session state](../../../docs/SESSION-STATE.md); the qqq version default is 4.1.0-RC.21 while owner qbits remain qualified private inputs). The composed acceptance test passes: an authenticated approved scanner, two scans, two retained originals and two accepted Printing jobs on one printer, an idempotent retry, and leaving closes the run. It uses a controlled synthetic renderer and a simulated Printing queue, so it is **not physical output**. The production `EchoDemo` default is unchanged and fails closed (the owner's cold bridge needs `runsc`; Windows Docker Desktop reports `runc`). A local run prints its URL in the startup log (`ECHO_DEMO_READY port=<port>`, then `http://127.0.0.1:<port>`; the port is chosen per run). Not production-ready and not a public SDK release. See `CLAUDE-HANDOFF.md` for status and what remains.
## Trusted local development launcher
`TrustedLocalEchoDemo` is a separate, explicit main for a developer machine. It starts the same server, accounts, permissions and loopback binding as `EchoDemo`, but builds the owner's `IsolatedRendererBridge(exchange.limits(), RetainedAssetReader, exchange)` over the renderer owner's supported public `TrustedLocalDockerExchange`, built from `TrustedLocalDockerExchange.configuration(...)`. This is **not** a production sandbox qualification: it has no repository digest, uses the owner's literal unqualified local qualification id, and is not gVisor. The production `EchoDemo` default is unchanged and fails closed, and neither launcher falls back to the other. The owner's exchange and its tests are the transport boundary; this demo adds no transport of its own.
The exchange requires the renderer artifact `qbit-document-generation-renderer` at a version that contains it. The demo POM's `renderer.version` property defaults to `${generation.version}`; override it for the private cohort, for example `-Drenderer.version=0.1.0-echo.gen64c9pu507.tl1`. Only the direct renderer dependency uses that property.
The launcher passes the owner factory: the Docker executable and exact image id from the environment below, a state directory `<ECHO_DEMO_ROOT>\renderer-state` (created if absent, absolute, not a symbolic link), the fixed owner id `echo-local`, the deadline, maximum bytes and pages, and a maximum concurrency of 1. Use a **fresh private `ECHO_DEMO_ROOT` for each runtime**: the journal there must belong to this process alone. At startup it calls the exchange's `reapExpired()` once, which reclaims only this owner's expired leases left by a killed process and logs a warning with the count of any it could not reclaim.
Extra environment (in addition to the shared settings below, except the `ECHO_RENDERER_*` cold-bridge variables, which this launcher does not use):
| Variable | Meaning |
| --- | --- |
| `ECHO_LOCAL_RENDERER` | Must be exactly `TRUSTED_LOCAL_DEVELOPMENT`; anything else refuses startup. |
| `ECHO_LOCAL_RENDERER_DOCKER` | Absolute path of an existing Docker executable. |
| `ECHO_LOCAL_RENDERER_IMAGE_ID` | The exact local image id, `sha256:` plus 64 lowercase hex characters. Tags and repository digests are refused. |
| `ECHO_LOCAL_RENDERER_DEADLINE_SECONDS` | Optional, 1-30, default 10. |
`ECHO_RENDERER_MAXIMUM_BYTES` (default 1048576) and `ECHO_RENDERER_MAXIMUM_PAGES` (default 1) also apply. The owner's exchange refuses the render unless the daemon reports runc, linux and exactly the configured image id. Launch exactly as in "Build and start" but with this main class:
```powershell
java -cp "examples\echo\demo\target\classes;$(Get-Content examples\echo\demo\target\classpath.txt)" com.kingsrook.examples.echo.demo.TrustedLocalEchoDemo 48123
```
The log line reports `renderer=trusted-local-development`. The unit tests (`TrustedLocalRendererSettingsTest`) never start Docker.
## Build and start
From the repository root, with the checked-in wrapper (`mvnw.cmd` on Windows) and the tuple installed in your Maven repository:
```powershell
.\mvnw.cmd -B -f examples\echo\demo\pom.xml verify
.\mvnw.cmd -B -f examples\echo\demo\pom.xml dependency:build-classpath "-Dmdep.outputFile=examples\echo\demo\target\classpath.txt"
```
Set the configuration below in the environment (never on the command line), then:
```powershell
java -cp "examples\echo\demo\target\classes;$(Get-Content examples\echo\demo\target\classpath.txt)" com.kingsrook.examples.echo.demo.EchoDemo 48123
```
The optional argument is the port; otherwise `ECHO_DEMO_PORT`. There is no default port. Ports 1024-65535 are accepted except the protected local runtimes listed in `EchoDemoSettings`. `mvnw.cmd -f examples\echo\demo\pom.xml exec:java` is equivalent when the environment is set.
## Configuration (environment only)
Required:
| Variable | Meaning |
| --- | --- |
| `ECHO_DEMO_ROOT` | Existing private absolute directory (not a symlink). The H2 file database lives in `<root>\db\echo`. |
| `ECHO_DEMO_PORT` | Loopback port, unless passed as the argument. |
| `ECHO_PRINTING_TENANT` | Printing tenant label. Operator sessions are locked to it. |
| `ECHO_PRINTING_ADMIN_TOKEN` | 32-1024 character bearer token for Printing's administration routes. |
| `ECHO_RENDERER_DOCKER` | Absolute path of the Docker executable. |
| `ECHO_RENDERER_IMAGE` | Digest-pinned image `repo@sha256:...` (the qualified catalog-9 cold image). |
| `ECHO_RENDERER_IMAGE_ID` | `sha256:...` image id. |
| `ECHO_RENDERER_QUALIFICATION_ID` | Owner qualification evidence id. |
| `ECHO_RENDERER_REQUIRED_QUALIFICATION_ID` | Must equal the evidence id above. |
| `ECHO_RENDERER_OWNER_ID` | Deployment owner id for renderer admission. |
| `ECHO_RENDERER_STATE_DIR` | Existing private absolute directory for renderer state. |
| `MOBILE_DEMO_SCANNER_PASSWORD`, `MOBILE_DEMO_ADMIN_PASSWORD` | First startup only, 12+ characters, as in the mobile demo. Existing accounts are never changed. |
Optional: `ECHO_RENDERER_DEADLINE_SECONDS` (10, max 30), `ECHO_RENDERER_MAXIMUM_BYTES` (1048576), `ECHO_RENDERER_MAXIMUM_PAGES` (1), `MOBILE_BRAND_*` (as the mobile demo), `ECHO_RELEASE_GENERATED_FILES=true`.
Retained files are stored by `LocalFilesystemAttachmentStorage` (the attachment owner's own storage SPI) under `<root>\objects`. Keys are hashed into file names, writes are temp-file-then-link and never replace an object. It adds no policy: Record Files and the attachment owner still decide quarantine, authorization and lifecycle. Single host, demo only.
`ECHO_RELEASE_GENERATED_FILES=true` sets Record Files' generated-file quarantine policy to off. Without it generated output is quarantined, Echo sees it as unavailable, and every print is refused. Turn it on only if you trust every producer allowed to retain generated output in this deployment.
The legacy `ECHO_TEMPLATE_ID`, `ECHO_TEMPLATE_MEDIA_ID` and `ECHO_TEMPLATE_MEDIA_REVISION` pin no longer selects a document and is ignored. The operator chooses the document at the start of every run from the published templates described below.
## Accounts and permissions
`scanner` receives exactly: `echo.use`, `printing_operator`, `document-generation.view|generate|print`, `record-files.view|manage` (manage is the owner's intake permission) and the `printing_tenant` row key. `admin` receives the same plus `printing_admin` (Printing's native pairing/approval/setup processes) and `document-generation.configure|publish` to prepare templates. The scanner never gets these, and nobody gets Record Files release/configure/audit. Scans still need an enrolled and approved device, so use the existing mobile enrollment flow.
## Operator order
1. Start the host. Enroll the Windows printing agent against `/printing/v1` and let it report printer media.
2. As `admin`, publish one or more ZPL templates for the reported media in Document Generation (scene per `EchoTemplatePublisher`; give each its own name with `EchoTemplatePublisher.publish(definitions, media, name)`). Each published, compatible template becomes a separate Document choice.
3. As the scanner, open Echo: choose the printer, then the paper or label size, then the Document (offered only from published `echoScan` templates whose stock equals that size and whose scene draws the scanned value and type dynamically). The three choices are fixed for the run; leave Echo and reopen it to change them.
## Echo scan photos (mobile Record Files)
The host also installs the existing `qbit-mobile-scanners-record-files` integration, so an approved scanner can attach photos to one of its own retained Echo scans. Nothing new is served: Record Files' registered, versioned `record-files` upload API (`/qqq/v1/qbit/record-files/...`) and the independently versioned `mobile-record-files` widget vocabulary are advertised in `/qqq/v1/version`, behind the same native auth and device guards.
- **Process:** `echoScanPhotos` ("Echo scan photos"), listed in the Echo app. It requires `echo.use`, `echoScan.attach` and a verified approved device. The first step lists the actor's own scans (the table's record security lock decides; label is the barcode and type, never the record id). The server then re-reads the chosen scan through the collaboration owner's visibility and `attach` checks and builds the `RECORD_FILES_CAPTURE` widget from the record that check returned, on the fixed `echoScan` table. The client never supplies a table or record reference, and rendering the widget directly names no parent. Record Files authorizes the parent again on every upload operation.
- **Policy:** JPEG/PNG only, 20 MiB per file, up to four files, 256 KiB chunks, private staging under the demo's `objects` directory (separate staging namespace). Retained originals follow the existing Record Files quarantine setting.
- **Needs a real scan first:** the list is empty until the scanner has Echo scans. Nothing is seeded outside this module's tests.
- **Existing databases:** the Record Files upload tables are added by the same tolerated-reapply schema step; plan the migration for a database from another version.
- **Not claimed:** no real photo upload, native capture or device run has been verified here. That needs the Android host against this server.
## Existing databases
Startup applies each owner schema with "already exists" tolerated, plus the four shipped Generation H2 migrations (output filenames, warm batch completion, print media profile and print copies, the last adding the nullable `copies` column where null means one copy). It does not migrate a database created by a different version. Plan migration separately for anything beyond a fresh demo root.
@@ -0,0 +1,32 @@
# Echo barcode rendering acceptance
On November 2, 2026, the real renderer produced the two labels below from the local server's published Echo template revision 1. ZXing 3.5.3 independently decoded the emitted raster pixels, preserving both the symbology and the exact value. The live server's published revision and stock were checked again after rendering and matched the tested descriptor.
| Input | Decoded format | Decoded value | Raster size |
| --- | --- | --- | --- |
| Code128 | `CODE_128` | `QQQ-TEST-123456` | 600 × 180 dots |
| QR | `QR_CODE` | `001234567890` | 208 × 205 dots, including byte padding |
The stock is 101600 × 25400 microns (4 × 1 inches) at 300 dpi: a 1200 × 300 dot label. Both graphics start at (48, 48), fit inside the published printable margins and fit rectangle, and contain the requested barcode. The checks validated the ZPL envelope, raster byte counts, output hashes, placement, dimensions, decoded format and string-exact value. The QR value was never converted to a number.
The renderer was the owner's `IsolatedRendererBridge` over its explicit trusted-local Docker exchange, using image `sha256:c124af0d890f1bb206422e97a1f997d863ea6a8b2225cecf32be726a252c9ee4`. This is development verification, **not production sandbox qualification**. The renderer received fixed test record contexts; no hardware scan was fabricated and no printer job was sent.
The [machine-readable receipt](echo-renderer-acceptance.json) records the original ZPL hashes and all checks. The previews below were reconstructed from those ZPL raster bytes, not generated separately from the expected values.
## Code128 test input
Open [the full-size Code128 image](barcodes/code128-preview.png) and scan it once in Echo for the physical test.
![Code128 label containing QQQ-TEST-123456](barcodes/code128-preview.png)
## Leading-zero QR test input
Open [the full-size QR image](barcodes/qr-preview.png) when testing leading-zero preservation.
![QR label containing 001234567890](barcodes/qr-preview.png)
## Remaining acceptance
The later [delivery check](echo-delivery-acceptance.json) uses a different, explicitly synthetic HTTP event (`ECHO-HTTP-TEST-001`). It traverses the real Echo process, renderer, retained output and installed Printing agent. Exactly one matching job reached `SPOOLED`, with a Windows spooler job assigned; native Cancel then closed Echo. It also exposed and verified the fix for the Generation/renderer byte-limit mismatch. This additional evidence does not turn either fixture into a physical handheld scan or confirm paper output.
These two decoded artifacts prove these rendering cases. They do not prove physical trigger capture, printer delivery, printed-label readability, other inputs or symbologies, or production release readiness. Complete the TC22 scan → server print → printed-label rescan, deliberate repeated scans and Leave/error recovery. Keep the remaining device/provider, barcode-fidelity, dependency-publication and primary CI gates open.
@@ -0,0 +1,26 @@
# Running all qbits in the local test server
One server, one database: the existing `MobileScannersDemo` data file is opened in place by the combined `EchoDemo` (Mobile Scanners/Echo, Collaboration/Record Files, Document Generation, Printing). No second server, network bridge or reset.
## Environment contract (public values only; no secrets are read from or written to this file)
Use the existing `TrustedLocalEchoDemo` (or `EchoDemo`) launcher plus the normal Echo variables, and add:
- `ECHO_EXISTING_DATABASE` - absolute path of the existing H2 file, ending `.mv.db` (for the normal server: `R:\Git.Local\QRunIO\qbit-mobile-scanners\demo\data\mobile.mv.db`). Setting it is the explicit existing-local-server mode.
- `ECHO_DEMO_PORT` - `57832` is accepted only in this mode. Every other protected port (including peer Printing 18081) is still refused, and fresh/private demos still refuse 57832.
- `ECHO_DEMO_ROOT` - existing absolute directory for Record Files objects and renderer state (`objects`, `renderer-state`); the database is NOT taken from it in this mode.
- Unchanged: `ECHO_PRINTING_TENANT`, `ECHO_PRINTING_ADMIN_TOKEN` (32+ chars), `ECHO_RELEASE_GENERATED_FILES`, renderer variables (`ECHO_LOCAL_RENDERER*` or `ECHO_RENDERER_*`), `MOBILE_BRAND_*`.
## Behavior
- The database is opened with `IFEXISTS=TRUE;DATABASE_TO_UPPER=FALSE;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000;WRITE_DELAY=0`. A missing file, a name not ending `.mv.db`, a relative path, a directory, a symbolic link or a reparse point is refused at startup and nothing is created.
- Startup adds only missing schema (`CREATE TABLE IF NOT EXISTS`, owner migrations). Accounts, password hashes, devices, device credentials, approvals and scans are never seeded, changed or reset; the `MOBILE_DEMO_*_PASSWORD` values are ignored in this mode.
- Authentication remains `EchoDemoAuthentication` over the existing demo accounts, native provider and device authorization. Restarts are idempotent.
- Requires the same H2 runtime (2.2.224) that wrote the file.
## Test
`EchoDemoAdoptionTest` uses temporary directories and fresh ports: baseline `MobileScannersDemo` with accounts, an approved device and a scan; the same file opened through `EchoDemo`; hashes, credentials, approval and scans unchanged; old logins work and new passwords do not; the one `QInstance` exposes Mobile, Echo, Record Files, Collaboration, Generation and Printing; idempotent restart; missing/wrong-suffix/relative/directory refusal; port guards. Both adoption tests passed in the full 26-test demo verification, with zero Checkstyle violations.
## Local development
Stop the existing development server and start the combined launcher with the variables above. This is a normal greenfield development restart. Do not run two servers against the same file concurrently (H2 file lock).
The TC22's existing `adb reverse tcp:8080 tcp:57832` connection sends the device-side `Host: 127.0.0.1:8080`. The loopback hostname check accepts a valid forwarded port while retaining exact same-origin and cross-site request checks. The scanner account still requires its approved device proof before Echo appears in its app catalog.
The combined server now responds on 57832 with API 1.10, Mobile Scanners 1.4 and Printing 2.3. Both original accounts authenticate, and authenticated metadata includes Printing, Document Generation and Record Files. The remaining demo acceptance is the installed print client's connection, a compatible published document, and physical scan/print/rescan on the TC22.
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

@@ -0,0 +1,38 @@
{
"checkedUtc": "2026-11-02T15:57:49.4437968Z",
"scope": "Explicitly synthetic native HTTP acceptance; real renderer, retained files, installed print agent and printer spooler",
"source": "synthetic-http-acceptance",
"testValue": "ECHO-HTTP-TEST-001",
"symbology": "LABEL-TYPE-CODE128",
"format": "ZPL",
"scanRows": 1,
"jobRows": 1,
"jobStatus": "SPOOLED",
"spoolerAssigned": true,
"processClosed": true,
"physicalTriggerConfirmed": false,
"paperOutputConfirmed": false,
"printedLabelRescanConfirmed": false,
"stock": {
"heightMicrons": 25400,
"dpi": 300,
"widthMicrons": 101600
},
"configurationFix": {
"beforeGenerationMaximumBytes": 20971520,
"generationMaximumBytes": 1048576,
"greenFailures": 0,
"greenSkips": 0,
"redFailures": 3,
"configuredRendererMaximumBytes": 1048576,
"greenErrors": 0,
"greenTests": 74,
"redTests": 4
},
"failedPriorEvent": {
"printJobCreated": false,
"retried": false,
"state": "ATTENTION",
"cancelSucceeded": true
}
}
@@ -0,0 +1,117 @@
{
"scope": "Two fixture renderings only; no physical scan or print and no production sandbox qualification",
"render": {
"renderer": "TRUSTED_LOCAL_DEVELOPMENT_UNQUALIFIED",
"imageId": "sha256:c124af0d890f1bb206422e97a1f997d863ea6a8b2225cecf32be726a252c9ee4",
"revisionId": 1,
"templateId": 1,
"sourceTable": "echoScan",
"stock": {
"dpi": 300,
"heightMicrons": 25400,
"leftMicrons": 3000,
"topMicrons": 3000,
"widthMicrons": 101600,
"rightMicrons": 3000,
"bottomMicrons": 3000
},
"cases": [
{
"file": "code128.zpl",
"inputType": "code128",
"inputValue": "QQQ-TEST-123456",
"byteCount": 27064,
"sha256": "e4ac771712fab2e8e14f71325e7053fdbdaa934f12513685836cb57f9b7ced73",
"durationMillis": 3345,
"zplEnvelopeStartsXaEndsXz": true
},
{
"file": "qr.zpl",
"inputType": "qr",
"inputValue": "001234567890",
"byteCount": 10722,
"sha256": "25cfac096f54b7b4baf21db2e7f2abcd68285f27775fad15fa594d10983734d0",
"durationMillis": 3481,
"zplEnvelopeStartsXaEndsXz": true
}
],
"physicalScannerQualified": false,
"physicalPrintingQualified": false
},
"decode": {
"decoder": "ZXing MultiFormatReader over reconstructed ZPL ^GFA pixels",
"publishedRevisionId": 1,
"stockChecks": {
"stockIs4x1At300dpi": true,
"receiptRevisionMatchesPublished": true,
"widthDotsExact": true,
"heightDotsExact": true
},
"cases": [
{
"file": "code128.zpl",
"artifactSha256": "e4ac771712fab2e8e14f71325e7053fdbdaa934f12513685836cb57f9b7ced73",
"artifactBytes": 27064,
"decodedFormat": "CODE_128",
"decodedText": "QQQ-TEST-123456",
"labelWidthDots": 1200,
"labelHeightDots": 300,
"graphicWidthDots": 600,
"graphicHeightDots": 180,
"originXDots": 48,
"originYDots": 48,
"checks": {
"sha256MatchesRenderReceipt": true,
"exactEnvelopeSingleFoSingleUncompressedGfa": true,
"printWidthAndLabelLengthMatchStock": true,
"totalEqualsDataBytes": true,
"bytesPerRowPositiveAndDividesTotal": true,
"hexLengthIsTwiceDataBytes": true,
"graphicWithinLabel": true,
"originMatchesPublishedScene": true,
"withinPrintableLeftTop": true,
"withinPrintableRightBottom": true,
"withinRequestedFit": true,
"decodedFormatMatches": true,
"decodedTextStringExact": true
}
},
{
"file": "qr.zpl",
"artifactSha256": "25cfac096f54b7b4baf21db2e7f2abcd68285f27775fad15fa594d10983734d0",
"artifactBytes": 10722,
"decodedFormat": "QR_CODE",
"decodedText": "001234567890",
"labelWidthDots": 1200,
"labelHeightDots": 300,
"graphicWidthDots": 208,
"graphicHeightDots": 205,
"originXDots": 48,
"originYDots": 48,
"checks": {
"sha256MatchesRenderReceipt": true,
"exactEnvelopeSingleFoSingleUncompressedGfa": true,
"printWidthAndLabelLengthMatchStock": true,
"totalEqualsDataBytes": true,
"bytesPerRowPositiveAndDividesTotal": true,
"hexLengthIsTwiceDataBytes": true,
"graphicWithinLabel": true,
"originMatchesPublishedScene": true,
"withinPrintableLeftTop": true,
"withinPrintableRightBottom": true,
"withinRequestedFit": true,
"decodedFormatMatches": true,
"decodedTextStringExact": true
}
}
],
"physicalScannerQualified": false,
"physicalPrintingQualified": false
},
"liveRevisionCheck": {
"checkedUtc": "2026-11-02T14:52:40.9062446Z",
"publishedPointerMatches": true,
"immutableRevisionFieldsMatch": true,
"printJobCount": 0
}
}
+55
View File
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Copyright (c) 2026 Kingsrook, LLC. SPDX-License-Identifier: Apache-2.0 -->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.kingsrook.examples</groupId>
<artifactId>qqq-echo-demo</artifactId>
<version>0.1.0-SNAPSHOT</version>
<name>QQQ Echo runnable local demo</name>
<licenses>
<license>
<name>Apache License, Version 2.0</name>
<url>https://www.apache.org/licenses/LICENSE-2.0</url>
<distribution>repo</distribution>
</license>
</licenses>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<qqq.version>4.1.0-RC.21</qqq.version>
<mobile.version>0.1.0-SNAPSHOT</mobile.version>
<echo.version>0.1.0-SNAPSHOT</echo.version>
<generation.version>0.1.0-SNAPSHOT</generation.version>
<renderer.version>${generation.version}</renderer.version>
<printing.version>0.4.0-SNAPSHOT</printing.version>
<recordfiles.version>0.1.0-SNAPSHOT</recordfiles.version>
<mobilerecordfiles.version>0.1.0-SNAPSHOT</mobilerecordfiles.version>
<collaboration.version>0.1.0-record-files-SNAPSHOT</collaboration.version>
</properties>
<dependencyManagement><dependencies>
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-bom-pom</artifactId><version>${qqq.version}</version><type>pom</type><scope>import</scope></dependency>
</dependencies></dependencyManagement>
<dependencies>
<dependency><groupId>com.kingsrook.examples</groupId><artifactId>qqq-echo</artifactId><version>${echo.version}</version></dependency>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-mobile-scanners-demo</artifactId><version>${mobile.version}</version></dependency>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-document-generation-renderer</artifactId><version>${renderer.version}</version></dependency>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-document-generation-record-files</artifactId><version>${generation.version}</version></dependency>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-document-generation-printing</artifactId><version>${generation.version}</version></dependency>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-printing</artifactId><version>${printing.version}</version></dependency>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-record-files</artifactId><version>${recordfiles.version}</version></dependency>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-mobile-scanners-record-files</artifactId><version>${mobilerecordfiles.version}</version></dependency>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-collaboration</artifactId><version>${collaboration.version}</version></dependency>
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-module-rdbms</artifactId></dependency>
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-frontend-next</artifactId><version>1.0.0-RC.35</version></dependency>
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><version>2.2.224</version></dependency>
<dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter-api</artifactId><version>6.0.2</version><scope>test</scope></dependency>
<dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter-engine</artifactId><version>6.0.2</version><scope>test</scope></dependency>
</dependencies>
<build><plugins>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.13.0</version><configuration><showWarnings>true</showWarnings><failOnWarning>true</failOnWarning><proc>none</proc><compilerArgs><arg>-Xlint:deprecation</arg><arg>-Xlint:unchecked</arg><arg>-Werror</arg></compilerArgs></configuration></plugin>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId><version>3.5.2</version><configuration><systemPropertyVariables><qqq.logger.logSessionId.disabled>true</qqq.logger.logSessionId.disabled></systemPropertyVariables></configuration></plugin>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-dependency-plugin</artifactId><version>3.8.1</version></plugin>
<plugin><groupId>org.codehaus.mojo</groupId><artifactId>exec-maven-plugin</artifactId><version>3.5.0</version><configuration><mainClass>com.kingsrook.examples.echo.demo.EchoDemo</mainClass></configuration></plugin>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-checkstyle-plugin</artifactId><version>3.6.0</version><dependencies><dependency><groupId>com.puppycrawl.tools</groupId><artifactId>checkstyle</artifactId><version>14.3.0</version></dependency></dependencies><configuration><configLocation>${project.basedir}/../../../checkstyle/checkstyle.xml</configLocation><includeTestSourceDirectory>true</includeTestSourceDirectory><failOnViolation>true</failOnViolation><violationSeverity>warning</violationSeverity></configuration><executions><execution><phase>validate</phase><goals><goal>check</goal></goals></execution></executions></plugin>
</plugins></build>
</project>
@@ -0,0 +1,524 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo.demo;
import javax.sql.DataSource;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.security.MessageDigest;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import com.kingsrook.examples.echo.EchoHost;
import com.kingsrook.examples.echo.EchoQBitProducer;
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
import com.kingsrook.qbits.collaboration.CollaborationQBitProducer;
import com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadJournalTable;
import com.kingsrook.qbits.documentgeneration.DocumentGenerationQBitConfig;
import com.kingsrook.qbits.documentgeneration.DocumentGenerationSchema;
import com.kingsrook.qbits.documentgeneration.GenerationPorts;
import com.kingsrook.qbits.documentgeneration.recordfiles.RetainedAssetReader;
import com.kingsrook.qbits.documentgeneration.renderer.IsolatedRendererBridge;
import com.kingsrook.qbits.mobilescanners.MobileScannersRouteProvider;
import com.kingsrook.qbits.mobilescanners.demo.MobileScannersDemo;
import com.kingsrook.qbits.mobilescanners.recordfiles.MobileRecordFilesContract;
import com.kingsrook.qbits.printing.PrintingRealtimeRoutes;
import com.kingsrook.qbits.printing.PrintingRoutes;
import com.kingsrook.qbits.printing.PrintingService;
import com.kingsrook.qbits.recordfiles.RecordFilesQBitConfig;
import com.kingsrook.qbits.recordfiles.RecordFilesQBitProducer;
import com.kingsrook.qbits.recordfiles.RecordFilesSchema;
import com.kingsrook.qbits.recordfiles.lifecycle.RecordFilesAttachmentLifecycle;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer;
import io.javalin.http.Context;
import io.javalin.http.ForbiddenResponse;
import org.apache.logging.log4j.LogManager;
import org.h2.jdbcx.JdbcDataSource;
/*******************************************************************************
** Runnable local Echo host. It reuses the mobile demo's database, accounts and
** native server, then composes Collaboration, Record Files, Printing, Document
** Generation and Echo from their public production classes. The renderer is the
** owner's cold Docker bridge from explicit deployment configuration; nothing is
** simulated, and the server is bound to loopback only.
*******************************************************************************/
public class EchoDemo extends MobileScannersDemo implements AutoCloseable
{
static final String BACKEND = "mobileDb";
private static final Set<Integer> TOLERATED_REAPPLY = Set.of(42101, 42111, 42121, 90045);
static final List<String> GENERATION_MIGRATIONS = List.of("20261031-output-filenames-h2.sql", "20261031-warm-batch-completion-h2.sql",
"20261101-print-media-profile-h2.sql", "20261102-print-copies-h2.sql");
private final EchoDemoSettings settings;
private final GenerationPorts.IsolatedRenderer renderer;
private final Integer maximumOutputBytes;
private final DataSource dataSource;
private final PrintingService printing;
private PrintingRealtimeRoutes realtime;
private QApplicationJavalinServer server;
private boolean composed;
/***************************************************************************
** Production: the renderer is always the owner's cold bridge built from the
** explicit deployment configuration, reading assets through Record Files.
***************************************************************************/
public EchoDemo(EchoDemoSettings settings)
{
this(settings, bridge(settings), settings.renderer().maximumBytes());
}
/***************************************************************************
** Boundary tests may supply a refusing renderer; no other part is replaceable.
** Generation keeps the owner's default output cap.
***************************************************************************/
EchoDemo(EchoDemoSettings settings, GenerationPorts.IsolatedRenderer renderer)
{
this(settings, renderer, null);
}
/***************************************************************************
** The renderer's own configured output cap is the cap Generation requests, so
** Echo never asks for a larger allowance than the configured renderer cap.
***************************************************************************/
EchoDemo(EchoDemoSettings settings, GenerationPorts.IsolatedRenderer renderer, Integer maximumOutputBytes)
{
super(jdbcUrl(settings));
this.settings = settings;
this.renderer = java.util.Objects.requireNonNull(renderer);
this.maximumOutputBytes = maximumOutputBytes;
JdbcDataSource source = new JdbcDataSource();
source.setURL(jdbcUrl(settings));
source.setUser("sa");
dataSource = source;
printing = new PrintingService(dataSource, settings.tenant());
}
/***************************************************************************
** The host's own Printing service, so setup and tests use its public API.
***************************************************************************/
PrintingService printing()
{
return printing;
}
/***************************************************************************
** Owner factory only; there is no fallback renderer class.
***************************************************************************/
private static GenerationPorts.IsolatedRenderer bridge(EchoDemoSettings settings)
{
if(settings.renderer() == null)
{
throw new IllegalArgumentException("The ECHO_RENDERER_* deployment configuration is required");
}
return new IsolatedRendererBridge(settings.renderer(), new RetainedAssetReader());
}
/***************************************************************************
** One private file database. Generation requires file H2 with WRITE_DELAY=0.
***************************************************************************/
static String jdbcUrl(EchoDemoSettings settings)
{
if(settings.existingDatabase() != null)
{
// explicit existing-local-server mode: open that exact file, never create it, same session settings
return "jdbc:h2:file:" + settings.existingDatabaseBase() + ";IFEXISTS=TRUE;DATABASE_TO_UPPER=FALSE;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000;WRITE_DELAY=0";
}
return "jdbc:h2:file:" + settings.root().resolve("db").resolve("echo").toAbsolutePath().normalize().toString().replace('\\', '/')
+ ";DATABASE_TO_UPPER=FALSE;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000;WRITE_DELAY=0";
}
/***************************************************************************
** The mobile demo's instance, composed once with every owner QBit and Echo.
***************************************************************************/
@Override
public QInstance defineQInstance() throws QException
{
QInstance instance = super.defineQInstance();
if(!composed)
{
// Owner facades (RecordFilesPrintSource -> AttachmentsService) read the installed QBits from QContext
// while EchoHost.install constructs them. Scope the system context to this one composition step and
// restore whatever was there; no request ever sees it.
QInstance previousInstance = QContext.getQInstance();
QSession previousSession = QContext.getQSession();
QContext.init(instance, new QSystemUserSession());
try
{
compose(instance);
}
finally
{
if(previousInstance != null)
{
QContext.init(previousInstance, previousSession);
}
else
{
QContext.clear();
}
}
composed = true;
}
return instance;
}
/***************************************************************************
** Order matters: Collaboration owns attachments, Echo's source table must
** exist before Record Files names it as a retained parent.
***************************************************************************/
private void compose(QInstance instance) throws QException
{
EchoDemoAuthentication.configure(settings.tenant());
instance.getAuthentication().withModuleCodeReference(new QCodeReference(EchoDemoAuthentication.class));
try
{
LocalFilesystemAttachmentStorage.configure(Files.createDirectories(settings.root().resolve("objects")));
}
catch(java.io.IOException failure)
{
throw new QException("The private object directory is unavailable.", failure);
}
CollaborationQBitConfig collaboration = new CollaborationQBitConfig().withBackendName(BACKEND).withDefaultFeatures(RecordFeature.values())
.withAttachmentLifecycle(new QCodeReference(RecordFilesAttachmentLifecycle.class))
.withAttachmentStorage(new QCodeReference(LocalFilesystemAttachmentStorage.class));
new CollaborationQBitProducer().withQBitConfig(collaboration).produce(instance);
// Documents are the operator's published, Echo-compatible templates, discovered per run; the legacy
// ECHO_TEMPLATE_* host pin no longer selects one.
EchoHost.install(instance, new EchoHost.Deployment(BACKEND, BACKEND, BACKEND, dataSource, printing, renderer, maximumOutputBytes));
RecordFilesQBitConfig recordFiles = new RecordFilesQBitConfig().withBackendName(BACKEND).withParentTables(List.of(EchoQBitProducer.SCAN_TABLE))
.withQuarantineGeneratedFiles(settings.releaseGeneratedFiles() ? Boolean.FALSE : null)
.withUploadConfiguration(EchoPhotoCapture.uploadConfiguration());
new RecordFilesQBitProducer().withQBitConfig(recordFiles).produce(instance);
// Configuring uploads makes the producer register the versioned record-files extension; the mobile widget
// vocabulary and the Echo photo process are added through the same ordinary metadata, never a custom route.
MobileRecordFilesContract.register(instance);
EchoPhotoCapture.register(instance);
// The server only validates the instance, it never enriches it. Enrichment (after every QBit) is what
// registers Collaboration's record-feature permissions (QInstanceEnricher -> RecordFeaturePermissionsRegistrar).
new QInstanceEnricher(instance).enrich();
}
/***************************************************************************
** Mobile schema and the two seeded accounts come from the existing demo; the
** owners' schemas are then applied, then Generation's additive migrations (the
** newest adds the nullable print `copies` column, null meaning one copy) so an
** existing development database is current before any request is read. An
** already-present object is skipped and anything else fails startup.
***************************************************************************/
@Override
public void initializeDatabase(String scannerPassword, String adminPassword) throws Exception
{
if(settings.existingDatabase() == null)
{
Files.createDirectories(settings.root().resolve("db"));
super.initializeDatabase(scannerPassword, adminPassword);
}
else
{
// adopting existing data: add only missing schema; accounts, hashes, devices and scans are never seeded or changed
super.initializeDatabase(null, null);
}
QInstance instance = defineQInstance();
printing.initialize();
try(Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement())
{
script(statement, resource("/db/echo-h2.sql"));
script(statement, DocumentGenerationSchema.postgresScript(config(instance, DocumentGenerationQBitConfig.class)).replace("TIMESTAMPTZ", "TIMESTAMP"));
for(String migration : GENERATION_MIGRATIONS)
{
script(statement, resource("/db/migrations/" + migration));
}
collaborationTables(statement, instance);
script(statement, UploadJournalTable.h2Script());
script(statement, RecordFilesSchema.h2Script(config(instance, RecordFilesQBitConfig.class)));
}
}
/***************************************************************************
** Resolve one installed QBit configuration, never a copy that could differ.
***************************************************************************/
private static <T> T config(QInstance instance, Class<T> type) throws QException
{
return instance.getQBits().values().stream().map(bit -> bit.getConfig()).filter(type::isInstance).map(type::cast).findFirst()
.orElseThrow(() -> new QException("A required QBit is not installed."));
}
/***************************************************************************
** Collaboration tables use the same metadata-derived H2 DDL as the Record
** Files owner's fixture; an owner-shipped script should replace it.
***************************************************************************/
private static void collaborationTables(Statement statement, QInstance instance) throws SQLException
{
for(QTableMetaData table : instance.getTables().values())
{
if(!table.getName().startsWith("collaboration_") || UploadJournalTable.NAME.equals(table.getName()))
{
continue;
}
List<String> columns = new java.util.ArrayList<>();
for(QFieldMetaData field : table.getFields().values())
{
String type = field.getName().equals(table.getPrimaryKeyField()) ? "INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY" : switch(field.getType())
{
case INTEGER -> "INTEGER";
case LONG -> "BIGINT";
case BOOLEAN -> "BOOLEAN";
case DATE_TIME -> "TIMESTAMP";
case DATE -> "DATE";
case DECIMAL -> "DECIMAL(30,10)";
default -> "VARCHAR(100000)";
};
columns.add("\"" + physical(field) + "\" " + type);
}
apply(statement, "CREATE TABLE IF NOT EXISTS \"" + table.getName() + "\" (" + String.join(",", columns) + ")");
int index = 0;
if(table.getUniqueKeys() != null)
{
for(var key : table.getUniqueKeys())
{
List<String> names = key.getFieldNames().stream().map(name -> "\"" + physical(table.getField(name)) + "\"").toList();
apply(statement, "CREATE UNIQUE INDEX IF NOT EXISTS uq_" + table.getName() + "_" + (++index) + " ON \"" + table.getName() + "\" (" + String.join(",", names) + ")");
}
}
}
}
/***************************************************************************
** The column name QQQ will use for a field.
***************************************************************************/
private static String physical(QFieldMetaData field)
{
return field.getBackendName() == null ? field.getName() : field.getBackendName();
}
/***************************************************************************
** Read one owner resource from the classpath.
***************************************************************************/
private static String resource(String name) throws java.io.IOException
{
try(InputStream stream = EchoDemo.class.getResourceAsStream(name))
{
if(stream == null)
{
throw new IllegalStateException("Owner schema resource is missing: " + name);
}
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
}
}
/***************************************************************************
** Run each statement of an owner script.
***************************************************************************/
private static void script(Statement statement, String script) throws SQLException
{
for(String sql : script.replaceAll("(?m)^--.*$", "").split(";"))
{
if(!sql.isBlank())
{
apply(statement, sql);
}
}
}
/***************************************************************************
** Skip only an object that already exists (table, index, column, constraint).
***************************************************************************/
private static void apply(Statement statement, String sql) throws SQLException
{
try
{
statement.execute(sql);
}
catch(SQLException failure)
{
if(!TOLERATED_REAPPLY.contains(failure.getErrorCode()))
{
throw failure;
}
}
}
/***************************************************************************
** Loopback only. Printing's agent and administration routes share this
** server with the mobile routes; the operator token guards administration.
***************************************************************************/
@Override
public QApplicationJavalinServer start(Integer port) throws Exception
{
var policy = printing.realtimePolicy();
long uploadBodyLimit = ((Math.max(policy.get("maxPdfBytes"), policy.get("maxZplBytes")) + 2) / 3) * 4 + 65536;
PrintingRoutes routes = new PrintingRoutes(printing, context -> MessageDigest.isEqual(settings.adminToken().getBytes(StandardCharsets.UTF_8),
PrintingRoutes.bearer(context).getBytes(StandardCharsets.UTF_8)), PrintingRoutes.LegacyEnrollment.DISABLED);
realtime = new PrintingRealtimeRoutes(printing);
server = new QApplicationJavalinServer(this).withPort(port).withServeFrontendNext(true).withServeLegacyUnversionedMiddlewareAPI(false)
.withAdditionalRouteProviders(List.of(new MobileScannersRouteProvider(defineQInstance()), routes, realtime))
.withJavalinConfigCustomizer(config ->
{
config.jetty.host = "127.0.0.1";
config.http.maxRequestSize = uploadBodyLimit;
config.routes.before(EchoDemo::guardOrigin);
});
server.start();
return server;
}
/***************************************************************************
** Reject DNS rebinding and cross-site cookie mutations, as Printing's own
** local host does. Explicit API credentials without cookies stay usable.
***************************************************************************/
static void guardOrigin(Context context)
{
String refusal = refusal(context.header("Host"), context.header("Origin"), context.header("Sec-Fetch-Site"), context.method().name(),
context.cookie("sessionUUID") != null || context.cookie("sessionId") != null);
if(refusal != null)
{
throw new ForbiddenResponse(refusal);
}
}
/***************************************************************************
** The guard decision, null when the request is allowed. The Host must be the
** literal name localhost or 127.0.0.1 with an explicit port from 1 to 65535.
** That port need not equal the listening port: the native client reaches this
** loopback-only server through USB forwarding (adb reverse tcp:8080 ->
** tcp:57832) and sends the device-side Host 127.0.0.1:8080. A DNS-rebinding
** request carries an attacker's host name, never a literal loopback name, so
** the literal-name check, not the port, is what stops it. Userinfo, paths,
** queries, other names, IPv6 and malformed ports are all refused. No
** forwarding headers are consulted. Origin must be exactly http://<Host>.
***************************************************************************/
static String refusal(String host, String origin, String fetchSite, String method, boolean cookie)
{
if(!isLoopbackAuthority(host))
{
return "The Echo demo accepts only loopback host names";
}
if(origin != null && !origin.equals("http://" + host))
{
return "Cross-origin access to the Echo demo is not allowed";
}
if("cross-site".equalsIgnoreCase(fetchSite))
{
return "Cross-site access to the Echo demo is not allowed";
}
if(!Set.of("GET", "HEAD", "OPTIONS").contains(method) && cookie && origin == null)
{
return "Cookie-authenticated changes require a same-origin request";
}
return null;
}
/***************************************************************************
** Literal loopback name and an explicit port with no leading zero.
***************************************************************************/
static boolean isLoopbackAuthority(String host)
{
if(host == null || !host.toLowerCase(Locale.ROOT).matches("(localhost|127\\.0\\.0\\.1):[1-9][0-9]{0,4}"))
{
return false;
}
return Integer.parseInt(host.substring(host.indexOf(':') + 1)) <= 65535;
}
/***************************************************************************
** Stop only what this host started.
***************************************************************************/
@Override
public void close()
{
if(server != null)
{
server.stop();
}
if(realtime != null)
{
realtime.close();
}
}
/***************************************************************************
** Secrets enter through the environment only and are never printed. First
** startup needs both mobile demo passwords; later startups keep the accounts.
***************************************************************************/
public static void main(String[] args) throws Exception
{
System.setProperty("qqq.logger.logSessionId.disabled", "true");
EchoDemoSettings settings = EchoDemoSettings.fromEnvironment(System.getenv(), args.length == 0 ? null : args[0]);
launch(new EchoDemo(settings), settings, "production");
}
/***************************************************************************
** Shared startup for the production and the explicit local development mains.
***************************************************************************/
static void launch(EchoDemo demo, EchoDemoSettings settings, String renderer) throws Exception
{
demo.withBranding(System.getenv("MOBILE_BRAND_DISPLAY_NAME"), System.getenv("MOBILE_BRAND_PRIMARY_COLOR"), System.getenv("MOBILE_BRAND_LOGO_URL"));
demo.initializeDatabase(System.getenv("MOBILE_DEMO_SCANNER_PASSWORD"), System.getenv("MOBILE_DEMO_ADMIN_PASSWORD"));
Runtime.getRuntime().addShutdownHook(new Thread(demo::close));
demo.start(settings.port());
LogManager.getLogger(EchoDemo.class).info("ECHO_DEMO_READY port={} loopback=true renderer={}", settings.port(), renderer);
}
}
@@ -0,0 +1,121 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo.demo;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.examples.echo.EchoQBitProducer;
import com.kingsrook.qbits.collaboration.CollaborationSecurity;
import com.kingsrook.qbits.mobilescanners.demo.DemoAuthenticationModule;
import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSession;
/*******************************************************************************
** The existing demo password/session validation plus exactly the grants the
** owners' source requires. The validated account selects the role, never a
** request field. Printing and Record Files administration is granted to no one.
*******************************************************************************/
public class EchoDemoAuthentication extends DemoAuthenticationModule
{
/**
** Scanner: Echo itself (also the source-row read rule), Printing's operator
** catalog and submit, Generation view/generate/print, and Record Files
** view (retained reads) and manage (the owner's intake permission), plus the
** attachments feature permission on the Echo scan table (Collaboration's
** requireFeature for "attach"; the default feature mode is not implied by edit).
*/
static final List<String> SCANNER = List.of(EchoQBitProducer.PERMISSION, "printing_operator", "document-generation.view",
"document-generation.generate", "document-generation.print", "record-files.view", "record-files.manage",
EchoQBitProducer.SCAN_TABLE + ".attach");
/**
** Admin also gets Printing's own administrator permission, which its native
** device pairing/approval/setup processes require, and what is needed to
** prepare and publish templates. The scanner never receives these.
*/
static final List<String> ADMIN_ADDITIONS = List.of("printing_admin", "document-generation.configure", "document-generation.publish");
private static volatile String tenant;
/***************************************************************************
** QQQ instantiates this module by class, so the host sets the Printing
** tenant once before the server accepts requests. Printing's rows lock to it.
***************************************************************************/
static void configure(String printingTenant)
{
tenant = printingTenant;
}
/***************************************************************************
** Login keeps the existing validation, then adds the grants.
***************************************************************************/
@Override
public QSession createSession(QInstance instance, Map<String, String> context) throws QAuthenticationException
{
return grant(instance, super.createSession(instance, context));
}
/***************************************************************************
** Native actions revalidate sessions independently of the HTTP handler.
***************************************************************************/
@Override
public boolean isSessionValid(QInstance instance, QSession session)
{
boolean valid = super.isSessionValid(instance, session);
if(valid)
{
grant(instance, session);
}
return valid;
}
/***************************************************************************
** Only the two seeded demo accounts receive access; only admin configures.
** Collaboration's owner key (collaboration_user, or its admin override) is
** applied last, after every grant, on every login and revalidation, so it is
** in place before the scanner's verified session is first pinned.
***************************************************************************/
static QSession grant(QInstance instance, QSession session)
{
String user = session.getUser() == null ? null : session.getUser().getIdReference();
if(tenant != null && ("scanner".equals(user) || "admin".equals(user)))
{
List<String> names = new ArrayList<>(SCANNER);
if("admin".equals(user))
{
names.addAll(ADMIN_ADDITIONS);
}
for(String name : names)
{
session.withPermission(name);
}
// Re-derived from the fixed host tenant on every call: replace, never append, so revalidation
// cannot grow the key map. All other keys are preserved.
Map<String, List<Serializable>> keys = session.getSecurityKeyValues() == null ? new HashMap<>() : new HashMap<>(session.getSecurityKeyValues());
if(!List.of(tenant).equals(keys.get("printing_tenant")))
{
keys.put("printing_tenant", new ArrayList<>(List.of(tenant)));
session.withSecurityKeyValues(keys);
}
}
CollaborationSecurity.applyToSession(instance, session);
return session;
}
}
@@ -0,0 +1,289 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo.demo;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.documentgeneration.renderer.RendererConfiguration;
/*******************************************************************************
** Deployment-owned Echo demo inputs. Every value comes from the environment (or
** one port argument) and nothing here has a default that could reach a real
** printer, renderer, tenant or account. Messages name a variable, never a value.
*******************************************************************************/
public record EchoDemoSettings(Path root, int port, String tenant, String adminToken, boolean releaseGeneratedFiles, TemplatePin template,
RendererConfiguration renderer, Path existingDatabase)
{
/** Local runtimes that must never be bound or shadowed by a demo. */
private static final Set<Integer> PROTECTED_PORTS = Set.of(57832, 51631, 18081, 18086, 18443, 19443, 18447, 18448, 18547, 18549, 18593);
/** The one canonical local test server port, permitted only when adopting that server's existing database. */
static final int NORMAL_LOCAL_SERVER_PORT = 57832;
static final String DATABASE_SUFFIX = ".mv.db";
private static final String LABEL = "[A-Za-z0-9._:-]{1,128}";
/***************************************************************************
** Fresh private demo: its database lives under the demo root and the
** canonical local server port stays protected.
***************************************************************************/
public EchoDemoSettings(Path root, int port, String tenant, String adminToken, boolean releaseGeneratedFiles, TemplatePin template,
RendererConfiguration renderer)
{
this(root, port, tenant, adminToken, releaseGeneratedFiles, template, renderer, null);
}
/***************************************************************************
** The one published ZPL template for exactly one reported media identity and
** revision. Absent, every scan is refused with Echo's no-layout message.
***************************************************************************/
public record TemplatePin(String mediaId, String mediaRevision, int templateId)
{
/***********************************************************************
** Bounded labels and a real template id.
***********************************************************************/
public TemplatePin
{
if(mediaId == null || !mediaId.matches(LABEL) || mediaRevision == null || !mediaRevision.matches(LABEL) || templateId < 1)
{
throw new IllegalArgumentException("ECHO_TEMPLATE_ID, ECHO_TEMPLATE_MEDIA_ID and ECHO_TEMPLATE_MEDIA_REVISION are invalid");
}
}
}
/***************************************************************************
** Refuse an unsafe port, root, tenant, token or permission list up front.
***************************************************************************/
public EchoDemoSettings
{
if(root == null || !root.isAbsolute() || !Files.isDirectory(root) || Files.isSymbolicLink(root))
{
throw new IllegalArgumentException("ECHO_DEMO_ROOT must be an existing absolute directory that is not a symbolic link");
}
if(existingDatabase != null)
{
requireExistingDatabase(existingDatabase);
}
boolean adoptingNormalServer = existingDatabase != null && port == NORMAL_LOCAL_SERVER_PORT;
if(port < 1024 || port > 65535 || (PROTECTED_PORTS.contains(port) && !adoptingNormalServer))
{
throw new IllegalArgumentException("The Echo demo port must be a fresh unprotected port from 1024 to 65535");
}
if(tenant == null || !tenant.matches(LABEL))
{
throw new IllegalArgumentException("ECHO_PRINTING_TENANT is required and must be a bounded label");
}
if(adminToken == null || adminToken.length() < 32 || adminToken.length() > 1024)
{
throw new IllegalArgumentException("ECHO_PRINTING_ADMIN_TOKEN is required and must be 32 to 1024 characters");
}
}
/***************************************************************************
** The explicit existing-local-server mode: an absolute, existing H2 file
** named *.mv.db that is a regular file, not a link or reparse point. The
** reason names the variable, never the path.
***************************************************************************/
private static void requireExistingDatabase(Path database)
{
try
{
var attributes = Files.readAttributes(database, java.nio.file.attribute.BasicFileAttributes.class, java.nio.file.LinkOption.NOFOLLOW_LINKS);
if(!database.isAbsolute() || !database.getFileName().toString().endsWith(DATABASE_SUFFIX) || database.getFileName().toString().length() == DATABASE_SUFFIX.length()
|| !attributes.isRegularFile() || attributes.isSymbolicLink() || attributes.isOther())
{
throw new IllegalArgumentException();
}
}
catch(java.io.IOException | RuntimeException failure)
{
throw new IllegalArgumentException("ECHO_EXISTING_DATABASE must be an existing absolute H2 " + DATABASE_SUFFIX + " file that is not a link");
}
}
/***************************************************************************
** The H2 file base (the path without the suffix) of the existing database;
** null for a fresh private demo.
***************************************************************************/
String existingDatabaseBase()
{
if(existingDatabase == null)
{
return null;
}
String path = existingDatabase.toString().replace('\\', '/');
return path.substring(0, path.length() - DATABASE_SUFFIX.length());
}
/***************************************************************************
** Parse the documented environment. The port argument, when present, wins.
***************************************************************************/
public static EchoDemoSettings fromEnvironment(Map<String, String> environment, String portArgument)
{
return parse(environment, portArgument, renderer(environment));
}
/***************************************************************************
** Shared settings for the explicit trusted local development launcher. These
** settings carry no cold-bridge configuration, so the production EchoDemo
** constructor refuses them; the local renderer is a separate opt-in record.
***************************************************************************/
public static EchoDemoSettings fromLocalEnvironment(Map<String, String> environment, String portArgument)
{
return parse(environment, portArgument, null);
}
/***************************************************************************
** One parser for the shared root, port, tenant, token, pin and release policy.
***************************************************************************/
private static EchoDemoSettings parse(Map<String, String> environment, String portArgument, RendererConfiguration renderer)
{
String port = portArgument != null ? portArgument : required(environment, "ECHO_DEMO_PORT");
TemplatePin pin = null;
if(environment.get("ECHO_TEMPLATE_ID") != null || environment.get("ECHO_TEMPLATE_MEDIA_ID") != null || environment.get("ECHO_TEMPLATE_MEDIA_REVISION") != null)
{
pin = new TemplatePin(required(environment, "ECHO_TEMPLATE_MEDIA_ID"), required(environment, "ECHO_TEMPLATE_MEDIA_REVISION"),
integer(required(environment, "ECHO_TEMPLATE_ID"), "ECHO_TEMPLATE_ID"));
}
return new EchoDemoSettings(Path.of(required(environment, "ECHO_DEMO_ROOT")), integer(port, "ECHO_DEMO_PORT"),
required(environment, "ECHO_PRINTING_TENANT"), required(environment, "ECHO_PRINTING_ADMIN_TOKEN"),
"true".equals(environment.get("ECHO_RELEASE_GENERATED_FILES")), pin, renderer,
environment.get("ECHO_EXISTING_DATABASE") == null ? null : Path.of(required(environment, "ECHO_EXISTING_DATABASE")));
}
/***************************************************************************
** Explicit trusted local development renderer: an absolute Docker executable
** and the exact local image id. It has no repository digest and no
** qualification id, and it is never a fallback for the production bridge.
***************************************************************************/
public record LocalRenderer(Path docker, String imageId, Duration deadline, int maximumBytes, int maximumPages)
{
/** Fixed value of ECHO_LOCAL_RENDERER; anything else is not an opt-in. */
static final String OPT_IN = "TRUSTED_LOCAL_DEVELOPMENT";
/***********************************************************************
** Bounded values; the exchange and the owner's limits validate again.
***********************************************************************/
public LocalRenderer
{
if(docker == null || !docker.isAbsolute() || !Files.isRegularFile(docker) || imageId == null || !imageId.matches("sha256:[a-f0-9]{64}")
|| deadline == null || deadline.compareTo(Duration.ofSeconds(1)) < 0 || deadline.compareTo(Duration.ofSeconds(30)) > 0
|| maximumBytes < 1 || maximumBytes > 100 * 1024 * 1024 || maximumPages < 1 || maximumPages > 500)
{
throw new IllegalArgumentException("The ECHO_LOCAL_RENDERER_* configuration is absent or invalid");
}
}
/***********************************************************************
** Requires ECHO_LOCAL_RENDERER=TRUSTED_LOCAL_DEVELOPMENT, the absolute
** ECHO_LOCAL_RENDERER_DOCKER and the sha256 ECHO_LOCAL_RENDERER_IMAGE_ID.
***********************************************************************/
public static LocalRenderer fromEnvironment(Map<String, String> environment)
{
if(!OPT_IN.equals(environment.get("ECHO_LOCAL_RENDERER")))
{
throw new IllegalArgumentException("Set ECHO_LOCAL_RENDERER=" + OPT_IN + " to use the trusted local development renderer");
}
return new LocalRenderer(Path.of(required(environment, "ECHO_LOCAL_RENDERER_DOCKER")), required(environment, "ECHO_LOCAL_RENDERER_IMAGE_ID"),
Duration.ofSeconds(integer(environment.getOrDefault("ECHO_LOCAL_RENDERER_DEADLINE_SECONDS", "10"), "ECHO_LOCAL_RENDERER_DEADLINE_SECONDS")),
integer(environment.getOrDefault("ECHO_RENDERER_MAXIMUM_BYTES", "1048576"), "ECHO_RENDERER_MAXIMUM_BYTES"),
integer(environment.getOrDefault("ECHO_RENDERER_MAXIMUM_PAGES", "1"), "ECHO_RENDERER_MAXIMUM_PAGES"));
}
}
/***************************************************************************
** The owner's cold bridge configuration, validated by the owner's record:
** absolute Docker executable, digest-pinned image, image id, matching
** qualification evidence, owner id and a private state directory.
***************************************************************************/
private static RendererConfiguration renderer(Map<String, String> environment)
{
try
{
return new RendererConfiguration(Path.of(required(environment, "ECHO_RENDERER_DOCKER")), required(environment, "ECHO_RENDERER_IMAGE"),
required(environment, "ECHO_RENDERER_IMAGE_ID"), required(environment, "ECHO_RENDERER_QUALIFICATION_ID"),
required(environment, "ECHO_RENDERER_REQUIRED_QUALIFICATION_ID"), required(environment, "ECHO_RENDERER_OWNER_ID"),
Path.of(required(environment, "ECHO_RENDERER_STATE_DIR")),
Duration.ofSeconds(integer(environment.getOrDefault("ECHO_RENDERER_DEADLINE_SECONDS", "10"), "ECHO_RENDERER_DEADLINE_SECONDS")),
integer(environment.getOrDefault("ECHO_RENDERER_MAXIMUM_BYTES", "1048576"), "ECHO_RENDERER_MAXIMUM_BYTES"),
integer(environment.getOrDefault("ECHO_RENDERER_MAXIMUM_PAGES", "1"), "ECHO_RENDERER_MAXIMUM_PAGES"));
}
catch(IllegalArgumentException failure)
{
throw new IllegalArgumentException("The ECHO_RENDERER_* deployment configuration is absent or outside the renderer owner's policy");
}
}
/***************************************************************************
** Required value, reported by variable name only.
***************************************************************************/
private static String required(Map<String, String> environment, String name)
{
String value = environment.get(name);
if(value == null || value.isBlank())
{
throw new IllegalArgumentException("Set " + name);
}
return value;
}
/***************************************************************************
** Whole-number value, reported by variable name only.
***************************************************************************/
private static int integer(String value, String name)
{
try
{
return Integer.parseInt(value.trim());
}
catch(NumberFormatException failure)
{
throw new IllegalArgumentException(name + " must be a whole number");
}
}
/***************************************************************************
** The administrator token is never rendered.
***************************************************************************/
@Override
public String toString()
{
return "EchoDemoSettings[port=" + port + ", tenant=" + tenant + ", secrets=redacted]";
}
}
@@ -0,0 +1,329 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo.demo;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import com.kingsrook.examples.echo.EchoQBitProducer;
import com.kingsrook.qbits.collaboration.security.ParentRecordAccess;
import com.kingsrook.qbits.mobilescanners.MobileDeviceAuthorization;
import com.kingsrook.qbits.mobilescanners.recordfiles.MobileCaptureIdentityProvider;
import com.kingsrook.qbits.mobilescanners.recordfiles.RecordFilesCaptureWidgetData;
import com.kingsrook.qbits.recordfiles.ingestion.UploadChunkStorage;
import com.kingsrook.qbits.recordfiles.ingestion.UploadConfiguration;
import com.kingsrook.qbits.recordfiles.ingestion.UploadManifest;
import com.kingsrook.qbits.recordfiles.ingestion.UploadQuota;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.values.QCustomPossibleValueProvider;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
/*******************************************************************************
** Composes the existing mobile Record Files integration into the Echo demo: one
** ordinary QQQ process lets an approved scanner choose one of its own retained
** Echo scans and then shows the native photo capture control for that record.
**
** The parent is never taken from the client. The table is the fixed Echo scan
** table; the record is chosen from a server-built list and re-read through the
** owner's visibility check (record security lock, attach feature) before the
** widget data is built from the record that check returned. Record Files then
** authorizes that parent independently on every upload operation. This adds no
** endpoint, storage, transport or authentication of its own.
*******************************************************************************/
public final class EchoPhotoCapture
{
public static final String PROCESS = "echoScanPhotos";
public static final String WIDGET = "echoScanPhotos";
public static final String CHOICES = "echoScanPhotoChoice";
public static final String SELECTION_FIELD = "echoScanId";
static final String TITLE = "Echo scan photos";
static final String INSTRUCTIONS = "Capture or choose a photo for the selected Echo scan.";
static final int MAXIMUM_CHOICES = 50;
private static final int MAXIMUM_ID = 64;
private static final long MAXIMUM_FILE_BYTES = 20L * 1024 * 1024;
/***************************************************************************
** Namespace only.
***************************************************************************/
private EchoPhotoCapture()
{
}
/***************************************************************************
** Immutable server policy: images only, bounded size and count, and the
** scanner's verified device/session as the capture identity authority. The
** private staging store carries the chunks.
***************************************************************************/
static UploadConfiguration uploadConfiguration()
{
return new UploadConfiguration(LocalStagingChunkStorage.ALIAS,
new UploadManifest.Limits(MAXIMUM_FILE_BYTES, 2 * MAXIMUM_FILE_BYTES, 4, 262144, 600, Set.of("image/jpeg", "image/png")),
new UploadQuota.Policy(10, 4 * MAXIMUM_FILE_BYTES, 100), new QCodeReference(MobileCaptureIdentityProvider.class))
{
/*********************************************************************
** This host's private directory instead of an object-storage bucket.
*********************************************************************/
@Override
public UploadChunkStorage createStorage()
{
return new LocalStagingChunkStorage();
}
};
}
/***************************************************************************
** Register the choice source, the guarded widget and the process, and list
** the process in the existing Echo app. Run after Echo is installed.
***************************************************************************/
static void register(QInstance instance) throws QException
{
QAppMetaData app = instance.getApp(EchoQBitProducer.APP);
if(app == null || app.getSections() == null || app.getSections().isEmpty() || instance.getTable(EchoQBitProducer.SCAN_TABLE) == null)
{
throw new QException("The Echo app and scan table must be installed before the photo process.");
}
instance.addPossibleValueSource(new QPossibleValueSource().withName(CHOICES).withType(QPossibleValueSourceType.CUSTOM)
.withIdType(QFieldType.STRING).withCustomCodeReference(new QCodeReference(Choices.class)));
instance.addWidget(new QWidgetMetaData().withName(WIDGET).withType("composite").withLabel(TITLE)
.withCodeReference(new QCodeReference(Widget.class)).withPermissionRules(rules()));
instance.addProcess(new QProcessMetaData().withName(PROCESS).withLabel(TITLE).withPermissionRules(rules())
.withStep(new QFrontendStepMetaData().withName("select").withLabel("Choose an Echo scan")
.withFormField(new QFieldMetaData(SELECTION_FIELD, QFieldType.STRING).withLabel("Echo scan").withIsRequired(true)
.withMaxLength(MAXIMUM_ID).withPossibleValueSourceName(CHOICES))
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM)))
.withStep(new QBackendStepMetaData().withName("prepare").withCode(new QCodeReference(Prepare.class)))
.withStep(new QFrontendStepMetaData().withName("capture").withLabel(TITLE)
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.WIDGET).withValue("widgetName", WIDGET))));
QAppSection section = app.getSections().getFirst();
List<String> processes = new ArrayList<>(section.getProcesses() == null ? List.of() : section.getProcesses());
if(!processes.contains(PROCESS))
{
processes.add(PROCESS);
}
section.withProcesses(processes);
}
/***************************************************************************
** Approved scanner, current Echo access and the attach feature; permission
** to see this process never grants a record or an upload.
***************************************************************************/
private static QPermissionRules rules()
{
return new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withCustomPermissionChecker(new QCodeReference(Permissions.class));
}
/***************************************************************************
** The only way the widget's parent is built: validated syntax, a record the
** current actor may see and attach to, and values taken from that record.
***************************************************************************/
static RecordFilesCaptureWidgetData widgetFor(Serializable requested) throws QException
{
String id = requested == null ? null : String.valueOf(requested);
if(id == null || id.isBlank() || id.length() > MAXIMUM_ID || id.codePoints().anyMatch(Character::isISOControl))
{
throw new QBadRequestException("Choose an Echo scan.");
}
QRecord parent = ParentRecordAccess.requireVisibleParent(EchoQBitProducer.SCAN_TABLE, id);
ParentRecordAccess.requireFeature(EchoQBitProducer.SCAN_TABLE, id, "attach");
return new RecordFilesCaptureWidgetData(EchoQBitProducer.SCAN_TABLE, Objects.requireNonNull(parent.getValueString("id")), TITLE, INSTRUCTIONS);
}
/***************************************************************************
** Approved device (the verified scanner session) plus Echo and attach grants.
***************************************************************************/
public static final class Permissions implements CustomPermissionChecker
{
/*********************************************************************
** Missing, ended or revoked device authority refuses discovery and launch.
*********************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput action, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
{
try
{
if(QContext.getQSession() == null || !QContext.getQSession().hasPermission(EchoQBitProducer.PERMISSION)
|| !QContext.getQSession().hasPermission(EchoQBitProducer.SCAN_TABLE + ".attach") || MobileDeviceAuthorization.captureCurrent().isEmpty())
{
throw new QPermissionDeniedException("An approved scanner with Echo access is required.");
}
}
catch(QPermissionDeniedException denied)
{
throw denied;
}
catch(Exception failure)
{
throw new QPermissionDeniedException("An approved scanner with Echo access is required.");
}
}
}
/***************************************************************************
** The current actor's own retained Echo scans, newest first. The record
** security lock decides visibility; nothing here widens it.
***************************************************************************/
public static final class Choices implements QCustomPossibleValueProvider<String>
{
/*********************************************************************
** Resolve one selected id back to its label, if it is still visible.
*********************************************************************/
@Override
public QPossibleValue<String> getPossibleValue(Serializable id) throws QException
{
List<QPossibleValue<String>> found = select(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.EQUALS, String.valueOf(id))), 1);
return found.isEmpty() ? null : found.getFirst();
}
/*********************************************************************
** Bounded exact-id or plain-text search over this actor's scans.
*********************************************************************/
@Override
public List<QPossibleValue<String>> search(SearchPossibleValueSourceInput input) throws QException
{
int limit = Math.max(1, Math.min(MAXIMUM_CHOICES, Objects.requireNonNullElse(input.getLimit(), MAXIMUM_CHOICES)));
if(input.getIdList() != null)
{
if(input.getIdList().size() > MAXIMUM_CHOICES)
{
throw new QBadRequestException("Too many Echo scans requested.");
}
return select(new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.IN, new ArrayList<Serializable>(input.getIdList()))), limit);
}
String term = Objects.requireNonNullElse(input.getSearchTerm(), "");
if(term.length() > 128)
{
throw new QBadRequestException("Narrow the Echo scan search.");
}
QQueryFilter filter = new QQueryFilter();
if(!term.isBlank())
{
filter.withCriteria(new QFilterCriteria("barcode", QCriteriaOperator.LIKE, "%" + term + "%"));
}
filter.withSkip(Math.max(0, Objects.requireNonNullElse(input.getSkip(), 0)));
return select(filter, limit);
}
/*********************************************************************
** Query under the caller's session so the table's own lock and read rule apply.
*********************************************************************/
private static List<QPossibleValue<String>> select(QQueryFilter filter, int limit) throws QException
{
filter.withLimit(limit).withOrderBy(new QFilterOrderBy("receivedAt", false)).withOrderBy(new QFilterOrderBy("id", false));
List<QPossibleValue<String>> choices = new ArrayList<>();
for(QRecord record : new QueryAction().execute(new QueryInput(EchoQBitProducer.SCAN_TABLE).withFilter(filter)).getRecords())
{
choices.add(new QPossibleValue<>(record.getValueString("id"), label(record)));
}
return choices;
}
}
/***************************************************************************
** A short, single-line label; control characters are not echoed.
***************************************************************************/
static String label(QRecord record)
{
String barcode = Objects.requireNonNullElse(record.getValueString("barcode"), "").replaceAll("\\p{Cntrl}", "?");
String shown = barcode.length() > 60 ? barcode.substring(0, 60) + "…" : barcode;
return shown + " (" + Objects.requireNonNullElse(record.getValueString("symbology"), "unknown") + ")";
}
/***************************************************************************
** Build the widget values on the server from the validated selection. The
** frontend step never receives a client-supplied table or record reference.
***************************************************************************/
public static final class Prepare implements BackendStep
{
/*********************************************************************
** Refuse an unavailable record before the capture step is shown.
*********************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
output.addValue(WIDGET, widgetFor(input.getValue(SELECTION_FIELD)));
}
}
/***************************************************************************
** The process supplies the data. Rendering this widget directly cannot name a
** parent, so a forged widget request yields nothing to attach to.
***************************************************************************/
public static final class Widget extends AbstractWidgetRenderer
{
/*********************************************************************
** Always refused; only the process's prepared values carry a parent.
*********************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
throw new QBadRequestException("Open Echo scan photos from its process and choose a scan.");
}
}
}
@@ -0,0 +1,145 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo.demo;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.Optional;
import com.kingsrook.qbits.collaboration.attachments.AttachmentStorage;
import com.kingsrook.qbits.objectstorage.api.ObjectHeaders;
import com.kingsrook.qbits.objectstorage.api.ObjectStream;
import com.kingsrook.qbits.objectstorage.api.PutRequest;
import com.kingsrook.qbits.objectstorage.api.StoredObject;
/*******************************************************************************
** The standard attachment owner's storage SPI over one private local directory,
** for this single-host demo only. It adds no policy of its own: Record Files and
** the attachment owner still decide quarantine, authorization, lifecycle and
** retention. QQQ instantiates storage by class, so the host sets the root once.
*******************************************************************************/
public class LocalFilesystemAttachmentStorage extends AttachmentStorage
{
static final long MAXIMUM_BYTES = 20L * 1024 * 1024;
private static volatile PrivateObjectFiles files;
/***************************************************************************
** Bind the explicit private root before the server accepts requests.
***************************************************************************/
static void configure(Path root)
{
files = new PrivateObjectFiles(root);
}
/***************************************************************************
** The configured store, or a refusal when the host never configured one.
***************************************************************************/
private static PrivateObjectFiles store()
{
PrivateObjectFiles configured = files;
if(configured == null)
{
throw new IllegalStateException("Local attachment storage has no configured root.");
}
return configured;
}
/***************************************************************************
** Small fixed limit for this demo's retained originals.
***************************************************************************/
@Override
public long maxBytes(String name)
{
return MAXIMUM_BYTES;
}
/***************************************************************************
** Conditional create: an existing key is never replaced.
***************************************************************************/
@Override
public void put(String name, PutRequest request)
{
try
{
java.io.InputStream content = request.getBytes() == null ? request.getStream() : new java.io.ByteArrayInputStream(request.getBytes());
try(content)
{
store().create(name, request.getKey(), content, MAXIMUM_BYTES, request.getMetadata());
}
}
catch(IOException failure)
{
throw new IllegalStateException("Local object write failed.", failure);
}
}
/***************************************************************************
** Reopen the persisted bytes with the owner's metadata.
***************************************************************************/
@Override
public ObjectStream open(String name, String key)
{
try
{
PrivateObjectFiles.Stored stored = store().find(name, key).orElseThrow(() -> new IllegalStateException("Local object is absent."));
return new ObjectStream(Files.newInputStream(stored.file()), stored.size(), "application/octet-stream", null, Instant.now(),
new ObjectHeaders().withUserMetadata(stored.metadata()));
}
catch(IOException failure)
{
throw new IllegalStateException("Local object read failed.", failure);
}
}
/***************************************************************************
** Existence and size for the owner's idempotent recovery.
***************************************************************************/
@Override
public Optional<StoredObject> head(String name, String key)
{
try
{
return store().find(name, key).map(stored -> new StoredObject().withSize(stored.size()));
}
catch(IOException failure)
{
throw new IllegalStateException("Local object lookup failed.", failure);
}
}
/***************************************************************************
** Remove one owner-requested object.
***************************************************************************/
@Override
public void delete(String name, String key)
{
try
{
store().delete(name, key);
}
catch(IOException failure)
{
throw new IllegalStateException("Local object delete failed.", failure);
}
}
}
@@ -0,0 +1,81 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo.demo;
import java.util.Optional;
import com.kingsrook.qbits.objectstorage.api.ObjectStream;
import com.kingsrook.qbits.objectstorage.api.PutRequest;
import com.kingsrook.qbits.objectstorage.api.StoredObject;
import com.kingsrook.qbits.recordfiles.ingestion.UploadChunkStorage;
/*******************************************************************************
** Record Files' temporary upload chunks in this demo's private object directory,
** under a dedicated staging namespace that retained originals never use. It adds
** no policy: the owner's coordinator authorizes, verifies and cleans every chunk,
** and keys are derived by the owner, never taken from a request.
*******************************************************************************/
final class LocalStagingChunkStorage extends UploadChunkStorage
{
static final String ALIAS = "echo-photo-staging";
private final LocalFilesystemAttachmentStorage files = new LocalFilesystemAttachmentStorage();
/***************************************************************************
** The dedicated staging alias; the private root is the one the host configured.
***************************************************************************/
LocalStagingChunkStorage()
{
super(ALIAS);
}
/***************************************************************************
** Conditional create; an existing chunk is never replaced.
***************************************************************************/
@Override
protected void put(String uploadId, PutRequest request)
{
files.put(ALIAS, request);
}
/***************************************************************************
** Existence for the owner's recovery of an uncertain write.
***************************************************************************/
@Override
protected Optional<StoredObject> head(String uploadId, String key)
{
return files.head(ALIAS, key);
}
/***************************************************************************
** Reopen a staged chunk for digest verification.
***************************************************************************/
@Override
protected ObjectStream open(String uploadId, String key)
{
return files.open(ALIAS, key);
}
/***************************************************************************
** Remove one staged chunk after the owner's committed terminal state.
***************************************************************************/
@Override
protected void delete(String uploadId, String key)
{
files.delete(ALIAS, key);
}
}
@@ -0,0 +1,178 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo.demo;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.Map;
import java.util.Optional;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
/*******************************************************************************
** Immutable object files under one explicit private root, using standard Files
** primitives only. Owner keys are opaque: they are validated and hashed into the
** file name, so no key can address another path, alias another object or climb
** out of the root. Objects are written to a temporary file in their own folder
** and linked into place, so a reader never sees a partial object and an existing
** object is never replaced.
*******************************************************************************/
final class PrivateObjectFiles
{
private static final ObjectMapper JSON = new ObjectMapper();
private static final int MAXIMUM_KEY = 2048;
private final Path root;
/***************************************************************************
** A stored object: its bytes' file, size and the owner's metadata.
***************************************************************************/
record Stored(Path file, long size, Map<String, String> metadata)
{
}
/***************************************************************************
** The root must be an existing absolute directory that is not a link.
***************************************************************************/
PrivateObjectFiles(Path root)
{
if(root == null || !root.isAbsolute() || !Files.isDirectory(root) || Files.isSymbolicLink(root))
{
throw new IllegalArgumentException("Object storage needs an existing absolute private directory that is not a symbolic link");
}
this.root = root;
}
/***************************************************************************
** Hash the validated bucket and key into one fixed-shape file name.
***************************************************************************/
Path file(String bucket, String key)
{
if(bucket == null || !bucket.matches("[A-Za-z0-9._-]{1,64}") || key == null || key.isEmpty() || key.length() > MAXIMUM_KEY)
{
throw new IllegalArgumentException("Object bucket or key is outside policy");
}
try
{
String name = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((bucket + "\n" + key).getBytes(StandardCharsets.UTF_8)));
return root.resolve(bucket).resolve(name.substring(0, 2)).resolve(name);
}
catch(java.security.NoSuchAlgorithmException failure)
{
throw new IllegalStateException(failure);
}
}
/***************************************************************************
** Create one object, refusing an existing one and anything over the limit.
** Metadata is made durable first, so an object that exists has its metadata.
***************************************************************************/
void create(String bucket, String key, InputStream content, long maximumBytes, Object metadata) throws IOException
{
Path target = file(bucket, key);
Path folder = target.getParent();
Files.createDirectories(folder);
if(Files.exists(target, java.nio.file.LinkOption.NOFOLLOW_LINKS))
{
throw new FileAlreadyExistsException("Object already exists");
}
Path temporary = Files.createTempFile(folder, ".incoming-", ".part");
Path sidecar = Files.createTempFile(folder, ".incoming-", ".meta");
try
{
long total = 0;
byte[] buffer = new byte[8192];
try(OutputStream out = Files.newOutputStream(temporary, StandardOpenOption.WRITE))
{
for(int read = content.read(buffer); read >= 0; read = content.read(buffer))
{
total += read;
if(total > maximumBytes)
{
throw new IOException("Object exceeds the storage limit");
}
out.write(buffer, 0, read);
}
}
JSON.writeValue(sidecar.toFile(), metadata == null ? Map.of() : metadata);
Files.move(sidecar, metadataFile(target), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
Files.createLink(target, temporary);
}
finally
{
Files.deleteIfExists(temporary);
Files.deleteIfExists(sidecar);
}
}
/***************************************************************************
** Read an object's identity; absent means empty. Links are refused.
***************************************************************************/
Optional<Stored> find(String bucket, String key) throws IOException
{
Path target = file(bucket, key);
if(!Files.exists(target, java.nio.file.LinkOption.NOFOLLOW_LINKS))
{
return Optional.empty();
}
if(!Files.isRegularFile(target, java.nio.file.LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(metadataFile(target)))
{
throw new IOException("Object path is not a plain file");
}
try
{
Map<String, String> metadata = JSON.readValue(metadataFile(target).toFile(), new TypeReference<Map<String, String>>() {});
return Optional.of(new Stored(target, Files.size(target), metadata));
}
catch(NoSuchFileException failure)
{
throw new IOException("Object metadata is missing", failure);
}
}
/***************************************************************************
** Remove one object and its metadata; an absent object is not an error.
***************************************************************************/
void delete(String bucket, String key) throws IOException
{
Path target = file(bucket, key);
Files.deleteIfExists(target);
Files.deleteIfExists(metadataFile(target));
}
/***************************************************************************
** The metadata sidecar beside an object file.
***************************************************************************/
private static Path metadataFile(Path target)
{
return target.resolveSibling(target.getFileName() + ".json");
}
}
@@ -0,0 +1,97 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo.demo;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.documentgeneration.GenerationPorts;
import com.kingsrook.qbits.documentgeneration.recordfiles.RetainedAssetReader;
import com.kingsrook.qbits.documentgeneration.renderer.IsolatedRendererBridge;
import com.kingsrook.qbits.documentgeneration.renderer.TrustedLocalDockerExchange;
import org.apache.logging.log4j.LogManager;
/*******************************************************************************
** Explicit TRUSTED LOCAL DEVELOPMENT Echo host. Same composition, accounts,
** permissions and loopback server as EchoDemo, but the renderer is the owner's
** production bridge over the owner's supported trusted-local Docker exchange.
** That exchange is not a production sandbox qualification. It is a separate
** main: the production EchoDemo never selects it and nothing here falls back to
** or from the production renderer.
*******************************************************************************/
public final class TrustedLocalEchoDemo
{
static final String OWNER_ID = "echo-local";
static final String STATE_DIRECTORY = "renderer-state";
/***************************************************************************
** Static launcher only.
***************************************************************************/
private TrustedLocalEchoDemo()
{
}
/***************************************************************************
** The owner's bridge over the owner's trusted-local exchange for an exact
** local image id. Its durable journal lives in a private directory under the
** demo root, which must be fresh for each runtime. Expired leases left by a
** killed process are reclaimed once, explicitly, before the first render.
***************************************************************************/
static GenerationPorts.IsolatedRenderer renderer(EchoDemoSettings.LocalRenderer local, Path demoRoot) throws Exception
{
TrustedLocalDockerExchange exchange = new TrustedLocalDockerExchange(TrustedLocalDockerExchange.configuration(local.docker(), local.imageId(),
stateDirectory(demoRoot), OWNER_ID, local.deadline(), local.maximumBytes(), local.maximumPages(), 1));
List<String> unreclaimed = exchange.reapExpired();
if(!unreclaimed.isEmpty())
{
LogManager.getLogger(TrustedLocalEchoDemo.class).warn("Trusted local renderer left {} expired lease(s) unreclaimed", unreclaimed.size());
}
return new IsolatedRendererBridge(exchange.limits(), new RetainedAssetReader(), exchange);
}
/***************************************************************************
** An existing absolute, non-symbolic-link directory private to this runtime.
***************************************************************************/
static Path stateDirectory(Path demoRoot) throws IOException
{
Path directory = demoRoot.resolve(STATE_DIRECTORY);
if(!Files.isDirectory(directory))
{
Files.createDirectory(directory);
}
if(!directory.isAbsolute() || Files.isSymbolicLink(directory))
{
throw new IllegalArgumentException("The renderer state directory must be an existing absolute directory that is not a symbolic link");
}
return directory;
}
/***************************************************************************
** Secrets enter through the environment only. Requires the explicit opt-in
** ECHO_LOCAL_RENDERER=TRUSTED_LOCAL_DEVELOPMENT plus the shared EchoDemo
** settings; a missing opt-in or a bad image or executable stops startup.
***************************************************************************/
public static void main(String[] args) throws Exception
{
System.setProperty("qqq.logger.logSessionId.disabled", "true");
Map<String, String> environment = System.getenv();
EchoDemoSettings.LocalRenderer local = EchoDemoSettings.LocalRenderer.fromEnvironment(environment);
EchoDemoSettings settings = EchoDemoSettings.fromLocalEnvironment(environment, args.length == 0 ? null : args[0]);
EchoDemo.launch(new EchoDemo(settings, renderer(local, settings.root()), local.maximumBytes()), settings, "trusted-local-development");
}
}
@@ -0,0 +1,834 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo.demo;
import java.net.HttpCookie;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.DriverManager;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Stream;
import com.kingsrook.examples.echo.EchoDestinations;
import com.kingsrook.examples.echo.EchoQBitProducer;
import com.kingsrook.examples.echo.EchoTemplatePublisher;
import com.kingsrook.qbits.documentgeneration.DocumentDefinitionService;
import com.kingsrook.qbits.documentgeneration.DocumentGenerationQBitConfig;
import com.kingsrook.qbits.documentgeneration.GenerationPorts;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.TableFeaturePermissionsMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** Complete composed-host acceptance over a fresh private file database: an
** authenticated, approved scanner runs the real Echo process, which drives the
** real Generation, Record Files retention and Printing job acceptance. Only two
** inputs are controlled: the renderer returns fixed synthetic ZPL, and the
** "printer" is a Printing device enrolled and inventoried through the owner's
** public service API. No Docker, physical printer, agent or device is touched,
** so this qualifies composition and contracts, not physical output.
*******************************************************************************/
class EchoDemoAcceptanceTest
{
private static final String TOKEN = UUID.randomUUID() + "-" + UUID.randomUUID();
private static final String TENANT = "echo-accept";
private static final String MEDIA = "echo-4x6-300";
private static final String REVISION = "c".repeat(64);
private static final String QUEUE = "EchoAcceptanceQueue";
/** A nondefault renderer cap: Generation's own default (20 MiB) is larger, so a host that leaves it unset is refused. */
private static final int RENDERER_CAP = 262144;
private static final String CODE_VALUE = "001234567890";
private static final String QR_VALUE = "QR-VALUE-0099";
private static final Set<Integer> PROTECTED = Set.of(57832, 51631, 18081, 18086, 18443, 19443, 18447, 18448, 18547, 18549, 18593);
@TempDir
Path root;
private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).followRedirects(HttpClient.Redirect.NEVER).build();
private String origin;
private String deviceId;
private String deviceToken;
/***************************************************************************
** Fixed synthetic output, derived only from the exact context the real
** Generation service hands the renderer. It records every call and, like the
** owner bridge, refuses a request whose maximum exceeds its own configured cap.
***************************************************************************/
private static final class ControlledRenderer implements GenerationPorts.IsolatedRenderer
{
private final List<GenerationPorts.RenderRequest> renders = new CopyOnWriteArrayList<>();
private final AtomicInteger validations = new AtomicInteger();
private final int cap;
/***********************************************************************
** The renderer's configured maximum output bytes.
***********************************************************************/
private ControlledRenderer(int cap)
{
this.cap = cap;
}
/***********************************************************************
** Catalog text with the owner's required heading.
***********************************************************************/
@Override
public String barcodeReference() throws QException
{
return "# Barcode catalog: qqq-barcodes-9 (controlled synthetic test renderer)";
}
/***********************************************************************
** Accept the published revision; the call is counted.
***********************************************************************/
@Override
public void validate(String actor, GenerationPorts.Revision revision) throws QException
{
validations.incrementAndGet();
}
/***********************************************************************
** Return fixed ZPL naming the exact symbology and value received.
***********************************************************************/
@Override
public GenerationPorts.Content render(GenerationPorts.RenderRequest request) throws QException
{
// like the owner bridge, an absent maximum means the renderer's own configured cap
int requestedMaximum = request.maximumBytes() == null ? cap : request.maximumBytes();
if(requestedMaximum < 1 || requestedMaximum > cap)
{
throw new QException("Isolated renderer rejected or could not complete the request.");
}
renders.add(request);
byte[] output = zpl((Map<?, ?>) request.context().get("record")).getBytes(StandardCharsets.US_ASCII);
if(output.length > requestedMaximum)
{
throw new QException("Isolated renderer rejected or could not complete the request.");
}
return new GenerationPorts.Content(output);
}
}
/***************************************************************************
** Generation requests exactly the renderer's configured cap: an explicit
** nondefault value is propagated, and a host given no cap keeps the owner
** default, which the bounded renderer would refuse (the live failure).
***************************************************************************/
@Test
void generationRequestsTheConfiguredRendererCap() throws Exception
{
EchoDemoSettings settings = new EchoDemoSettings(root, freePort(), TENANT, TOKEN, true, null, null);
try(EchoDemo demo = new EchoDemo(settings, new ControlledRenderer(RENDERER_CAP), RENDERER_CAP))
{
demo.initializeDatabase(UUID.randomUUID().toString(), UUID.randomUUID().toString());
assertEquals(RENDERER_CAP, generationConfig(demo.defineQInstance()).getMaximumOutputBytes());
}
com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager.resetConnectionProviders();
Path other = Files.createDirectory(root.resolve("owner-default"));
EchoDemoSettings unconfigured = new EchoDemoSettings(other, freePort(), TENANT, TOKEN, true, null, null);
try(EchoDemo demo = new EchoDemo(unconfigured, new ControlledRenderer(RENDERER_CAP)))
{
demo.initializeDatabase(UUID.randomUUID().toString(), UUID.randomUUID().toString());
Integer requested = generationConfig(demo.defineQInstance()).getMaximumOutputBytes();
assertTrue(requested > RENDERER_CAP, "The owner default exceeds the nondefault cap, so an unset host is refused.");
}
}
/***************************************************************************
** The Generation configuration the host installed in the instance.
***************************************************************************/
private static DocumentGenerationQBitConfig generationConfig(QInstance instance)
{
return instance.getQBits().values().stream().map(bit -> bit.getConfig()).filter(DocumentGenerationQBitConfig.class::isInstance)
.map(DocumentGenerationQBitConfig.class::cast).findFirst().orElseThrow();
}
/***************************************************************************
** The synthetic label for one scanned record context.
***************************************************************************/
private static String zpl(Map<?, ?> record)
{
return "^XA^FO48,48^FD" + record.get("rendererSymbology") + "|" + record.get("barcode") + "^FS^XZ";
}
/***************************************************************************
** The RDBMS connection providers are cached per backend name for the whole
** JVM, and every composed host here names its backend "mobileDb". Drop any
** provider left by another test so this run never depends on test order.
***************************************************************************/
@BeforeEach
@AfterEach
void isolateConnectionProviders()
{
com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager.resetConnectionProviders();
}
/***************************************************************************
** Setup through owner APIs, then scanner scans over real native HTTP.
***************************************************************************/
@Test
void approvedScannerScansBecomeRetainedOriginalsAndPrintingJobs() throws Exception
{
ControlledRenderer renderer = new ControlledRenderer(RENDERER_CAP);
int setupPort = freePort();
int port = freePort();
EchoDemoSettings setup = new EchoDemoSettings(root, setupPort, TENANT, TOKEN, true, null, null);
String scannerPassword = UUID.randomUUID().toString();
String adminPassword = UUID.randomUUID().toString();
String printerRowId;
Integer templateA;
Integer templateB;
Integer templateOther;
try(EchoDemo demo = new EchoDemo(setup, renderer, RENDERER_CAP))
{
demo.initializeDatabase(scannerPassword, adminPassword);
printerRowId = enrollPrinter(demo, setup);
demo.start(setupPort);
origin = "http://127.0.0.1:" + setupPort;
String adminSession = login("admin", adminPassword);
// two compatible published documents for the one reported size, and one for a different size
templateA = publishTemplate(demo, printerRowId, adminSession, "Echo label A", MEDIA, REVISION, 101600, 152400, 300);
templateB = publishTemplate(demo, printerRowId, adminSession, "Echo label B", MEDIA, REVISION, 101600, 152400, 300);
templateOther = publishTemplate(demo, printerRowId, adminSession, "Echo other size", "echo-2x1", "d".repeat(64), 50800, 25400, 203);
}
assertTrue(renderer.validations.get() > 0, "Publishing must validate the exact template in the renderer.");
EchoDemoSettings settings = new EchoDemoSettings(root, port, TENANT, TOKEN, true, null, null);
origin = "http://127.0.0.1:" + port;
try(EchoDemo demo = new EchoDemo(settings, renderer, RENDERER_CAP))
{
assertNotNull(TableFeaturePermissionsMetaData.of(demo.defineQInstance()), "Host enrichment must register record-feature permissions.");
assertEquals(RENDERER_CAP, generationConfig(demo.defineQInstance()).getMaximumOutputBytes(), "Generation must request the renderer's configured cap.");
demo.start(port);
String scanner = login("scanner", scannerPassword);
String admin = login("admin", adminPassword);
enrollAndApproveDevice(scanner, admin);
// the Document choice is real native possible values, dependent on the chosen printer and size
Set<Integer> offered = new HashSet<>();
for(Object option : documentChoices(scanner, new JSONObject().put("printerId", printerRowId).put("mediaId", MEDIA)))
{
Object id = ((JSONObject) option).get("id");
assertTrue(id instanceof Integer || id instanceof Long, "Document ids are JSON integers.");
offered.add(((Number) id).intValue());
}
assertEquals(Set.of(templateA, templateB), offered, "Exactly the compatible published documents for this printer and size are offered.");
assertEquals(0, documentChoices(scanner, new JSONObject().put("printerId", printerRowId)).length());
// missing, unknown, malformed and wrong-size documents never start a run
for(Object invalid : new Object[] { null, 999999, "abc", templateOther })
{
JSONObject attempt = echo(form("/qqq/v1/processes/echoPrint/init", scanner, new JSONObject(), true), scanner);
JSONObject values = new JSONObject().put("printerId", printerRowId).put("mediaId", MEDIA);
if(invalid != null)
{
values.put("templateId", invalid);
}
assertTrue(rejected(form("/qqq/v1/processes/echoPrint/" + attempt.getString("processUUID") + "/step/setup", scanner, values, true), scanner),
"An invalid document choice must be refused.");
// the refusal does not trap the operator: Leave still closes that attempt
assertEquals(200, form("/qqq/v1/processes/echoPrint/" + attempt.getString("processUUID") + "/cancel", scanner, new JSONObject(), true).statusCode(),
"Leave must work after an invalid setup choice.");
}
assertEquals(0, renderer.renders.size());
JSONObject launched = echo(form("/qqq/v1/processes/echoPrint/init", scanner, new JSONObject(), true), scanner);
assertEquals("setup", launched.getString("nextStep"));
String process = launched.getString("processUUID");
JSONObject selected = echo(form("/qqq/v1/processes/echoPrint/" + process + "/step/setup", scanner,
new JSONObject().put("printerId", printerRowId).put("mediaId", MEDIA).put("templateId", templateB), true), scanner);
assertEquals("scan", selected.getString("nextStep"));
assertTrue(rejected(form("/qqq/v1/processes/echoPrint/" + process + "/step/setup", scanner,
new JSONObject().put("printerId", printerRowId).put("mediaId", MEDIA).put("templateId", templateA), true), scanner),
"Setup cannot be replayed to retarget the document.");
JSONObject firstEvent = event("LABEL-TYPE-CODE128", CODE_VALUE);
JSONObject first = echo(form("/qqq/v1/processes/echoPrint/" + process + "/step/scan", scanner, firstEvent, true), scanner).getJSONObject("values");
assertEquals("QUEUED", first.getString("printStatus"));
String firstJob = first.getString("printJobId");
assertFalse(firstJob.isBlank());
JSONObject retry = echo(form("/qqq/v1/processes/echoPrint/" + process + "/step/scan", scanner, firstEvent, true), scanner).getJSONObject("values");
assertEquals(firstJob, retry.getString("printJobId"));
// a forged document value on a later scan cannot retarget the run's chosen document
JSONObject secondResult = echo(form("/qqq/v1/processes/echoPrint/" + process + "/step/scan", scanner,
event("QRCODE", QR_VALUE).put("templateId", templateA), true), scanner);
assertEquals("scan", secondResult.getString("nextStep"));
String secondJob = secondResult.getJSONObject("values").getString("printJobId");
assertFalse(secondJob.isBlank());
assertFalse(firstJob.equals(secondJob), "Each scan is its own accepted job.");
String firstZpl = "^XA^FO48,48^FDcode128|" + CODE_VALUE + "^FS^XZ";
String secondZpl = "^XA^FO48,48^FDqr|" + QR_VALUE + "^FS^XZ";
assertEquals(2, renderer.renders.size(), "A retry must not render again.");
for(GenerationPorts.RenderRequest render : renderer.renders)
{
assertEquals(GenerationPorts.Format.ZPL, render.format());
assertEquals(EchoQBitProducer.SCAN_TABLE, render.source().tableName());
assertEquals(templateB, render.revision().templateId(), "Every scan uses the document chosen at setup.");
}
Map<?, ?> firstRecord = (Map<?, ?>) renderer.renders.get(0).context().get("record");
Map<?, ?> secondRecord = (Map<?, ?>) renderer.renders.get(1).context().get("record");
assertEquals("code128", firstRecord.get("rendererSymbology"));
assertEquals(CODE_VALUE, firstRecord.get("barcode"));
assertEquals("qr", secondRecord.get("rendererSymbology"));
assertEquals(QR_VALUE, secondRecord.get("barcode"));
JSONArray scans = query(EchoQBitProducer.SCAN_TABLE, scanner);
assertEquals(2, scans.length());
Set<String> accepted = new HashSet<>();
for(Object item : scans)
{
JSONObject values = ((JSONObject) item).getJSONObject("values");
assertEquals("QUEUED", values.getString("state"));
accepted.add(values.getString("rendererSymbology") + "|" + values.getString("barcode") + "|" + values.getString("printJobId"));
}
assertEquals(Set.of("code128|" + CODE_VALUE + "|" + firstJob, "qr|" + QR_VALUE + "|" + secondJob), accepted);
List<List<String>> jobs = sql(setup, "SELECT id, printer_row_id, printer_id, format, content_base64 FROM printing_job", 5);
assertEquals(2, jobs.size(), "Exactly one accepted Printing job per scan.");
Set<String> jobIds = new HashSet<>();
Set<String> content = new HashSet<>();
for(List<String> job : jobs)
{
jobIds.add(job.get(0));
assertEquals(printerRowId, job.get(1), "Both scans use the one selected destination.");
assertEquals(QUEUE, job.get(2));
assertEquals("ZPL", job.get(3));
content.add(new String(Base64.getDecoder().decode(job.get(4)), StandardCharsets.US_ASCII));
}
assertEquals(Set.of(firstJob, secondJob), jobIds);
assertEquals(Set.of(firstZpl, secondZpl), content);
Set<String> retained = new HashSet<>();
try(Stream<Path> files = Files.walk(root.resolve("objects")))
{
for(Path file : files.filter(Files::isRegularFile).filter(path -> !path.getFileName().toString().endsWith(".json")).toList())
{
retained.add(Files.readString(file, StandardCharsets.US_ASCII));
}
}
assertEquals(Set.of(firstZpl, secondZpl), retained, "Record Files retained exactly the two generated originals.");
assertEquals(200, form("/qqq/v1/processes/echoPrint/" + process + "/cancel", scanner, new JSONObject(), true).statusCode());
// the successful run is the only one with a selected printer; the four refused setups never selected one
assertEquals(List.of(List.of("CLOSED", printerRowId, MEDIA)), sql(setup, "SELECT state, printerRecordId, mediaId FROM echo_run WHERE printerRecordId <> ''", 3));
assertEquals(List.of(List.of("CLOSED"), List.of("CLOSED"), List.of("CLOSED"), List.of("CLOSED")), sql(setup, "SELECT state FROM echo_run WHERE printerRecordId = ''", 1));
assertEquals(List.of(), sql(setup, "SELECT id FROM echo_run WHERE state IN ('OPEN','SETUP')", 1), "No run is left open after explicit Leave.");
}
}
/***************************************************************************
** The shared X-QQQ-Device-* headers belong to two independent registries.
** A real native Printing agent enrollment and session must work on its own
** channel, while a printer proof never satisfies scanner authentication and
** the channel marker alone never replaces it.
***************************************************************************/
@Test
void printerDeviceProofIsAuthorizedByPrintingAndNeverByScannerAuthentication() throws Exception
{
int port = freePort();
EchoDemoSettings settings = new EchoDemoSettings(root, port, TENANT, TOKEN, true, null, null);
String scannerPassword = UUID.randomUUID().toString();
String adminPassword = UUID.randomUUID().toString();
origin = "http://127.0.0.1:" + port;
try(EchoDemo demo = new EchoDemo(settings, new ControlledRenderer(RENDERER_CAP), RENDERER_CAP))
{
demo.initializeDatabase(scannerPassword, adminPassword);
String code = demo.printing().createEnrollment("default", "Printer agent").getString("enrollmentCode");
demo.start(port);
String scanner = login("scanner", scannerPassword);
String admin = login("admin", adminPassword);
enrollAndApproveDevice(scanner, admin);
String scannerId = deviceId;
String scannerToken = deviceToken;
String agent = "api/qqq-print-agent";
HttpResponse<String> enrolled = as(agent, "POST", "/qqq/v1/qbit/printing/devices/enroll", admin,
new JSONObject().put("enrollmentCode", code).put("deviceName", "Printer agent").put("agentVersion", "acceptance-1").toString(), null, null);
assertEquals(200, enrolled.statusCode(), "Printing enrollment must not be intercepted by scanner device binding.");
String printerId = new JSONObject(enrolled.body()).getString("deviceId");
String printerToken = new JSONObject(enrolled.body()).getString("deviceToken");
assertFalse(printerId.equals(scannerId), "Printing and scanner devices are separate registries.");
assertEquals(200, as(agent, "POST", "/qqq/v1/qbit/printing/device/bind", admin, "{}", printerId, printerToken).statusCode());
HttpResponse<String> session = as(agent, "GET", "/qqq/v1/qbit/printing/session", admin, null, printerId, printerToken);
assertEquals(200, session.statusCode(), "The Printing session with its own device proof must succeed.");
assertEquals(printerId, new JSONObject(session.body()).getString("deviceId"));
assertEquals(403, as(agent, "GET", "/qqq/v1/qbit/printing/session", admin, null, printerId, "wrong-proof").statusCode());
assertEquals(403, as(agent, "GET", "/qqq/v1/qbit/printing/session", scanner, null, printerId, printerToken).statusCode(),
"A different actor cannot use the printer proof.");
// scanner operations always verify the scanner registry, whatever channel the request claims
assertEquals(403, as("android/qqq-mobile", "POST", "/qqq/v1/processes/echoPrint/init", scanner, "{}", printerId, printerToken).statusCode());
assertEquals(403, as(agent, "POST", "/qqq/v1/processes/echoPrint/init", scanner, "{}", printerId, printerToken).statusCode());
assertEquals(403, as(agent, "POST", "/qqq/v1/processes/echoPrint/init", scanner, "{}", scannerId, "wrong-proof").statusCode());
assertEquals(403, as(agent, "GET", "/qqq/v1/qbit/mobile-scanners/devices/current", scanner, null, printerId, printerToken).statusCode());
// Android keeps scanner verification even on another extension's routes
assertEquals(403, as("android/qqq-mobile", "GET", "/qqq/v1/qbit/printing/session", admin, null, printerId, printerToken).statusCode());
// and the scanner's own proof still works on its own channel and routes
assertEquals(200, as("android/qqq-mobile", "GET", "/qqq/v1/metaData", scanner, null, scannerId, scannerToken).statusCode());
}
}
/***************************************************************************
** One request with an explicit client channel and optional device proof.
***************************************************************************/
private HttpResponse<String> as(String channel, String method, String path, String session, String body, String id, String token) throws Exception
{
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(origin + path)).timeout(Duration.ofSeconds(20)).header("X-QQQ-Client", channel)
.header("Origin", origin).header("Content-Type", "application/json");
if(session != null)
{
request.header("Cookie", "sessionUUID=" + session);
}
if(id != null)
{
request.header("X-QQQ-Device-Id", id).header("X-QQQ-Device-Token", token);
}
return client.send(request.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body)).build(),
HttpResponse.BodyHandlers.ofString());
}
/***************************************************************************
** A document republished by its owner after setup is a different revision
** from the one the run pinned: the next scan fails visibly, nothing is
** generated or printed and no other document is substituted, Leave still
** works, and a fresh run can choose the republished document again.
***************************************************************************/
@Test
void republishedDocumentFailsVisiblyWithoutReplacementAndLeaveStillWorks() throws Exception
{
ControlledRenderer renderer = new ControlledRenderer(RENDERER_CAP);
int port = freePort();
EchoDemoSettings settings = new EchoDemoSettings(root, port, TENANT, TOKEN, true, null, null);
String scannerPassword = UUID.randomUUID().toString();
String adminPassword = UUID.randomUUID().toString();
origin = "http://127.0.0.1:" + port;
try(EchoDemo demo = new EchoDemo(settings, renderer, RENDERER_CAP))
{
demo.initializeDatabase(scannerPassword, adminPassword);
String printerRowId = enrollPrinter(demo, settings);
demo.start(port);
String scanner = login("scanner", scannerPassword);
String admin = login("admin", adminPassword);
Integer templateA = publishTemplate(demo, printerRowId, admin, "Echo label A", MEDIA, REVISION, 101600, 152400, 300);
Integer templateB = publishTemplate(demo, printerRowId, admin, "Echo label B", MEDIA, REVISION, 101600, 152400, 300);
enrollAndApproveDevice(scanner, admin);
JSONObject launched = echo(form("/qqq/v1/processes/echoPrint/init", scanner, new JSONObject(), true), scanner);
String process = launched.getString("processUUID");
echo(form("/qqq/v1/processes/echoPrint/" + process + "/step/setup", scanner,
new JSONObject().put("printerId", printerRowId).put("mediaId", MEDIA).put("templateId", templateA), true), scanner);
echo(form("/qqq/v1/processes/echoPrint/" + process + "/step/scan", scanner, event("LABEL-TYPE-CODE128", CODE_VALUE), true), scanner);
assertEquals(1, renderer.renders.size());
republishTemplate(demo, admin, templateA);
JSONObject changed = event("QRCODE", QR_VALUE);
assertTrue(rejected(form("/qqq/v1/processes/echoPrint/" + process + "/step/scan", scanner, changed, true), scanner),
"A republished document must fail visibly on the next scan.");
assertTrue(rejected(form("/qqq/v1/processes/echoPrint/" + process + "/step/scan", scanner, changed, true), scanner),
"Retrying the same scan must not silently use the new revision or another document.");
assertEquals(1, renderer.renders.size(), "No replacement document may be generated.");
assertEquals(1, sql(settings, "SELECT id FROM printing_job", 1).size(), "No replacement print job may be accepted.");
assertEquals(templateA, renderer.renders.get(0).revision().templateId());
assertEquals(200, form("/qqq/v1/processes/echoPrint/" + process + "/cancel", scanner, new JSONObject(), true).statusCode(), "Leave must still work.");
assertEquals(List.of(List.of("CLOSED")), sql(settings, "SELECT state FROM echo_run", 1));
// a new run offers the republished document again and pins its new revision
assertEquals(Set.of(templateA, templateB), documentIds(scanner, printerRowId));
JSONObject again = echo(form("/qqq/v1/processes/echoPrint/init", scanner, new JSONObject(), true), scanner);
echo(form("/qqq/v1/processes/echoPrint/" + again.getString("processUUID") + "/step/setup", scanner,
new JSONObject().put("printerId", printerRowId).put("mediaId", MEDIA).put("templateId", templateA), true), scanner);
echo(form("/qqq/v1/processes/echoPrint/" + again.getString("processUUID") + "/step/scan", scanner, event("QRCODE", QR_VALUE), true), scanner);
assertEquals(2, renderer.renders.size());
assertEquals(templateA, renderer.renders.get(1).revision().templateId());
}
}
/***************************************************************************
** The published searchProcessPossibleValues contract, as the native client
** sends it: POST JSON {values, processValues, useCase}.
***************************************************************************/
private JSONArray documentChoices(String session, JSONObject values) throws Exception
{
HttpResponse<String> response = request("POST", "/qqq/v1/processes/echoPrint/possibleValues/templateId", session,
new JSONObject().put("values", values).put("processValues", new JSONObject()).put("useCase", "form").toString(), true);
assertEquals(200, response.statusCode(), "Document possible-value search failed.");
// the semantic API may omit optional empty options
JSONArray options = new JSONObject(response.body()).optJSONArray("options");
return options == null ? new JSONArray() : options;
}
/***************************************************************************
** The integer ids currently offered for the fixture printer and size.
***************************************************************************/
private Set<Integer> documentIds(String session, String printerRowId) throws Exception
{
Set<Integer> ids = new HashSet<>();
for(Object option : documentChoices(session, new JSONObject().put("printerId", printerRowId).put("mediaId", MEDIA)))
{
ids.add(((JSONObject) option).getInt("id"));
}
return ids;
}
/***************************************************************************
** Republish through the owner's definition service as the admin: a changed
** (still compatible) draft becomes a new published revision of the template.
***************************************************************************/
private void republishTemplate(EchoDemo demo, String adminSessionId, Integer templateId) throws Exception
{
QInstance instance = demo.defineQInstance();
DocumentGenerationQBitConfig config = instance.getQBits().values().stream().map(bit -> bit.getConfig())
.filter(DocumentGenerationQBitConfig.class::isInstance).map(DocumentGenerationQBitConfig.class::cast).findFirst().orElseThrow();
QContext.init(instance, null);
try
{
QContext.init(instance, new EchoDemoAuthentication().createSession(instance, Map.of("sessionUUID", adminSessionId, "sessionId", adminSessionId)));
DocumentDefinitionService definitions = new DocumentDefinitionService(config);
var draft = definitions.loadDraft(templateId);
JSONObject scene = new JSONObject(draft.draft().zplSource());
scene.getJSONArray("elements").getJSONObject(0).put("quiet", 11);
definitions.saveSources(templateId, draft.stockId(), null, scene.toString(), draft.fingerprint());
definitions.publish(templateId, definitions.loadDraft(templateId).fingerprint());
}
finally
{
QContext.clear();
}
}
/***************************************************************************
** A real Printing device and one ready ZPL queue with one reported media
** profile, enabled, through the owner's public service. Returns the row id.
***************************************************************************/
private static String enrollPrinter(EchoDemo demo, EchoDemoSettings setup) throws Exception
{
var printing = demo.printing();
String code = printing.createEnrollment("echo-facility", "Echo acceptance host").getString("enrollmentCode");
String device = printing.enroll(new JSONObject().put("enrollmentCode", code).put("deviceName", "Echo acceptance host").put("agentVersion", "acceptance-1"))
.getString("deviceId");
JSONObject media = new JSONObject().put("id", MEDIA).put("revision", REVISION).put("name", "4 x 6 in").put("widthMicrons", 101600)
.put("heightMicrons", 152400).put("dpiX", 300).put("dpiY", 300).put("source", "WINDOWS_QUEUE_DEFAULT").put("applyMode", "DRIVER_DEFAULT");
printing.inventory(device, new JSONArray().put(new JSONObject().put("printerId", QUEUE).put("name", QUEUE)
.put("formats", new JSONArray().put("ZPL")).put("status", "READY").put("mediaProfiles", new JSONArray().put(media))));
printing.enablePrinter(device, QUEUE, true);
return sql(setup, "SELECT id FROM printing_printer WHERE device_id='" + device + "'", 1).getFirst().getFirst();
}
/***************************************************************************
** Publish the ZPL template for the reported media as the authenticated admin.
** The session is the demo authentication module's own session for the stored
** native login (not a fabricated user), so core session validation and the
** admin's real grants apply. The renderer validates the template.
***************************************************************************/
private Integer publishTemplate(EchoDemo demo, String printerRowId, String adminSessionId, String name, String mediaId, String mediaRevision,
int widthMicrons, int heightMicrons, int dpi) throws Exception
{
QInstance instance = demo.defineQInstance();
DocumentGenerationQBitConfig config = instance.getQBits().values().stream().map(bit -> bit.getConfig())
.filter(DocumentGenerationQBitConfig.class::isInstance).map(DocumentGenerationQBitConfig.class::cast).findFirst().orElseThrow();
// The module's session resume runs a real query, so the instance must be in the context first
// (the module sets the resumed session itself). Resume and publish share one cleared scope.
QContext.init(instance, null);
try
{
QSession admin = new EchoDemoAuthentication().createSession(instance, Map.of("sessionUUID", adminSessionId, "sessionId", adminSessionId));
QContext.init(instance, admin);
return EchoTemplatePublisher.publish(new DocumentDefinitionService(config),
new EchoDestinations.Destination(printerRowId, mediaId, mediaRevision, widthMicrons, heightMicrons, dpi, dpi), name);
}
finally
{
QContext.clear();
}
}
/***************************************************************************
** Read rows over JDBC from the private file database, as strings.
***************************************************************************/
private static List<List<String>> sql(EchoDemoSettings settings, String statement, int columns) throws Exception
{
List<List<String>> rows = new ArrayList<>();
try(var connection = DriverManager.getConnection(EchoDemo.jdbcUrl(settings), "sa", ""); var query = connection.createStatement();
var result = query.executeQuery(statement))
{
while(result.next())
{
List<String> row = new ArrayList<>();
for(int column = 1; column <= columns; column++)
{
row.add(result.getString(column));
}
rows.add(row);
}
}
return rows;
}
/***************************************************************************
** The scanner enrolls its device through the native route and the admin
** approves it with the native approval process.
***************************************************************************/
private void enrollAndApproveDevice(String scanner, String admin) throws Exception
{
HttpResponse<String> enrolled = request("POST", "/qqq/v1/qbit/mobile-scanners/devices/enroll", scanner,
new JSONObject().put("installationId", UUID.randomUUID().toString()).toString(), false);
assertTrue(enrolled.statusCode() == 200 || enrolled.statusCode() == 201, "Device enrollment returned " + enrolled.statusCode() + ": " + enrolled.body());
JSONObject device = new JSONObject(enrolled.body());
deviceId = device.getString("deviceId");
deviceToken = device.getString("deviceToken");
JSONObject approval = complete(form("/qqq/v1/processes/mobileDeviceApprove/init", admin,
new JSONObject().put("recordsParam", "recordIds").put("recordIds", deviceId).put("tableName", "mobileDevice"), false), "mobileDeviceApprove", admin, false);
complete(form("/qqq/v1/processes/mobileDeviceApprove/" + approval.getString("processUUID") + "/step/input", admin, new JSONObject(), false),
"mobileDeviceApprove", admin, false);
assertEquals(200, request("GET", "/qqq/v1/metaData", scanner, null, true).statusCode());
}
/***************************************************************************
** A fresh port that is neither privileged nor a protected local runtime.
***************************************************************************/
private static int freePort() throws Exception
{
while(true)
{
try(ServerSocket socket = new ServerSocket(0, 0, InetAddress.getLoopbackAddress()))
{
if(socket.getLocalPort() >= 1024 && !PROTECTED.contains(socket.getLocalPort()))
{
return socket.getLocalPort();
}
}
}
}
/***************************************************************************
** QQQ validates Basic credentials; secrets never appear in assertion output.
***************************************************************************/
private String login(String username, String password) throws Exception
{
String basic = Base64.getEncoder().encodeToString((username + ":" + password).getBytes(StandardCharsets.UTF_8));
HttpResponse<String> response = client.send(headers("/qqq/v1/manageSession", null, false).header("Authorization", "Basic " + basic)
.header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString("{}")).build(), HttpResponse.BodyHandlers.ofString());
assertEquals(200, response.statusCode(), "Native login failed.");
return response.headers().allValues("Set-Cookie").stream().flatMap(value -> HttpCookie.parse(value).stream())
.filter(cookie -> "sessionUUID".equals(cookie.getName())).findFirst().orElseThrow().getValue();
}
/***************************************************************************
** Observations use ordinary authorized QQQ table queries as the scanner.
***************************************************************************/
private JSONArray query(String table, String session) throws Exception
{
HttpResponse<String> response = request("POST", "/qqq/v1/table/" + table + "/query", session,
new JSONObject().put("filter", new JSONObject().put("limit", 20)).toString(), true);
assertEquals(200, response.statusCode(), "Native inspection failed.");
return new JSONObject(response.body()).getJSONArray("records");
}
/***************************************************************************
** Echo process step as the scanner with the approved device headers.
***************************************************************************/
private JSONObject echo(HttpResponse<String> response, String session) throws Exception
{
return complete(response, EchoQBitProducer.PROCESS, session, true);
}
/***************************************************************************
** Poll an async setup step: true when the server refuses it (a non-200
** status, an error body, or no advance to the scan step).
***************************************************************************/
private boolean rejected(HttpResponse<String> response, String session) throws Exception
{
if(response.statusCode() != 200)
{
return true;
}
JSONObject body = new JSONObject(response.body());
String processId = body.optString("processUUID");
String jobId = body.optString("jobUUID");
long deadline = System.nanoTime() + Duration.ofSeconds(30).toNanos();
while("JOB_STARTED".equals(body.optString("type")) || "RUNNING".equals(body.optString("type")))
{
assertTrue(System.nanoTime() < deadline, "Native process timed out.");
Thread.sleep(25);
response = request("GET", "/qqq/v1/processes/" + EchoQBitProducer.PROCESS + "/" + processId + "/status/" + jobId, session, null, true);
if(response.statusCode() != 200)
{
return true;
}
body = new JSONObject(response.body());
}
return body.has("error") || !"scan".equals(body.optString("nextStep"));
}
/***************************************************************************
** Poll an async process step to completion; an error body fails the test.
***************************************************************************/
private JSONObject complete(HttpResponse<String> response, String process, String session, boolean device) throws Exception
{
assertEquals(200, response.statusCode(), "Native process request failed.");
JSONObject body = new JSONObject(response.body());
String processId = body.optString("processUUID");
String jobId = body.optString("jobUUID");
long deadline = System.nanoTime() + Duration.ofSeconds(30).toNanos();
while("JOB_STARTED".equals(body.optString("type")) || "RUNNING".equals(body.optString("type")))
{
assertTrue(System.nanoTime() < deadline, "Native process timed out.");
Thread.sleep(25);
response = request("GET", "/qqq/v1/processes/" + process + "/" + processId + "/status/" + jobId, session, null, device);
assertEquals(200, response.statusCode());
body = new JSONObject(response.body());
}
assertFalse(body.has("error"), "Native process returned an error: " + body.optString("error"));
return body;
}
/***************************************************************************
** The same multipart JSON-values body as the native QQQ process client.
***************************************************************************/
private HttpResponse<String> form(String path, String session, JSONObject values, boolean device) throws Exception
{
String boundary = "echo-accept-" + UUID.randomUUID();
String body = "--" + boundary + "\r\nContent-Disposition: form-data; name=\"values\"\r\n\r\n" + values
+ "\r\n--" + boundary + "\r\nContent-Disposition: form-data; name=\"stepTimeoutMillis\"\r\n\r\n1\r\n";
for(String key : new String[] { "recordsParam", "recordIds", "filterJSON" })
{
if(values.has(key))
{
body += "--" + boundary + "\r\nContent-Disposition: form-data; name=\"" + key + "\"\r\n\r\n" + values.getString(key) + "\r\n";
}
}
body += "--" + boundary + "--\r\n";
return client.send(headers(path, session, device).header("Content-Type", "multipart/form-data; boundary=" + boundary)
.POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString());
}
/***************************************************************************
** All requests target this test's loopback origin.
***************************************************************************/
private HttpResponse<String> request(String method, String path, String session, String body, boolean device) throws Exception
{
return client.send(headers(path, session, device).header("Content-Type", "application/json")
.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString());
}
/***************************************************************************
** Credentials and device tokens remain request headers, never process values.
***************************************************************************/
private HttpRequest.Builder headers(String path, String session, boolean device)
{
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(origin + path)).timeout(Duration.ofSeconds(20)).header("X-QQQ-Client", "android/qqq-mobile")
.header("Origin", origin);
if(session != null)
{
request.header("Cookie", "sessionUUID=" + session);
}
if(device)
{
request.header("X-QQQ-Device-Id", deviceId).header("X-QQQ-Device-Token", deviceToken);
}
return request;
}
/***************************************************************************
** One scan event with a unique id and the exact scanned text.
***************************************************************************/
private static JSONObject event(String type, String value)
{
return new JSONObject().put("barcode", value).put("symbology", type).put("scanSource", "zebra-emdk")
.put("eventId", UUID.randomUUID().toString()).put("capturedAt", "2026-11-01T18:00:00Z");
}
}
@@ -0,0 +1,466 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo.demo;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import com.kingsrook.examples.echo.EchoQBitProducer;
import com.kingsrook.qbits.documentgeneration.GenerationPorts;
import com.kingsrook.qbits.mobilescanners.MobileScannersQBitProducer;
import com.kingsrook.qbits.mobilescanners.demo.MobileScannersDemo;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer;
import org.json.JSONObject;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** The normal local test server's existing mobile database is adopted in place
** by the combined Echo host: a baseline MobileScannersDemo creates accounts, an
** approved device and a scan in a file database, then EchoDemo opens that same
** file through the explicit existing-local-server mode. Temporary directories and
** fresh loopback ports only; the canonical port and data are never touched.
*******************************************************************************/
class EchoDemoAdoptionTest
{
private static final String TOKEN = UUID.randomUUID() + "-" + UUID.randomUUID();
private static final List<String> TABLES = List.of("demoUser", "mobileDevice", "mobileDeviceCredential", "mobileScan");
@TempDir
Path root;
@TempDir
Path data;
private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).followRedirects(HttpClient.Redirect.NEVER).build();
private String origin;
/***************************************************************************
** Fixture renderer: adoption never renders and never validates a template.
***************************************************************************/
private static final class UnusedRenderer implements GenerationPorts.IsolatedRenderer
{
/***********************************************************************
** Catalog text with the owner's required heading.
***********************************************************************/
@Override
public String barcodeReference() throws QException
{
return "# Barcode catalog: qqq-barcodes-9 (adoption test renderer)";
}
/***********************************************************************
** Not used by adoption.
***********************************************************************/
@Override
public void validate(String actor, GenerationPorts.Revision revision) throws QException
{
}
/***********************************************************************
** Adoption never renders.
***********************************************************************/
@Override
public GenerationPorts.Content render(GenerationPorts.RenderRequest request) throws QException
{
throw new QException("This adoption test never renders.");
}
}
/***************************************************************************
** Each test starts and ends without a cached connection provider.
***************************************************************************/
@BeforeEach
@AfterEach
void isolateConnectionProviders()
{
com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager.resetConnectionProviders();
}
/***************************************************************************
** Accounts, hashes, device credentials, approval and scans survive adoption
** and an idempotent restart; the one QInstance exposes every composed QBit.
***************************************************************************/
@Test
void adoptingAnExistingLocalDatabasePreservesAccountsDevicesAndScansAcrossRestarts() throws Exception
{
Path base = data.resolve("mobile");
Path file = data.resolve("mobile.mv.db");
String scannerPassword = UUID.randomUUID().toString();
String adminPassword = UUID.randomUUID().toString();
String[] device = baseline(base, scannerPassword, adminPassword);
assertTrue(Files.isRegularFile(file));
com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager.resetConnectionProviders();
EchoDemoSettings settings = new EchoDemoSettings(root, freePort(), "echo-adopt", TOKEN, true, null, null, file);
Map<String, List<List<String>>> before = snapshot(settings, true);
com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager.resetConnectionProviders();
assertFalse(before.get("mobileScan").isEmpty());
assertFalse(before.get("mobileDeviceCredential").isEmpty());
Map<String, List<List<String>>> afterDeviceUse;
try(EchoDemo demo = new EchoDemo(settings, new UnusedRenderer()))
{
// different passwords on adoption are ignored: nothing is seeded or changed
demo.initializeDatabase(UUID.randomUUID().toString(), UUID.randomUUID().toString());
demo.start(settings.port());
origin = "http://127.0.0.1:" + settings.port();
QInstance instance = demo.defineQInstance();
Set<String> composed = new HashSet<>();
instance.getQBits().values().forEach(bit -> composed.add(bit.getConfig().getClass().getSimpleName()));
for(String expected : List.of("MobileScannersQBitConfig", "EchoConfig", "RecordFilesQBitConfig", "CollaborationQBitConfig", "DocumentGenerationQBitConfig", "PrintingQBitConfig"))
{
assertTrue(composed.contains(expected), expected + " must be composed into the one server instance");
}
assertNotNull(instance.getTable(EchoQBitProducer.SCAN_TABLE));
assertNotNull(instance.getTable(MobileScannersQBitProducer.SCAN));
assertNotNull(instance.getTable("printing_printer"));
assertNotNull(instance.getTable("document_generation_template"));
assertNotNull(instance.getProcess(EchoQBitProducer.PROCESS));
// the old native accounts still work; the passwords supplied at adoption do not
String scanner = login("scanner", scannerPassword);
login("admin", adminPassword);
assertNotEquals(200, loginStatus("scanner", UUID.randomUUID().toString()));
assertEquals(before, snapshot(settings, false), "Adopting the database must not change accounts, devices, credentials or scans.");
// the already approved device credential works against the composed server
assertEquals(200, request("GET", "/qqq/v1/metaData/process/" + EchoQBitProducer.PROCESS, scanner, device).statusCode());
assertEquals(200, request("GET", "/qqq/v1/metaData", scanner, device).statusCode());
assertTrue(List.of(401, 403).contains(request("GET", "/qqq/v1/metaData", scanner, new String[] { device[0], "wrong-proof" }).statusCode()));
}
shutdown(settings);
com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager.resetConnectionProviders();
afterDeviceUse = snapshot(settings, true);
for(String table : List.of("demoUser", "mobileDeviceCredential", "mobileScan"))
{
assertEquals(before.get(table), afterDeviceUse.get(table), table + " must be unchanged by adoption and device use.");
}
com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager.resetConnectionProviders();
// idempotent restart of the same file
EchoDemoSettings again = new EchoDemoSettings(root, freePort(), "echo-adopt", TOKEN, true, null, null, file);
try(EchoDemo demo = new EchoDemo(again, new UnusedRenderer()))
{
demo.initializeDatabase(null, null);
demo.start(again.port());
origin = "http://127.0.0.1:" + again.port();
login("scanner", scannerPassword);
assertEquals(afterDeviceUse, snapshot(again, false), "A second startup must change nothing.");
}
shutdown(again);
}
/***************************************************************************
** Only an existing absolute H2 .mv.db file is accepted, and the canonical
** local server port is allowed only in that explicit mode.
***************************************************************************/
@Test
void existingLocalServerModeRefusesUnsafeDatabasesAndPorts() throws Exception
{
Path file = data.resolve("mobile.mv.db");
Files.writeString(file, "h2");
Path wrong = data.resolve("mobile.h2.db");
Files.writeString(wrong, "h2");
Path directory = Files.createDirectory(data.resolve("directory.mv.db"));
int port = freePort();
for(Path invalid : new Path[] { data.resolve("missing.mv.db"), wrong, directory, Path.of("relative.mv.db"), data.resolve(".mv.db") })
{
assertThrows(IllegalArgumentException.class, () -> new EchoDemoSettings(root, port, "echo-adopt", TOKEN, true, null, null, invalid));
}
// fresh private demos keep every protected port, including the canonical server and peer printing
assertThrows(IllegalArgumentException.class, () -> new EchoDemoSettings(root, 57832, "echo-adopt", TOKEN, true, null, null));
assertThrows(IllegalArgumentException.class, () -> new EchoDemoSettings(root, 57832, "echo-adopt", TOKEN, true, null, null, null));
// adoption permits only the canonical port; peer runtimes stay protected
assertThrows(IllegalArgumentException.class, () -> new EchoDemoSettings(root, 18081, "echo-adopt", TOKEN, true, null, null, file));
assertThrows(IllegalArgumentException.class, () -> new EchoDemoSettings(root, 51631, "echo-adopt", TOKEN, true, null, null, file));
// construction only validates; nothing here binds the canonical port
EchoDemoSettings normal = new EchoDemoSettings(root, 57832, "echo-adopt", TOKEN, true, null, null, file);
String url = EchoDemo.jdbcUrl(normal);
assertTrue(url.contains("IFEXISTS=TRUE") && url.contains("DATABASE_TO_UPPER=FALSE") && url.contains("WRITE_DELAY=0"));
assertTrue(url.endsWith("WRITE_DELAY=0") && url.startsWith("jdbc:h2:file:") && !url.contains(".mv.db"));
assertFalse(normal.toString().contains(TOKEN));
// the environment contract
Map<String, String> environment = Map.of("ECHO_DEMO_ROOT", root.toString(), "ECHO_DEMO_PORT", String.valueOf(port), "ECHO_PRINTING_TENANT", "echo-adopt",
"ECHO_PRINTING_ADMIN_TOKEN", TOKEN, "ECHO_EXISTING_DATABASE", file.toString());
assertEquals(file, EchoDemoSettings.fromLocalEnvironment(environment, null).existingDatabase());
assertEquals(null, EchoDemoSettings.fromLocalEnvironment(Map.of("ECHO_DEMO_ROOT", root.toString(), "ECHO_DEMO_PORT", String.valueOf(port),
"ECHO_PRINTING_TENANT", "echo-adopt", "ECHO_PRINTING_ADMIN_TOKEN", TOKEN), null).existingDatabase());
}
/***************************************************************************
** Baseline: a plain MobileScannersDemo creates accounts, enrolls and
** approves a device through the native routes and records one scan. Returns
** the device id and token, then closes the database file cleanly.
***************************************************************************/
private String[] baseline(Path base, String scannerPassword, String adminPassword) throws Exception
{
String url = "jdbc:h2:file:" + base.toString().replace('\\', '/') + ";DATABASE_TO_UPPER=FALSE;DB_CLOSE_DELAY=-1";
MobileScannersDemo demo = new MobileScannersDemo(url);
demo.initializeDatabase(scannerPassword, adminPassword);
int port = freePort();
QApplicationJavalinServer server = demo.start(port);
origin = "http://127.0.0.1:" + port;
try
{
String scanner = login("scanner", scannerPassword);
String admin = login("admin", adminPassword);
JSONObject enrolled = new JSONObject(post("/qqq/v1/qbit/mobile-scanners/devices/enroll", scanner, new JSONObject().put("installationId", UUID.randomUUID().toString()).toString(), null).body());
String[] device = { enrolled.getString("deviceId"), enrolled.getString("deviceToken") };
JSONObject approval = complete(form("/qqq/v1/processes/mobileDeviceApprove/init", admin,
new JSONObject().put("recordsParam", "recordIds").put("recordIds", device[0]).put("tableName", "mobileDevice"), null), "mobileDeviceApprove", admin, null);
complete(form("/qqq/v1/processes/mobileDeviceApprove/" + approval.getString("processUUID") + "/step/input", admin, new JSONObject(), null),
"mobileDeviceApprove", admin, null);
JSONObject launch = complete(form("/qqq/v1/processes/mobileScanCapture/init", scanner, new JSONObject(), device), "mobileScanCapture", scanner, device);
JSONObject values = new JSONObject().put("barcode", "000123456789").put("symbology", "LABEL-TYPE-CODE128").put("scanSource", "scanner")
.put("eventId", UUID.randomUUID().toString()).put("capturedAt", Instant.now().toString());
complete(form("/qqq/v1/processes/mobileScanCapture/" + launch.getString("processUUID") + "/step/scan", scanner, values, device), "mobileScanCapture", scanner, device);
return device;
}
finally
{
server.stop();
try(Connection connection = DriverManager.getConnection(url, "sa", ""); var statement = connection.createStatement())
{
statement.execute("SHUTDOWN");
}
}
}
/***************************************************************************
** Every column of the identity, device, credential and scan tables, as text.
***************************************************************************/
private static Map<String, List<List<String>>> snapshot(EchoDemoSettings settings, boolean closeAfter) throws Exception
{
Map<String, List<List<String>>> result = new LinkedHashMap<>();
try(Connection connection = DriverManager.getConnection(EchoDemo.jdbcUrl(settings), "sa", ""); var statement = connection.createStatement())
{
for(String table : TABLES)
{
List<List<String>> rows = new ArrayList<>();
try(var set = statement.executeQuery("SELECT * FROM " + table + " ORDER BY 1"))
{
int columns = set.getMetaData().getColumnCount();
while(set.next())
{
List<String> row = new ArrayList<>();
for(int column = 1; column <= columns; column++)
{
row.add(set.getString(column));
}
rows.add(row);
}
}
result.put(table, rows);
}
if(closeAfter)
{
statement.execute("SHUTDOWN");
}
}
return result;
}
/***************************************************************************
** Close the adopted file cleanly so the next open sees only flushed data.
***************************************************************************/
private static void shutdown(EchoDemoSettings settings) throws Exception
{
try(Connection connection = DriverManager.getConnection(EchoDemo.jdbcUrl(settings), "sa", ""); var statement = connection.createStatement())
{
statement.execute("SHUTDOWN");
}
}
/***************************************************************************
** A fresh loopback port that is not any protected local runtime.
***************************************************************************/
private static int freePort() throws Exception
{
while(true)
{
try(ServerSocket socket = new ServerSocket(0, 0, InetAddress.getLoopbackAddress()))
{
if(socket.getLocalPort() >= 1024 && !Set.of(57832, 51631, 18081, 18086, 18443, 19443, 18447, 18448, 18547, 18549, 18593).contains(socket.getLocalPort()))
{
return socket.getLocalPort();
}
}
}
}
/***************************************************************************
** Native Basic login: returns the session cookie value.
***************************************************************************/
private String login(String username, String password) throws Exception
{
HttpResponse<String> response = loginResponse(username, password);
assertEquals(200, response.statusCode(), "Native login failed.");
return response.headers().allValues("Set-Cookie").stream().flatMap(value -> java.net.HttpCookie.parse(value).stream())
.filter(cookie -> "sessionUUID".equals(cookie.getName())).findFirst().orElseThrow().getValue();
}
/***************************************************************************
** Login status only, for a credential that must be refused.
***************************************************************************/
private int loginStatus(String username, String password) throws Exception
{
return loginResponse(username, password).statusCode();
}
/***************************************************************************
** One Basic login request.
***************************************************************************/
private HttpResponse<String> loginResponse(String username, String password) throws Exception
{
String basic = Base64.getEncoder().encodeToString((username + ":" + password).getBytes(StandardCharsets.UTF_8));
return client.send(builder("/qqq/v1/manageSession", null, null).header("Authorization", "Basic " + basic).header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{}")).build(), HttpResponse.BodyHandlers.ofString());
}
/***************************************************************************
** A request with the session cookie and optional device proof headers.
***************************************************************************/
private HttpResponse<String> request(String method, String path, String session, String[] device) throws Exception
{
return client.send(builder(path, session, device).method(method, HttpRequest.BodyPublishers.noBody()).build(), HttpResponse.BodyHandlers.ofString());
}
/***************************************************************************
** A JSON POST.
***************************************************************************/
private HttpResponse<String> post(String path, String session, String body, String[] device) throws Exception
{
return client.send(builder(path, session, device).header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString(body)).build(),
HttpResponse.BodyHandlers.ofString());
}
/***************************************************************************
** The multipart JSON-values body the native process client sends.
***************************************************************************/
private HttpResponse<String> form(String path, String session, JSONObject values, String[] device) throws Exception
{
String boundary = "adopt-" + UUID.randomUUID();
String body = "--" + boundary + "\r\nContent-Disposition: form-data; name=\"values\"\r\n\r\n" + values
+ "\r\n--" + boundary + "\r\nContent-Disposition: form-data; name=\"stepTimeoutMillis\"\r\n\r\n1\r\n";
for(String key : new String[] { "recordsParam", "recordIds", "filterJSON" })
{
if(values.has(key))
{
body += "--" + boundary + "\r\nContent-Disposition: form-data; name=\"" + key + "\"\r\n\r\n" + values.getString(key) + "\r\n";
}
}
body += "--" + boundary + "--\r\n";
return client.send(builder(path, session, device).header("Content-Type", "multipart/form-data; boundary=" + boundary)
.POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString());
}
/***************************************************************************
** Poll an asynchronous process step to completion; an error body fails.
***************************************************************************/
private JSONObject complete(HttpResponse<String> response, String process, String session, String[] device) throws Exception
{
assertEquals(200, response.statusCode(), "Native process request failed.");
JSONObject body = new JSONObject(response.body());
String processId = body.optString("processUUID");
String jobId = body.optString("jobUUID");
long deadline = System.nanoTime() + Duration.ofSeconds(30).toNanos();
while("JOB_STARTED".equals(body.optString("type")) || "RUNNING".equals(body.optString("type")))
{
assertTrue(System.nanoTime() < deadline, "Native process timed out.");
Thread.sleep(25);
response = request("GET", "/qqq/v1/processes/" + process + "/" + processId + "/status/" + jobId, session, device);
assertEquals(200, response.statusCode());
body = new JSONObject(response.body());
}
assertFalse(body.has("error"), "Native process returned an error.");
return body;
}
/***************************************************************************
** Loopback request builder; credentials stay in headers.
***************************************************************************/
private HttpRequest.Builder builder(String path, String session, String[] device)
{
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(origin + path)).timeout(Duration.ofSeconds(20)).header("X-QQQ-Client", "android/qqq-mobile")
.header("Origin", origin);
if(session != null)
{
request.header("Cookie", "sessionUUID=" + session);
}
if(device != null)
{
request.header("X-QQQ-Device-Id", device[0]).header("X-QQQ-Device-Token", device[1]);
}
return request;
}
}
@@ -0,0 +1,350 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo.demo;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import com.kingsrook.qbits.documentgeneration.DocumentGenerationService;
import com.kingsrook.qbits.documentgeneration.GenerationPorts;
import com.kingsrook.qbits.documentgeneration.GenerationTables;
import com.kingsrook.qbits.mobilescanners.demo.MobileScannersDemo;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** The print `copies` column reaches an existing local development database
** through the demo's own migration list, before any request is read, and the
** composed Generation classes accept a count of three. Temporary file databases
** and fresh ports only; the canonical server and its data are never touched.
*******************************************************************************/
class EchoDemoCopiesMigrationTest
{
private static final String TOKEN = UUID.randomUUID() + "-" + UUID.randomUUID();
private static final String REQUEST_TABLE = "document_generation_request";
@TempDir
Path root;
@TempDir
Path data;
/***************************************************************************
** Fixture renderer: migration never renders or validates a template.
***************************************************************************/
private static final class UnusedRenderer implements GenerationPorts.IsolatedRenderer
{
/***********************************************************************
** Catalog text with the owner's required heading.
***********************************************************************/
@Override
public String barcodeReference() throws QException
{
return "# Barcode catalog: qqq-barcodes-9 (copies migration test renderer)";
}
/***********************************************************************
** Not used by migration.
***********************************************************************/
@Override
public void validate(String actor, GenerationPorts.Revision revision) throws QException
{
}
/***********************************************************************
** Migration never renders.
***********************************************************************/
@Override
public GenerationPorts.Content render(GenerationPorts.RenderRequest request) throws QException
{
throw new QException("This migration test never renders.");
}
}
/***************************************************************************
** Each test starts and ends without a cached connection provider.
***************************************************************************/
@BeforeEach
@AfterEach
void isolateConnectionProviders()
{
com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager.resetConnectionProviders();
}
/***************************************************************************
** The shipped list ends with the copies migration, which the owner publishes.
***************************************************************************/
@Test
void copiesMigrationIsPartOfTheDemoMigrationList() throws Exception
{
assertEquals("20261102-print-copies-h2.sql", EchoDemo.GENERATION_MIGRATIONS.getLast());
assertEquals(EchoDemo.GENERATION_MIGRATIONS.size(), Set.copyOf(EchoDemo.GENERATION_MIGRATIONS).size());
try(var resource = EchoDemo.class.getResourceAsStream("/db/migrations/20261102-print-copies-h2.sql"))
{
assertTrue(resource != null, "The Generation owner's copies migration must be on the demo classpath.");
String sql = new String(resource.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
assertTrue(sql.contains("ALTER TABLE " + REQUEST_TABLE + " ADD COLUMN copies"), sql);
}
}
/***************************************************************************
** An existing database from before copies gains the nullable column on
** adoption, keeps its legacy rows untouched (null means one copy), keeps a
** chosen count across repeated migration, and the composed metadata and
** request classes carry the count.
***************************************************************************/
@Test
void adoptingAnOlderDatabaseAddsCopiesAndPreservesRowsAcrossRepeatedMigration() throws Exception
{
Path base = data.resolve("mobile");
Path file = data.resolve("mobile.mv.db");
String url = "jdbc:h2:file:" + base.toString().replace('\\', '/') + ";DATABASE_TO_UPPER=FALSE;DB_CLOSE_DELAY=-1";
MobileScannersDemo baseline = new MobileScannersDemo(url);
baseline.initializeDatabase(UUID.randomUUID().toString(), UUID.randomUUID().toString());
try(Connection connection = DriverManager.getConnection(url, "sa", ""); Statement statement = connection.createStatement())
{
statement.execute("SHUTDOWN");
}
com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager.resetConnectionProviders();
EchoDemoSettings settings = new EchoDemoSettings(root, freePort(), "echo-copies", TOKEN, true, null, null, file);
// a full adoption builds the current schema; then make it an older database, with a fixture legacy request row
adopt(settings);
List<String> legacy;
try(Connection connection = DriverManager.getConnection(EchoDemo.jdbcUrl(settings), "sa", ""); Statement statement = connection.createStatement())
{
assertTrue(columns(statement).contains("copies"), "A fresh adoption already has the owner's current column.");
statement.execute("ALTER TABLE " + REQUEST_TABLE + " DROP COLUMN copies");
assertTrue(!columns(statement).contains("copies"));
insertFixtureRow(connection, "legacy", null);
legacy = row(statement, "legacy");
statement.execute("SHUTDOWN");
}
com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager.resetConnectionProviders();
// adopting the older database migrates it before any request is read
adopt(settings);
List<String> migrated;
try(Connection connection = DriverManager.getConnection(EchoDemo.jdbcUrl(settings), "sa", ""); Statement statement = connection.createStatement())
{
assertTrue(columns(statement).contains("copies"));
try(var set = statement.executeQuery("SELECT IS_NULLABLE, DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE LOWER(TABLE_NAME)='" + REQUEST_TABLE
+ "' AND LOWER(COLUMN_NAME)='copies'"))
{
assertTrue(set.next());
assertEquals("YES", set.getString(1));
assertEquals("INTEGER", set.getString(2));
}
migrated = row(statement, "legacy");
assertEquals(legacy.size() + 1, migrated.size());
assertEquals(legacy, migrated.subList(0, legacy.size()), "The legacy row must be unchanged.");
assertNull(migrated.getLast(), "A row from before copies reads as no count, meaning one copy.");
insertFixtureRow(connection, "chosen", 3);
statement.execute("SHUTDOWN");
}
com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager.resetConnectionProviders();
// repeating the migration changes nothing, including a chosen count
adopt(settings);
adopt(settings);
try(Connection connection = DriverManager.getConnection(EchoDemo.jdbcUrl(settings), "sa", ""); Statement statement = connection.createStatement())
{
assertEquals(migrated, row(statement, "legacy"));
assertEquals("3", row(statement, "chosen").getLast());
try(var set = statement.executeQuery("SELECT COUNT(*) FROM " + REQUEST_TABLE))
{
assertTrue(set.next());
assertEquals(2, set.getInt(1));
}
statement.execute("SHUTDOWN");
}
com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager.resetConnectionProviders();
// the composed metadata and ordinary request classes carry the count
try(EchoDemo demo = new EchoDemo(settings, new UnusedRenderer()))
{
var field = demo.defineQInstance().getTable(GenerationTables.REQUEST).getField("copies");
assertEquals(QFieldType.INTEGER, field.getType());
}
try(Connection connection = DriverManager.getConnection(EchoDemo.jdbcUrl(settings), "sa", ""); Statement statement = connection.createStatement())
{
statement.execute("SHUTDOWN");
}
}
/***************************************************************************
** The composed Generation classes accept three copies on the request and on
** the print intent, default to one for every earlier constructor, and refuse
** counts outside Printing's 1..100 before they can reach admission.
***************************************************************************/
@Test
void composedGenerationRequestAndPrintIntentCarryThreeCopies()
{
GenerationPorts.Source source = new GenerationPorts.Source("echoScan", "scan-1");
GenerationPorts.Stock stock = new GenerationPorts.Stock(101600, 25400, 3000, 3000, 3000, 3000, 300);
GenerationPorts.MediaProfile media = new GenerationPorts.MediaProfile("4x1", "a".repeat(64));
DocumentGenerationService.Expected expected = new DocumentGenerationService.Expected(7, 11, stock);
DocumentGenerationService.Request three = new DocumentGenerationService.Request(7, source, GenerationPorts.Format.ZPL, "echo:scan-1", "printer-row", expected, media, 3);
assertEquals(3, three.copies());
assertEquals(1, new DocumentGenerationService.Request(7, source, GenerationPorts.Format.ZPL, "echo:scan-1", "printer-row", expected, media).copies());
assertEquals(1, new DocumentGenerationService.Request(7, source, GenerationPorts.Format.ZPL, "echo:scan-1", "printer-row", expected, media, null).copies());
assertEquals(3, new GenerationPorts.PrintRequest("op", "actor", source, null, "printer-row", media, 3).copies());
assertEquals(1, new GenerationPorts.PrintRequest("op", "actor", source, null, "printer-row", media).copies());
assertEquals(100, new GenerationPorts.PrintRequest("op", "actor", source, null, "printer-row", media, 100).copies());
for(int invalid : new int[] { 0, -1, 101, Integer.MAX_VALUE })
{
assertThrows(IllegalArgumentException.class, () -> new DocumentGenerationService.Request(7, source, GenerationPorts.Format.ZPL, "echo:scan-1", "printer-row", expected, media, invalid));
assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.PrintRequest("op", "actor", source, null, "printer-row", media, invalid));
}
}
/***************************************************************************
** One adoption of the existing file database, closed cleanly afterwards.
***************************************************************************/
private void adopt(EchoDemoSettings settings) throws Exception
{
try(EchoDemo demo = new EchoDemo(settings, new UnusedRenderer()))
{
demo.initializeDatabase(null, null);
}
try(Connection connection = DriverManager.getConnection(EchoDemo.jdbcUrl(settings), "sa", ""); Statement statement = connection.createStatement())
{
statement.execute("SHUTDOWN");
}
com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager.resetConnectionProviders();
}
/***************************************************************************
** Lower-case column names of the request table.
***************************************************************************/
private static List<String> columns(Statement statement) throws Exception
{
List<String> names = new ArrayList<>();
try(var set = statement.executeQuery("SELECT LOWER(COLUMN_NAME) FROM INFORMATION_SCHEMA.COLUMNS WHERE LOWER(TABLE_NAME)='" + REQUEST_TABLE + "' ORDER BY ORDINAL_POSITION"))
{
while(set.next())
{
names.add(set.getString(1));
}
}
return names;
}
/***************************************************************************
** Test-fixture request row only, with the owner's real physical columns (the
** table's columns other than the identity id are all nullable). The stable
** operation_id identifies it; the state says it is a fixture, so no printed
** output is implied. The count column is set only when one is given.
***************************************************************************/
private static void insertFixtureRow(Connection connection, String tag, Integer copies) throws Exception
{
String sql = "INSERT INTO " + REQUEST_TABLE + " (operation_id, request_key, actor_id, table_name, record_id, kind, format, state"
+ (copies == null ? "" : ", copies") + ") VALUES (?,?,?,?,?,?,?,?" + (copies == null ? "" : ",?") + ")";
try(PreparedStatement insert = connection.prepareStatement(sql))
{
insert.setString(1, operationId(tag));
insert.setString(2, "fixture:" + tag);
insert.setString(3, "fixture-actor");
insert.setString(4, "echoScan");
insert.setString(5, "fixture-" + tag);
insert.setString(6, "FIXTURE");
insert.setString(7, "ZPL");
insert.setString(8, "FIXTURE");
if(copies != null)
{
insert.setInt(9, copies);
}
insert.executeUpdate();
}
}
/***************************************************************************
** The stable operation id of one fixture row.
***************************************************************************/
private static String operationId(String tag)
{
return "fixture-op-" + tag;
}
/***************************************************************************
** The fixture row with this tag's operation id, every column as text.
***************************************************************************/
private static List<String> row(Statement statement, String tag) throws Exception
{
try(var set = statement.executeQuery("SELECT * FROM " + REQUEST_TABLE + " WHERE operation_id='" + operationId(tag) + "'"))
{
assertTrue(set.next(), "The tagged fixture row is missing.");
List<String> result = new ArrayList<>();
for(int column = 1; column <= set.getMetaData().getColumnCount(); column++)
{
result.add(set.getString(column));
}
assertTrue(!set.next(), "The tagged fixture row is not unique.");
return result;
}
}
/***************************************************************************
** A fresh loopback port that is not any protected local runtime.
***************************************************************************/
private static int freePort() throws Exception
{
while(true)
{
try(ServerSocket socket = new ServerSocket(0, 0, InetAddress.getLoopbackAddress()))
{
if(socket.getLocalPort() >= 1024 && !Set.of(57832, 51631, 18081, 18086, 18443, 19443, 18447, 18448, 18547, 18549, 18593).contains(socket.getLocalPort()))
{
return socket.getLocalPort();
}
}
}
}
}
@@ -0,0 +1,59 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo.demo;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** The loopback host guard, including the native client's USB-forwarded Host.
*******************************************************************************/
class EchoDemoHostGuardTest
{
/***************************************************************************
** The device-side Host 127.0.0.1:8080 reaches the server on 57832 through adb
** reverse and must be allowed for public status and login, as must the
** listening port itself and the localhost name.
***************************************************************************/
@Test
void forwardedLoopbackHostPortIsAcceptedAndEverythingElseStaysDenied()
{
for(String host : new String[] { "127.0.0.1:8080", "127.0.0.1:57832", "localhost:8080", "LOCALHOST:8080", "127.0.0.1:1", "127.0.0.1:65535" })
{
assertTrue(EchoDemo.isLoopbackAuthority(host), host);
assertNull(EchoDemo.refusal(host, null, null, "GET", false), "public status over " + host);
assertNull(EchoDemo.refusal(host, "http://" + host, "same-origin", "POST", true), "login over " + host);
}
// not a literal loopback name with an explicit valid port
for(String host : new String[] { null, "", "127.0.0.1", "localhost", "127.0.0.1:", "127.0.0.1:0", "127.0.0.1:65536", "127.0.0.1:99999", "127.0.0.1:080",
"127.0.0.1:80a", "127.0.0.1:-1", "127.0.0.1:8080/path", "127.0.0.1:8080?x=1", "user@127.0.0.1:8080", "127.0.0.1:8080@evil.test", "evil.test:8080",
"127.0.0.1.evil.test:8080", "localhost.evil.test:8080", "[::1]:8080", "0.0.0.0:8080", "127.0.0.2:8080", " 127.0.0.1:8080", "127.0.0.1:8080 ", "127.0.0.1:8080:9" })
{
assertFalse(EchoDemo.isLoopbackAuthority(host), String.valueOf(host));
assertNotNull(EchoDemo.refusal(host, null, null, "GET", false), String.valueOf(host));
}
// Origin must be exactly http://<Host>, whatever the forwarded port is
assertNotNull(EchoDemo.refusal("127.0.0.1:8080", "http://127.0.0.1:57832", null, "POST", true));
assertNotNull(EchoDemo.refusal("127.0.0.1:8080", "http://localhost:8080", null, "GET", false));
assertNotNull(EchoDemo.refusal("127.0.0.1:8080", "http://evil.test", null, "GET", false));
assertNotNull(EchoDemo.refusal("127.0.0.1:8080", "https://127.0.0.1:8080", null, "GET", false));
// cross-site fetches and cookie mutations without an Origin stay denied
assertEquals("Cross-site access to the Echo demo is not allowed", EchoDemo.refusal("127.0.0.1:8080", null, "cross-site", "GET", false));
assertEquals("Cross-site access to the Echo demo is not allowed", EchoDemo.refusal("127.0.0.1:8080", "http://127.0.0.1:8080", "Cross-Site", "POST", false));
assertEquals("Cookie-authenticated changes require a same-origin request", EchoDemo.refusal("127.0.0.1:8080", null, null, "POST", true));
// credentials without cookies and safe methods remain usable
assertNull(EchoDemo.refusal("127.0.0.1:8080", null, null, "POST", false));
assertNull(EchoDemo.refusal("127.0.0.1:8080", null, "none", "GET", true));
}
}
@@ -0,0 +1,117 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo.demo;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** Deployment inputs are refused before any database, server or renderer exists.
*******************************************************************************/
class EchoDemoSettingsTest
{
private static final String TOKEN = UUID.randomUUID() + "-" + UUID.randomUUID();
@TempDir
Path root;
/***************************************************************************
** Everything but the renderer.
***************************************************************************/
private Map<String, String> environment()
{
Map<String, String> environment = new HashMap<>();
environment.put("ECHO_DEMO_ROOT", root.toString());
environment.put("ECHO_DEMO_PORT", "48123");
environment.put("ECHO_PRINTING_TENANT", "echo-demo");
environment.put("ECHO_PRINTING_ADMIN_TOKEN", TOKEN);
return environment;
}
/***************************************************************************
** Without the owner's cold bridge configuration there is no renderer, and
** the message names the variables rather than any configured value.
***************************************************************************/
@Test
void missingRendererConfigurationIsRefusedWithoutEchoingValues()
{
IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, () -> EchoDemoSettings.fromEnvironment(environment(), null));
assertTrue(failure.getMessage().contains("ECHO_RENDERER_"));
assertFalse(failure.getMessage().contains(TOKEN));
}
/***************************************************************************
** A mutable image tag is not a digest pin and is refused by the owner record.
***************************************************************************/
@Test
void mutableImageIsRefused()
{
Map<String, String> environment = environment();
environment.put("ECHO_RENDERER_DOCKER", root.resolve("docker").toString());
environment.put("ECHO_RENDERER_IMAGE", "renderer:latest");
assertThrows(IllegalArgumentException.class, () -> EchoDemoSettings.fromEnvironment(environment, null));
}
/***************************************************************************
** Relative roots, protected ports and weak tokens never reach a running host.
***************************************************************************/
@Test
void unsafeInputsAreRefused()
{
assertThrows(IllegalArgumentException.class, () -> new EchoDemoSettings(Path.of("relative"), 48123, "t", TOKEN, false, null, null));
assertThrows(IllegalArgumentException.class, () -> new EchoDemoSettings(root, 18081, "t", TOKEN, false, null, null));
assertThrows(IllegalArgumentException.class, () -> new EchoDemoSettings(root, 80, "t", TOKEN, false, null, null));
assertThrows(IllegalArgumentException.class, () -> new EchoDemoSettings(root, 48123, "t", "short", false, null, null));
assertThrows(IllegalArgumentException.class, () -> new EchoDemoSettings(root, 48123, "bad tenant", TOKEN, false, null, null));
}
/***************************************************************************
** Settings never render the administrator token.
***************************************************************************/
@Test
void tokenIsNotRendered()
{
EchoDemoSettings settings = new EchoDemoSettings(root, 48123, "t", TOKEN, false, null, null);
assertFalse(settings.toString().contains(TOKEN));
}
/***************************************************************************
** Scanner grants are exactly the owner-required operator set; configure and
** publish belong only to the admin additions, and no administration name
** appears anywhere.
***************************************************************************/
@Test
void permissionSetsAreLeastPrivilege()
{
assertEquals(List.of("echo.use", "printing_operator", "document-generation.view", "document-generation.generate", "document-generation.print",
"record-files.view", "record-files.manage", "echoScan.attach"), EchoDemoAuthentication.SCANNER);
assertEquals(List.of("printing_admin", "document-generation.configure", "document-generation.publish"), EchoDemoAuthentication.ADMIN_ADDITIONS);
assertFalse(EchoDemoAuthentication.SCANNER.stream().anyMatch(name -> name.endsWith(".configure") || name.endsWith(".publish") || name.endsWith("admin")));
}
}
@@ -0,0 +1,227 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo.demo;
import java.net.ServerSocket;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.DriverManager;
import java.time.Duration;
import java.util.Base64;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import com.kingsrook.qbits.documentgeneration.GenerationPorts;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** Real HTTP startup of the public launcher class over an isolated file database.
** The renderer refuses every call, no printer is enrolled and no template exists,
** so this test cannot render or print. It proves composition, schema, accounts
** and route registration only.
*******************************************************************************/
class EchoDemoStartupTest
{
private static final String TOKEN = UUID.randomUUID() + "-" + UUID.randomUUID();
private static final String SCANNER_PASSWORD = "scanner-test-password-1";
private static final String ADMIN_PASSWORD = "admin-test-password-1";
private static final Set<Integer> PROTECTED = Set.of(57832, 51631, 18081, 18086, 18443, 19443, 18447, 18448, 18547, 18549, 18593);
@TempDir
Path root;
private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
/***************************************************************************
** Counts calls so the test can show nothing was ever rendered.
***************************************************************************/
private static final class RefusingRenderer implements GenerationPorts.IsolatedRenderer
{
private final AtomicInteger calls = new AtomicInteger();
/***********************************************************************
** Never provides a reference.
***********************************************************************/
@Override
public String barcodeReference() throws QException
{
calls.incrementAndGet();
throw new QException("The startup test renderer refuses every call.");
}
/***********************************************************************
** Never validates.
***********************************************************************/
@Override
public void validate(String actor, GenerationPorts.Revision revision) throws QException
{
calls.incrementAndGet();
throw new QException("The startup test renderer refuses every call.");
}
/***********************************************************************
** Never renders.
***********************************************************************/
@Override
public GenerationPorts.Content render(GenerationPorts.RenderRequest request) throws QException
{
calls.incrementAndGet();
throw new QException("The startup test renderer refuses every call.");
}
}
/***************************************************************************
** A fresh port that is neither privileged nor a protected local runtime.
***************************************************************************/
private static int freePort() throws Exception
{
while(true)
{
try(ServerSocket socket = new ServerSocket(0))
{
if(socket.getLocalPort() >= 1024 && !PROTECTED.contains(socket.getLocalPort()))
{
return socket.getLocalPort();
}
}
}
}
/***************************************************************************
** The RDBMS connection providers are cached per backend name for the whole
** JVM, and every composed host here names its backend "mobileDb". Drop any
** provider left by an earlier test so this host reads its own database.
***************************************************************************/
@BeforeEach
@AfterEach
void isolateConnectionProviders()
{
com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager.resetConnectionProviders();
}
/***************************************************************************
** Compose, initialize and serve; then observe over real HTTP and JDBC.
***************************************************************************/
@Test
void startsComposedHostOverIsolatedDatabaseWithoutOutput() throws Exception
{
int port = freePort();
EchoDemoSettings settings = new EchoDemoSettings(root, port, "echo-test", TOKEN, false, null, null);
RefusingRenderer renderer = new RefusingRenderer();
try(EchoDemo demo = new EchoDemo(settings, renderer))
{
demo.initializeDatabase(SCANNER_PASSWORD, ADMIN_PASSWORD);
demo.start(port);
QInstance instance = demo.defineQInstance();
assertNotNull(instance.getApp("echo"));
assertNotNull(instance.getProcess("echoPrint"));
assertNotNull(instance.getTable("echoScan"));
assertNotNull(instance.getTable("printing_printer"));
assertNotNull(MiddlewareSpecExtensionsMetaData.ofOrWithNew(instance).getExtensions().get("printing"));
assertTrue(Files.isDirectory(root.resolve("objects")));
QSession scanner = EchoDemoAuthentication.grant(instance, new QSession().withUser(new QUser().withIdReference("scanner")));
QSession admin = EchoDemoAuthentication.grant(instance, new QSession().withUser(new QUser().withIdReference("admin")));
QSession stranger = EchoDemoAuthentication.grant(instance, new QSession().withUser(new QUser().withIdReference("other")));
assertTrue(scanner.hasPermission("printing_operator") && scanner.hasPermission("echo.use"));
assertFalse(scanner.hasPermission("printing_admin") || scanner.hasPermission("document-generation.publish"));
assertTrue(admin.hasPermission("printing_admin") && admin.hasPermission("document-generation.publish"));
assertFalse(stranger.hasPermission("echo.use") || stranger.hasPermission("printing_operator"));
HttpResponse<String> version = send(port, "/printing/v1/version", null, null);
assertEquals(200, version.statusCode());
assertTrue(version.body().contains("protocolVersion"));
assertNotEquals(200, send(port, "/qqq/v1/metaData", null, null).statusCode());
assertEquals(200, login(port, "scanner", SCANNER_PASSWORD).statusCode());
assertNotEquals(200, login(port, "scanner", "not-the-password-1").statusCode());
assertEquals(0, renderer.calls.get());
try(var connection = DriverManager.getConnection(EchoDemo.jdbcUrl(settings), "sa", ""); var statement = connection.createStatement();
var rows = statement.executeQuery("SELECT COUNT(*) FROM printing_job"))
{
rows.next();
assertEquals(0, rows.getInt(1));
}
}
finally
{
try(var connection = DriverManager.getConnection(EchoDemo.jdbcUrl(settings), "sa", ""); var statement = connection.createStatement())
{
statement.execute("SHUTDOWN");
}
}
}
/***************************************************************************
** A Basic-authenticated native login, exactly as the existing fixtures do.
***************************************************************************/
private HttpResponse<String> login(int port, String user, String password) throws Exception
{
String basic = Base64.getEncoder().encodeToString((user + ":" + password).getBytes(StandardCharsets.UTF_8));
return send(port, "/qqq/v1/manageSession", "Basic " + basic, "{}");
}
/***************************************************************************
** One loopback request; a body makes it a JSON POST.
***************************************************************************/
private HttpResponse<String> send(int port, String path, String authorization, String body) throws Exception
{
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + path)).timeout(Duration.ofSeconds(20))
.header("X-QQQ-Client", "android/qqq-mobile").header("Origin", "http://127.0.0.1:" + port);
if(authorization != null)
{
request.header("Authorization", authorization);
}
if(body == null)
{
request.GET();
}
else
{
request.header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString(body));
}
return client.send(request.build(), HttpResponse.BodyHandlers.ofString());
}
}
@@ -0,0 +1,385 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo.demo;
import java.io.Serializable;
import java.net.ServerSocket;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.time.Duration;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import com.kingsrook.examples.echo.EchoQBitProducer;
import com.kingsrook.qbits.documentgeneration.GenerationPorts;
import com.kingsrook.qbits.mobilescanners.recordfiles.MobileCaptureIdentityProvider;
import com.kingsrook.qbits.mobilescanners.recordfiles.RecordFilesCaptureWidgetData;
import com.kingsrook.qbits.recordfiles.RecordFilesQBitConfig;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.backend.core.modules.authentication.implementations.TableBasedAuthenticationModule;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** The mobile Record Files composition in the live Echo host: advertised
** extensions, ordinary semantic metadata, and server-side target binding. The
** renderer refuses every call and no photo is uploaded; real camera/gallery
** upload is a separate, root-run device qualification. Seeded rows exist only
** in this test's private temporary database.
*******************************************************************************/
class EchoPhotoCaptureTest
{
private static final String TOKEN = UUID.randomUUID() + "-" + UUID.randomUUID();
private static final String SCANNER_PASSWORD = "scanner-test-password-1";
private static final String ADMIN_PASSWORD = "admin-test-password-1";
private static final String OWN_ID = "own-scan-0001";
private static final String OTHER_ID = "other-scan-0001";
private static final Set<Integer> PROTECTED = Set.of(57832, 51631, 18081, 18086, 18443, 19443, 18447, 18448, 18547, 18549, 18593);
@TempDir
Path root;
private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
/***************************************************************************
** The host's renderer is never reached by photo capture.
***************************************************************************/
private static final class RefusingRenderer implements GenerationPorts.IsolatedRenderer
{
/***********************************************************************
** Never provides a reference.
***********************************************************************/
@Override
public String barcodeReference() throws QException
{
throw new QException("The photo test renderer refuses every call.");
}
/***********************************************************************
** Never validates.
***********************************************************************/
@Override
public void validate(String actor, GenerationPorts.Revision revision) throws QException
{
throw new QException("The photo test renderer refuses every call.");
}
/***********************************************************************
** Never renders.
***********************************************************************/
@Override
public GenerationPorts.Content render(GenerationPorts.RenderRequest request) throws QException
{
throw new QException("The photo test renderer refuses every call.");
}
}
/***************************************************************************
** A fresh port that is neither privileged nor a protected local runtime.
***************************************************************************/
private static int freePort() throws Exception
{
while(true)
{
try(ServerSocket socket = new ServerSocket(0))
{
if(socket.getLocalPort() >= 1024 && !PROTECTED.contains(socket.getLocalPort()))
{
return socket.getLocalPort();
}
}
}
}
/***************************************************************************
** Connection providers are cached per backend name for the JVM; drop any
** left by another test so this host reads its own database.
***************************************************************************/
@BeforeEach
@AfterEach
void isolateConnectionProviders()
{
QContext.clear();
com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager.resetConnectionProviders();
}
/***************************************************************************
** Extensions, upload policy, catalog placement and ordinary metadata.
***************************************************************************/
@Test
void composesAdvertisedExtensionsAndOrdinaryMetadata() throws Exception
{
int port = freePort();
EchoDemoSettings settings = new EchoDemoSettings(root, port, "echo-photo-test", TOKEN, false, null, null);
try(EchoDemo demo = new EchoDemo(settings, new RefusingRenderer()))
{
demo.initializeDatabase(SCANNER_PASSWORD, ADMIN_PASSWORD);
demo.start(port);
QInstance instance = demo.defineQInstance();
var extensions = MiddlewareSpecExtensionsMetaData.ofOrWithNew(instance).getExtensions();
assertNotNull(extensions.get("record-files"), "the versioned upload API must be advertised");
assertNotNull(extensions.get("mobile-record-files"), "the native widget vocabulary must be advertised");
assertNotNull(extensions.get("mobile-scanners"), "the mobile contract is unchanged");
assertNotNull(extensions.get("printing"), "existing Echo composition stays registered");
RecordFilesQBitConfig config = (RecordFilesQBitConfig) instance.getQBits().values().stream().map(bit -> bit.getConfig())
.filter(RecordFilesQBitConfig.class::isInstance).findFirst().orElseThrow();
assertEquals(List.of(EchoQBitProducer.SCAN_TABLE), config.getParentTables());
assertEquals(Set.of("image/jpeg", "image/png"), config.getUploadConfiguration().limits().acceptedMediaTypes());
assertTrue(config.getUploadConfiguration().createIdentityProvider() instanceof MobileCaptureIdentityProvider);
assertTrue(config.getUploadConfiguration().createStorage() instanceof LocalStagingChunkStorage);
assertTrue(instance.getApp(EchoQBitProducer.APP).getSections().getFirst().getProcesses().containsAll(List.of(EchoQBitProducer.PROCESS, EchoPhotoCapture.PROCESS)));
var process = instance.getProcess(EchoPhotoCapture.PROCESS);
assertEquals(EchoPhotoCapture.Permissions.class.getName(), process.getPermissionRules().getCustomPermissionChecker().getName());
assertEquals(EchoPhotoCapture.Permissions.class.getName(), instance.getWidget(EchoPhotoCapture.WIDGET).getPermissionRules().getCustomPermissionChecker().getName());
assertEquals(3, process.getStepList().size());
QFrontendStepMetaData select = (QFrontendStepMetaData) process.getStepList().get(0);
assertEquals(List.of(EchoPhotoCapture.SELECTION_FIELD), select.getFormFields().stream().map(field -> field.getName()).toList(),
"the client chooses a record only; it has no table or other record fields to pass");
assertEquals(EchoPhotoCapture.CHOICES, select.getFormFields().getFirst().getPossibleValueSourceName());
assertEquals(EchoPhotoCapture.WIDGET, ((QFrontendStepMetaData) process.getStepList().get(2)).getComponents().getFirst().getValues().get("widgetName"));
HttpResponse<String> contract = send(port, "/qqq/v1/qbit/mobile-record-files/contract");
assertEquals(200, contract.statusCode());
assertTrue(contract.body().contains("RECORD_FILES_CAPTURE") && contract.body().contains("\"1.0.0\""));
}
finally
{
shutdown(settings);
}
}
/***************************************************************************
** The widget's parent comes only from a record this actor may see and attach
** to, and is built from that record on the fixed Echo scan table.
***************************************************************************/
@Test
void bindsTargetServerSideAndRejectsForgedOrInvisibleRecords() throws Exception
{
int port = freePort();
EchoDemoSettings settings = new EchoDemoSettings(root, port, "echo-photo-test", TOKEN, false, null, null);
try(EchoDemo demo = new EchoDemo(settings, new RefusingRenderer()))
{
demo.initializeDatabase(SCANNER_PASSWORD, ADMIN_PASSWORD);
QInstance instance = demo.defineQInstance();
seed(settings);
// a real login through the demo's authentication module: its session is stored and revalidated
// by every native action, so ParentRecordAccess and the record lock run exactly as for a user
QSession scanner = login(instance, "scanner", SCANNER_PASSWORD);
QSession admin = login(instance, "admin", ADMIN_PASSWORD);
QContext.init(instance, scanner);
RecordFilesCaptureWidgetData widget = EchoPhotoCapture.widgetFor(OWN_ID);
@SuppressWarnings("unchecked") Map<String, Serializable> parent = (Map<String, Serializable>) ((Map<String, Serializable>) widget.getSubBlocks().getFirst().get("values")).get("parent");
assertEquals(Map.of("tableName", EchoQBitProducer.SCAN_TABLE, "recordId", OWN_ID), parent);
assertThrows(QNotFoundException.class, () -> EchoPhotoCapture.widgetFor(OTHER_ID), "another actor's scan is invisible");
assertThrows(QNotFoundException.class, () -> EchoPhotoCapture.widgetFor("no-such-scan"), "an unknown record id is not found");
assertThrows(QBadRequestException.class, () -> EchoPhotoCapture.widgetFor(null));
assertThrows(QBadRequestException.class, () -> EchoPhotoCapture.widgetFor(" "));
assertThrows(QBadRequestException.class, () -> EchoPhotoCapture.widgetFor("a".repeat(65)));
assertThrows(QBadRequestException.class, () -> EchoPhotoCapture.widgetFor("bad\nid"));
// The choice list is the actor's own retained scans only, labelled without the record id.
var choice = new EchoPhotoCapture.Choices().getPossibleValue(OWN_ID);
assertNotNull(choice);
assertEquals("001234567890 (LABEL-TYPE-CODE128)", choice.getLabel());
assertNull(new EchoPhotoCapture.Choices().getPossibleValue(OTHER_ID));
assertEquals("12?3 (QRCODE)", EchoPhotoCapture.label(new QRecord().withValue("barcode", "12\u001d3").withValue("symbology", "QRCODE")));
// Rendering the widget directly cannot name a parent.
assertThrows(QBadRequestException.class, () -> new EchoPhotoCapture.Widget().render(null));
// A different, validly signed-in actor cannot reach the scanner's scan, and the scanner's own choice is not theirs.
QContext.init(instance, admin);
assertThrows(QNotFoundException.class, () -> EchoPhotoCapture.widgetFor(OWN_ID), "another signed-in actor must not see this scan");
assertNull(new EchoPhotoCapture.Choices().getPossibleValue(OWN_ID));
// Without a valid session the call is refused, never served.
QContext.init(instance, new QSession().withUser(new QUser().withIdReference("scanner")).withSecurityKeyValue("qqq.session.userId", "scanner"));
assertThrows(QException.class, () -> EchoPhotoCapture.widgetFor(OWN_ID), "a hand-built session is not a login");
}
finally
{
shutdown(settings);
}
}
/***************************************************************************
** The process and its widget require Echo access, the attach grant and an
** approved device; a plain authenticated session has none of the device.
***************************************************************************/
@Test
void refusesSessionsWithoutEchoAttachOrApprovedDevice() throws Exception
{
int port = freePort();
EchoDemoSettings settings = new EchoDemoSettings(root, port, "echo-photo-test", TOKEN, false, null, null);
try(EchoDemo demo = new EchoDemo(settings, new RefusingRenderer()))
{
demo.initializeDatabase(SCANNER_PASSWORD, ADMIN_PASSWORD);
QInstance instance = demo.defineQInstance();
EchoPhotoCapture.Permissions permissions = new EchoPhotoCapture.Permissions();
QContext.init(instance, actor(instance, "scanner"));
assertThrows(QPermissionDeniedException.class, () -> permissions.checkPermissionsThrowing(null, null), "no verified device in this context");
QContext.init(instance, actor(instance, "other"));
assertThrows(QPermissionDeniedException.class, () -> permissions.checkPermissionsThrowing(null, null), "an account without Echo access");
assertThrows(QException.class, () -> EchoPhotoCapture.widgetFor(OWN_ID), "and no record is selectable for it");
}
finally
{
shutdown(settings);
}
}
/***************************************************************************
** Sign in through the host's real authentication module with the account's
** password. The module stores the session and later revalidates it. The
** approved-device layer, absent from a direct call, is what pins the actor
** key Echo's record lock reads, so only that key is added here.
***************************************************************************/
private static QSession login(QInstance instance, String user, String password) throws Exception
{
QContext.setQInstance(instance);
String basic = Base64.getEncoder().encodeToString((user + ":" + password).getBytes(StandardCharsets.UTF_8));
QSession session = new EchoDemoAuthentication().createSession(instance, Map.of(TableBasedAuthenticationModule.BASIC_AUTH_KEY, basic));
assertEquals(user, session.getUser().getIdReference());
return session.withSecurityKeyValue("qqq.session.userId", user);
}
/***************************************************************************
** Grants exactly as login does, plus the actor key Echo's record lock reads.
** Used only where no stored session is needed: permission checks on context.
***************************************************************************/
private static QSession actor(QInstance instance, String user)
{
QSession session = EchoDemoAuthentication.grant(instance, new QSession().withUser(new QUser().withIdReference(user)));
return session.withSecurityKeyValue("qqq.session.userId", user);
}
/***************************************************************************
** Two synthetic retained scans in this test's private database: one for the
** scanner and one for another actor. They are never printed or published.
***************************************************************************/
private static void seed(EchoDemoSettings settings) throws Exception
{
try(Connection connection = DriverManager.getConnection(EchoDemo.jdbcUrl(settings), "sa", ""))
{
for(String actor : List.of("scanner", "other-actor"))
{
try(PreparedStatement run = connection.prepareStatement("INSERT INTO echo_run (id,actorId,deviceId,publicSessionId,printerRecordId,mediaId,state) VALUES (?,?,?,?,?,?,?)"))
{
List<String> values = List.of("run-" + actor, actor, "device-1", "session-1", "printer-1", "media-1", "OPEN");
for(int index = 0; index < values.size(); index++)
{
run.setString(index + 1, values.get(index));
}
run.executeUpdate();
}
}
insertScan(connection, OWN_ID, "run-scanner", "scanner", "001234567890", "LABEL-TYPE-CODE128");
insertScan(connection, OTHER_ID, "run-other-actor", "other-actor", "OTHER-VALUE", "QRCODE");
}
}
/***************************************************************************
** One retained scan row with every required column.
***************************************************************************/
private static void insertScan(Connection connection, String id, String run, String actor, String barcode, String symbology) throws Exception
{
try(PreparedStatement scan = connection.prepareStatement("INSERT INTO echo_scan (id,runId,eventId,actorId,deviceId,mobileScanId,barcode,symbology,rendererSymbology,scanSource,capturedAt,receivedAt,state) "
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)"))
{
List<String> values = List.of(id, run, "event-" + id, actor, "device-1", "mobile-" + id, barcode, symbology, "code128", "test", "2026-11-02T00:00:00Z", "2026-11-02T00:00:01Z", "QUEUED");
for(int index = 0; index < values.size(); index++)
{
scan.setString(index + 1, values.get(index));
}
scan.executeUpdate();
}
}
/***************************************************************************
** Release the private database file.
***************************************************************************/
private static void shutdown(EchoDemoSettings settings) throws Exception
{
try(Connection connection = DriverManager.getConnection(EchoDemo.jdbcUrl(settings), "sa", ""); Statement statement = connection.createStatement())
{
statement.execute("SHUTDOWN");
}
}
/***************************************************************************
** One unauthenticated loopback GET with the native client channel.
***************************************************************************/
private HttpResponse<String> send(int port, String path) throws Exception
{
HttpRequest request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + path)).timeout(Duration.ofSeconds(20))
.header("X-QQQ-Client", "android/qqq-mobile").header("Origin", "http://127.0.0.1:" + port).GET().build();
return client.send(request, HttpResponse.BodyHandlers.ofString());
}
}
@@ -0,0 +1,136 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo.demo;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** The private object files keep every object under the root, whole and immutable.
*******************************************************************************/
class PrivateObjectFilesTest
{
@TempDir
Path root;
/***************************************************************************
** Small fixed content as a stream.
***************************************************************************/
private static ByteArrayInputStream bytes(String value)
{
return new ByteArrayInputStream(value.getBytes(java.nio.charset.StandardCharsets.UTF_8));
}
/***************************************************************************
** Bytes and owner metadata come back exactly; no temporary files remain.
***************************************************************************/
@Test
void roundTripKeepsBytesAndMetadataAndLeavesNoTemporaryFiles() throws IOException
{
PrivateObjectFiles files = new PrivateObjectFiles(root);
files.create("canonical", "owner/key=1", bytes("^XA^XZ"), 1024, Map.of("sha256", "abc"));
var stored = files.find("canonical", "owner/key=1").orElseThrow();
assertEquals(6, stored.size());
assertArrayEquals("^XA^XZ".getBytes(java.nio.charset.StandardCharsets.UTF_8), Files.readAllBytes(stored.file()));
assertEquals(Map.of("sha256", "abc"), stored.metadata());
try(Stream<Path> all = Files.walk(root))
{
assertFalse(all.anyMatch(path -> path.getFileName().toString().startsWith(".incoming-")));
}
}
/***************************************************************************
** An existing object is never replaced, even by identical bytes.
***************************************************************************/
@Test
void existingObjectIsNeverReplaced() throws IOException
{
PrivateObjectFiles files = new PrivateObjectFiles(root);
files.create("canonical", "k", bytes("one"), 1024, Map.of());
assertThrows(FileAlreadyExistsException.class, () -> files.create("canonical", "k", bytes("two"), 1024, Map.of()));
assertEquals(3, files.find("canonical", "k").orElseThrow().size());
}
/***************************************************************************
** Oversize content is refused and leaves no object behind.
***************************************************************************/
@Test
void oversizeContentIsRefusedWithoutAnObject() throws IOException
{
PrivateObjectFiles files = new PrivateObjectFiles(root);
assertThrows(IOException.class, () -> files.create("canonical", "big", bytes("0123456789"), 5, Map.of()));
assertTrue(files.find("canonical", "big").isEmpty());
}
/***************************************************************************
** Keys are hashed, so traversal text cannot leave the root or alias another
** object, and different buckets never share a key.
***************************************************************************/
@Test
void keysCannotTraverseOrAlias() throws IOException
{
PrivateObjectFiles files = new PrivateObjectFiles(root);
Path hostile = files.file("canonical", "../../outside\\..\\x");
assertTrue(hostile.normalize().startsWith(root));
assertNotEquals(files.file("canonical", "k"), files.file("staging", "k"));
assertNotEquals(files.file("canonical", "a/b"), files.file("canonical", "a//b"));
assertThrows(IllegalArgumentException.class, () -> files.file("../canonical", "k"));
assertThrows(IllegalArgumentException.class, () -> files.file("canonical", ""));
assertThrows(IllegalArgumentException.class, () -> files.file("canonical", "x".repeat(2049)));
}
/***************************************************************************
** Delete removes the object and its metadata; absent is not an error.
***************************************************************************/
@Test
void deleteRemovesObjectAndMetadata() throws IOException
{
PrivateObjectFiles files = new PrivateObjectFiles(root);
files.create("canonical", "k", bytes("one"), 1024, Map.of());
files.delete("canonical", "k");
files.delete("canonical", "k");
assertTrue(files.find("canonical", "k").isEmpty());
}
/***************************************************************************
** The root itself must be an existing absolute directory.
***************************************************************************/
@Test
void rootMustBeAnExistingAbsoluteDirectory()
{
assertThrows(IllegalArgumentException.class, () -> new PrivateObjectFiles(Path.of("relative")));
assertThrows(IllegalArgumentException.class, () -> new PrivateObjectFiles(root.resolve("missing")));
}
}
@@ -0,0 +1,246 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo.demo;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** The explicit trusted local renderer is opt-in and validated; the production
** configuration stays mandatory. No test starts Docker or a server.
*******************************************************************************/
class TrustedLocalRendererSettingsTest
{
private static final String TOKEN = UUID.randomUUID() + "-" + UUID.randomUUID();
private static final String IMAGE = "sha256:" + "a".repeat(64);
@TempDir
Path root;
private Path docker;
/***************************************************************************
** A regular file stands in for the Docker executable; it is never run.
***************************************************************************/
@BeforeEach
void setup() throws IOException
{
docker = Files.createFile(root.resolve("docker-stand-in"));
}
/***************************************************************************
** Shared settings only: no production renderer variables.
***************************************************************************/
private Map<String, String> shared()
{
Map<String, String> environment = new HashMap<>();
environment.put("ECHO_DEMO_ROOT", root.toString());
environment.put("ECHO_DEMO_PORT", "48123");
environment.put("ECHO_PRINTING_TENANT", "echo-demo");
environment.put("ECHO_PRINTING_ADMIN_TOKEN", TOKEN);
return environment;
}
/***************************************************************************
** The complete explicit local opt-in.
***************************************************************************/
private Map<String, String> local()
{
Map<String, String> environment = shared();
environment.put("ECHO_LOCAL_RENDERER", "TRUSTED_LOCAL_DEVELOPMENT");
environment.put("ECHO_LOCAL_RENDERER_DOCKER", docker.toString());
environment.put("ECHO_LOCAL_RENDERER_IMAGE_ID", IMAGE);
return environment;
}
/***************************************************************************
** Local variables never satisfy the production settings, which still require
** the owner's cold bridge configuration; there is no automatic fallback.
***************************************************************************/
@Test
void productionConfigurationStillRequiresTheColdBridge()
{
IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, () -> EchoDemoSettings.fromEnvironment(local(), null));
assertTrue(failure.getMessage().contains("ECHO_RENDERER_"));
assertFalse(failure.getMessage().contains(TOKEN));
}
/***************************************************************************
** Local settings carry no cold bridge, so the production EchoDemo refuses
** them before any database or renderer exists.
***************************************************************************/
@Test
void localSettingsAreRefusedByTheProductionHost()
{
EchoDemoSettings settings = EchoDemoSettings.fromLocalEnvironment(local(), null);
assertNull(settings.renderer());
assertEquals(48123, settings.port());
assertThrows(IllegalArgumentException.class, () -> new EchoDemo(settings));
}
/***************************************************************************
** The shared parser still applies the shared safety checks.
***************************************************************************/
@Test
void sharedSettingsKeepTheirValidation()
{
Map<String, String> weak = local();
weak.put("ECHO_PRINTING_ADMIN_TOKEN", "short");
assertThrows(IllegalArgumentException.class, () -> EchoDemoSettings.fromLocalEnvironment(weak, null));
assertThrows(IllegalArgumentException.class, () -> EchoDemoSettings.fromLocalEnvironment(local(), "18081"));
}
/***************************************************************************
** A complete opt-in parses to exactly the local image and executable.
***************************************************************************/
@Test
void explicitOptInParses()
{
EchoDemoSettings.LocalRenderer parsed = EchoDemoSettings.LocalRenderer.fromEnvironment(local());
assertEquals(docker, parsed.docker());
assertEquals(IMAGE, parsed.imageId());
assertEquals(Duration.ofSeconds(10), parsed.deadline());
}
/***************************************************************************
** The renderer's output cap, the value the launcher hands Generation, is the
** documented default or the explicit nondefault setting, and stays bounded.
***************************************************************************/
@Test
void outputCapIsTheConfiguredRendererMaximum()
{
assertEquals(1048576, EchoDemoSettings.LocalRenderer.fromEnvironment(local()).maximumBytes());
Map<String, String> configured = local();
configured.put("ECHO_RENDERER_MAXIMUM_BYTES", "262144");
assertEquals(262144, EchoDemoSettings.LocalRenderer.fromEnvironment(configured).maximumBytes());
for(String invalid : new String[] { "0", "-1", "104857601" })
{
Map<String, String> environment = local();
environment.put("ECHO_RENDERER_MAXIMUM_BYTES", invalid);
assertThrows(IllegalArgumentException.class, () -> EchoDemoSettings.LocalRenderer.fromEnvironment(environment));
}
}
/***************************************************************************
** Absent, wrong or case-changed opt-in never selects the local renderer.
***************************************************************************/
@Test
void missingOrWrongOptInIsRefused()
{
Map<String, String> missing = local();
missing.remove("ECHO_LOCAL_RENDERER");
assertThrows(IllegalArgumentException.class, () -> EchoDemoSettings.LocalRenderer.fromEnvironment(missing));
for(String value : new String[] { "true", "1", "trusted_local_development", "" })
{
Map<String, String> wrong = local();
wrong.put("ECHO_LOCAL_RENDERER", value);
assertThrows(IllegalArgumentException.class, () -> EchoDemoSettings.LocalRenderer.fromEnvironment(wrong));
}
}
/***************************************************************************
** Only an exact local sha256 image id is accepted: no tag, repository digest
** reference, short or uppercase id.
***************************************************************************/
@Test
void badImageIdIsRefused()
{
for(String image : new String[] { "renderer:latest", "repo@sha256:" + "a".repeat(64), "sha256:abc", "sha256:" + "A".repeat(64), "sha256:" + "a".repeat(65), " " })
{
Map<String, String> environment = local();
environment.put("ECHO_LOCAL_RENDERER_IMAGE_ID", image);
assertThrows(IllegalArgumentException.class, () -> EchoDemoSettings.LocalRenderer.fromEnvironment(environment));
}
}
/***************************************************************************
** The executable must be an existing absolute regular file.
***************************************************************************/
@Test
void badExecutableIsRefused()
{
for(String path : new String[] { "docker", root.resolve("missing").toString(), root.toString() })
{
Map<String, String> environment = local();
environment.put("ECHO_LOCAL_RENDERER_DOCKER", path);
assertThrows(IllegalArgumentException.class, () -> EchoDemoSettings.LocalRenderer.fromEnvironment(environment));
}
}
/***************************************************************************
** Deadline and output limits stay inside the bounded ranges.
***************************************************************************/
@Test
void deadlineAndLimitsAreBounded()
{
for(String deadline : new String[] { "0", "31", "-1", "ten" })
{
Map<String, String> environment = local();
environment.put("ECHO_LOCAL_RENDERER_DEADLINE_SECONDS", deadline);
assertThrows(IllegalArgumentException.class, () -> EchoDemoSettings.LocalRenderer.fromEnvironment(environment));
}
Map<String, String> pages = local();
pages.put("ECHO_RENDERER_MAXIMUM_PAGES", "501");
assertThrows(IllegalArgumentException.class, () -> EchoDemoSettings.LocalRenderer.fromEnvironment(pages));
}
/***************************************************************************
** The journal directory is created under the demo root, absolute and
** private; a regular file in its place is refused rather than replaced.
***************************************************************************/
@Test
void rendererStateDirectoryIsPrivateUnderTheDemoRoot() throws IOException
{
Path state = TrustedLocalEchoDemo.stateDirectory(root);
assertEquals(root.resolve("renderer-state"), state);
assertTrue(state.isAbsolute() && Files.isDirectory(state) && !Files.isSymbolicLink(state));
assertEquals(state, TrustedLocalEchoDemo.stateDirectory(root));
Path other = Files.createDirectory(root.resolve("other"));
Files.createFile(other.resolve("renderer-state"));
assertThrows(IOException.class, () -> TrustedLocalEchoDemo.stateDirectory(other));
}
}
+49
View File
@@ -0,0 +1,49 @@
# Echo owner adapters and host composition handoff (Claude adapters role)
Updated: 2026-11-01. Scope: `examples/echo` library module. Primary forge: Gitea. This handoff makes no claim that Echo has printed, that anything is published to a public Maven repository, or that it is production-ready.
## Verified state (reported by root; I ran nothing)
- `mvnw verify` for this module: **38 tests pass**, **Checkstyle 0** (2026-11-02), against a complete private owner cohort: Generation and bridges `0.1.0-echo.gen64c9pu507`, Printing protected main `64c9ebf`, core `4.1.0-pu.507b0d21e09d7eebb76782d14660483c45c77b11`, 15 artifacts. The POM defaults (`qqq.version` 4.1.0-RC.20, `generation.version` 0.1.0-SNAPSHOT, `printing.version` 0.4.0-SNAPSHOT) are not that cohort.
- Scanner source `707e1ab` (Gitea PR 7): scanner 50, demo 7 and Record Files 18 tests pass. Android `c55d300` is retained inside migration merge `a2f0080` (identical tree).
- Runnable demo (`demo/`): **22 tests pass, Checkstyle 0**, with the new Echo read checker (it accepts Collaboration's and Record Files' plain capability probe for sessions with `echo.use`) and the renderer owner's TL1 trusted-local factory (52 owner tests pass; the demo copies no transport). Its clean composed acceptance passes: authenticated approved scanner, real `echoPrint`, real Generation, Record Files retention and Printing job acceptance; two scans give two retained originals and two accepted jobs on one printer, a retry gives the same job, and leaving closes the run. It uses a controlled synthetic renderer and a simulated Printing queue. A local run was observed live on an ephemeral loopback port (scanner and admin native login and metadata 200) with no printer or template. The production `EchoDemo` default still fails closed on Windows Docker Desktop (`runc`, not `runsc`).
- TC22 native five-stage flow and 86 semantic API calls pass **against simulated printing only**.
- No real-renderer template publication, independent decode or physical print has happened. Remaining: the actual native Printing client connection and stock handoff, physical scan/print/rescan, full AIM/EAN/UPC/GS1/raw fidelity (the six types are printable ASCII only and the rest fail explicitly as unsupported), then public immutable SDK, Gitea CI/distribution and signing, last.
## What exists (all under `examples/echo`)
- `EchoPrintingDestinations`: `authorize` requires the scanner identity's actor to equal Printing's current principal (`PrintingOperatorAuthorization`), checks the printer row, device and ZPL format, and pins the exact reported media profile id and revision (`PrintingMediaProfile`). `plan` reauthorizes, picks the template through `EchoTemplates`, reads the published template and revision with enforced native permissions, and requires its stock to equal the media. `media(printerId)` lists only that printer's authorized sizes.
- `EchoMediaChoices`: semantic custom possible-value provider. It reads the posted `printerId` dependency from `SearchPossibleValueSourceInput.getOtherValues()` and returns that printer's sizes only; no printer means no choices; a bare `getPossibleValue` refuses. The public id is the owner's profile id and the private revision pin stays in `authorize`.
- `EchoQBitProducer`: setup's printer field uses Printing's own table possible values (`printing_printer`) with `enabled` and `available` filters.
- `EchoTemplates`: exact key of renderer symbology, media id and media revision; `forMedia` maps the admitted symbologies to one template.
- `EchoTemplatePublisher.publish(definitions, media)`: one-time operator action creating type, stock equal to the reported media, ZPL-only binding for `echoScan`, and the template. Scene `qqq-zpl-scene/1`: `symbologyPath=/record/rendererSymbology`, `valuePath=/record/barcode`, allowlist, `quiet=10`, `fit` box inside the stock, no scanner-attestation paths.
- `EchoDocumentGenerator`: Generation's public `generate` and read-only `lookupAcceptedPrint` with exact `Expected` tuple and `MediaProfile` under key `echo:<scanId>`. `QUEUED` only for a durable, available retained receipt with a job id and the same media profile. Lookup must match source, destination and media; absence refuses; `reconcile` is never used.
- `EchoHost.install(instance, Deployment)`: `RecordFilesPrintSource`, `RecordFilesOutputIntake`, **`PrintingDispatch.forOwnerMediaEnforcement(source)`** (the owner's explicit adoption factory in the printing bridge; the plain constructor still denies selected media), `DocumentGenerationQBitConfig` with `echoScan` as source table and `GenerationAudit`, `PrintingQBitProducer`, then `EchoOwnerAdapters.config` and `EchoQBitProducer`, then `DocumentGenerationQBitProducer`. That order is required: Generation validates its source tables, and an earlier order failed the demo with "Unknown generation process source table". `Deployment` carries `printingBackend`, `generationBackend`, `echoBackend`, `echoState`, `printing`, `renderer` and `templates`; null `printing`, `renderer` or `templates` are refused.
- `EchoService`: admits scanner identifiers `CODE128`, `QRCODE`, `CODE39`, `DATAMATRIX`, `PDF417` and `AZTEC` (and their `LABEL-TYPE-` aliases). `EchoTemplates.SYMBOLOGIES` is exactly `code128, qr, code39, datamatrix, pdf417, aztec`.
## Fidelity: what is and is not claimed
- Six types are admitted; **EAN13, EAN8, UPCA and UPCE0 are refused before `EchoStore.claim` or generation** (regression in `EchoTest.unsupportedAndControlPayloadsFailWithoutPrinting`: generator never called, zero `echoScan` rows). The worker converts their full-length scans only in scanner mode, which needs the AIM identifier Echo does not record.
- Not supported and not claimed: EAN/UPC, GS1, raw or binary payloads, control characters, UNKNOWN or manual input. Completing the original barcode-fidelity scope (AIM evidence, then EAN/UPC) is open work after the first Code 128 and QR pilot; the six admitted types are not claimed complete.
- The published scene was checked by file review against the worker source (`ZplRenderer`, `BarcodeFit`, the owner's `BarcodeFitTest` fixture shape) and the worker's integer dot arithmetic for 4 x 6 in at 300 dpi (x=y=48 dots, fit 93600 x 144400 µm). `EchoOwnerAdaptersTest` asserts that shape and arithmetic. **No test runs the real worker on the Echo template.**
## Owner and deployment boundaries still open
1. **Renderer transport.** An owner-provided explicit cold trusted-local Java transport for Windows Docker Desktop; `catalog-9` image qualification is also listed open in `renderer/BARCODE-FIT.md`. `EchoHost` refuses to start without an `IsolatedRenderer`.
2. **Template publication through the real worker.** `freeze` calls `renderer.validate` on the exact scene, so a publish that returns a template id is the worker's acceptance. Then `DocumentGenerationService.preview(new Request(templateId, new Source("echoScan", rowId), ZPL, key, null), draftFingerprint)` renders a retained original with no print destination, for geometry inspection and independent decode of a Code 128 value and a leading-zero QR value such as `001234567890`.
3. **Printing service and agent.** A `PrintingService` for a tenant, a Windows agent exposing `printer-media-v1`, and a ZPL-capable printer at 203, 300 or 600 dpi are deployment items.
4. **Permissions** (no `printing_admin` on the operator): `echo.use`, an approved scanner, Printing's operator catalog permission and `PrintingOperatorProcesses.SUBMIT`, Generation view/generate/print, read on Generation template and revision tables (for `plan`), and Record Files release and read grants for the original actor.
5. **Original-actor source reauthorization** for delayed byte release belongs to Printing's retained source authority (`RecordFilesPrintSource`), and acceptance is still queue acceptance, not paper.
6. **Format.** The publisher is ZPL-only; the printer must report ZPL. A PDF variant is not written.
7. **Distribution.** The cohort is private and unpublished. Public Maven publication, hosted CI, signing and release are not done.
## Test inventory
24 core and HTTP-fixture tests (`EchoTest` 23, `EchoHttpFixtureTest` 1) and 14 adapter tests (`EchoOwnerAdaptersTest`): exact profile mapping, unknown or malformed media refusal, template keys, exact request pins, no-queue results, undurable or unavailable or missing receipt refusal, read-only lookup, mismatched-media lookup, printer-filtered media choices, bounded printer dependency, absent deployment parts refused, and the published scene geometry. Total 38, verified by the coordinator.
## Next gates (in order)
1. Renderer owner's cold trusted-local transport; start the demo against it.
2. Publish the template through the real worker; preview and independently decode Code 128 and QR with leading zeros.
3. Real-server TC22 run: setup once, scan twice, leave.
4. Physical print and TC22 rescan of exact value, symbology, quiet zones and 4 x 6 in stock, then AIM evidence and EAN/UPC.
@@ -0,0 +1,44 @@
Root verification:45 Echo library +24 composed demo tests PASS, Checkstyle0. Android99 focused/host tests +8 Node guards/lint0errors0warnings pass and test APK compiles. Updated TC22 document-choice test passed all7 stages and112 published semantic requests against simulated printing; no physical-print claim.
# Echo required Document choice (server and native UI verified)
## Process contract (ordinary QQQ metadata; Android renders it generically)
`echoPrint` frontend step `setup`, form fields in order:
1. `printerId` - Printing's own printer possible values (unchanged).
2. `mediaId` - possible value source `echoMedia`, `dependsOn=[printerId]` (unchanged).
3. `templateId` - label **Document**, required, **INTEGER** (Generation template id), possible value source `echoDocument` (`idType=INTEGER`, custom `EchoDocumentChoices implements QCustomPossibleValueProvider<Integer>`, registered by `EchoQBitProducer` if absent), `dependsOn=[printerId, mediaId]`.
HTTP (published `searchProcessPossibleValues`): `POST /qqq/v1/processes/echoPrint/possibleValues/templateId`, JSON `{"values":{"printerId":"...","mediaId":"..."},"processValues":{},"useCase":"form"}`; optional `searchTerm`, `ids`. Returns `{"options":[{"id":<JSON integer>,"label":"<template name>"}]}`. Either dependency missing returns no options; an unauthorized printer or unknown size is refused. `ids` arrive as strings and are coerced strictly (plain digits or integers only, else 400).
Setup submit value: `templateId` as a JSON integer. `EchoSteps.documentId` accepts an integral number, or plain digits from a text form; fractions, exponents, blanks, overflow, booleans and values below 1 are refused. Persisted: `echo_run.templateId` and `templateRevisionId` are INT; the library HTTP fixture's `echoFixtureRun` table now exposes both as INTEGER fields (Android E2E reads `templateId`).
`EchoService.start/select` and `EchoDestinations.DocumentChoice` now use `Integer` ids.
## Server rules
- `EchoDestinations.authorizeDocument(identity, destination, templateId)` and `documents(printerId, mediaId)` (new). `EchoPrintingDestinations` reads Generation `document_generation_template`/`_revision` with `PermissionEnforcement.ENFORCE` (query for choices, get for authorization). A template is eligible only if: it is for `echoScan`, has a published non-PREVIEW revision for `echoScan`, the revision's stock width/height/dpi equal the chosen media, and (ZPL) its scene is `qqq-zpl-scene/1` with a barcode element using `symbologyPath=/record/rendererSymbology`, `valuePath=/record/barcode` and an allowlist covering all of `EchoTemplates.SYMBOLOGIES`. Static, wrong-symbology or other-size templates are never listed or accepted. Nothing else is substituted.
- Setup (`EchoService.select`, step `start`) validates a numeric `templateId`, authorizes printer+media then the document, and stores `templateId` and `templateRevisionId` on `echo_run` once. Replays with a different document are refused (same rule as printer/media).
- Every scan: `claim` returns the run's pinned document; `EchoDestinations.plan(identity, destination, document, scan)` reauthorizes the destination, re-reads the template and requires the current published revision to equal the pinned one, else fails visibly ("changed or was withdrawn", Leave stays available). Print/Scan steps never read `templateId` input. Single in-flight, idempotent same-event retry, pin compare-and-set and durable-receipt ordering from 101efc8 are unchanged. A run with no document (pre-migration row) refuses to print.
- `EchoTemplates` (host catalogue) and the `ECHO_TEMPLATE_*` demo pin no longer select anything (`EchoHost.Deployment` lost its `templates` argument, `EchoOwnerAdapters.config` lost `templates`; the helper class remains for the symbology list). `EchoTemplatePublisher.publish(definitions, media, name)` publishes additional named documents.
## Migration
`echo-h2.sql`: `ALTER TABLE echo_run ADD COLUMN IF NOT EXISTS templateId INT` and `templateRevisionId INT`. Idempotent; existing rows are kept with NULL. Other databases need the equivalent additive columns (not provided).
## Tests (root verified)
- `EchoTest`: setupRequiresAnAuthorizedCompatibleDocument, documentChoicesComeFromTheNativePossibleValueSearch (real SearchPossibleValueSourceAction), chosenDocumentIsUsedForEveryScanAndCannotBeRetargeted (repeated scans, forged process value, setup replay), changedOrWithdrawnDocumentFailsVisiblyWithoutSubstitution, documentMigrationPreservesExistingRuns; existing tests updated to pass a document. Library suite:45 tests pass (29 process/persistence,15 adapters,1 native HTTP fixture). Full composed demo:24 tests pass. Both enforce Checkstyle0.
- `EchoOwnerAdaptersTest`: onlyDynamicSameSymbologyScenesAreEligibleDocuments (15 tests).
- `EchoHttpFixtureTest`: real HTTP possible-value search plus selected document.
- `EchoDemoAcceptanceTest` (real HTTP, real Printing/Generation/Record Files): two compatible published documents plus one other-size document; the PVS offers exactly the two; missing/unknown/malformed/wrong-size documents refused; document B chosen, used by every render across scans, replayed setup and a forged `templateId` on a later scan do not retarget.
Added in the typed-contract pass: `EchoDemoAcceptanceTest.republishedDocumentFailsVisiblyWithoutReplacementAndLeaveStillWorks` (real owner `DocumentDefinitionService` republish of the chosen template after setup: the next scan and its retry fail visibly, one render and one Printing job in total, Leave closes the run, and a fresh run offers and prints the republished document); integer-id and strict-coercion assertions in `EchoTest`; all possible-value requests in the fixture and demo tests use the JSON `{values, processValues, useCase}` POST, not multipart. Each demo test runs under the existing before/after connection-provider reset.
## Process-state trap (diagnosed from current core source; fix is in Echo, not core)
Both composed failures had one cause. In core, `RunBackendStepAction.runStepCode` catches any exception thrown by step code and replaces the result with `new RunBackendStepOutput()` (an empty `ProcessState`); `RunProcessAction` (line ~810) then stores that state under the process key before rethrowing. The new state has no `ProcessStateAccess`, so the next USER request fails `primeProcessState` -> `ProcessStateAccess.requireAccess(null, ...)` -> "Permission denied for process state" (the 403 on Leave). So a thrown business refusal (replayed/invalid setup, republished document at scan time) trapped the operator, including their own mistakes. No auth check was weakened.
Fix (example only): `EchoSteps.Start` and `Print` now record a `QException` with `output.setException(...)` instead of throwing. The output then shares the input's `ProcessState` (`seedFromRequest`), so the stored state, ownership record and values survive, and core still raises the exception to the client as the ordinary process error. Operators can correct a setup choice in the same process, retry the same scan, or Leave.
Core proposal (not applied, peer-owned): in `runStepCode`'s catch, keep the input's process state (call `runBackendStepOutput.seedFromRequest(runBackendStepInput)` before `setException`) so a thrown step exception cannot drop ownership for any process. Evidence: `RunBackendStepAction.java` lines 325-330 and `RunProcessAction.java` lines 810 and 646-652.
Test truth: `EchoDemoAcceptanceTest` setup-replay and republish cases are unchanged and now rely on this; new `EchoTest.businessRefusalsNeverTrapTheProcessAndLeaveStillWorks` proves correction in the same process, a refused scan, and Leave. Not run.
## Not covered / gaps
- No test for an unauthorized-to-read template: Generation has no template-specific grant, only table permissions, so it needs a new security grant that was not invented here.
- A true withdrawal is not tested: the owner's `DocumentDefinitionService` has no withdraw/unpublish call, only republish. Republish (changed revision) is the real owner path covered.
- Android: no change needed; the new field is ordinary dependent metadata. Not exercised on Android here.
## Gate (root)
`./mvnw.cmd verify install` from the repo root is unaffected; run the Echo example verify and the Echo demo verify through the existing private-cohort gate script.
+23
View File
@@ -0,0 +1,23 @@
# Echo session state
November 1, 2026: optional example source is being implemented under this directory only. Echo registers ordinary setup and scanner-loop process metadata, uses current approved-device attribution and canonical persisted scan context, and delegates generation/retention/printing to existing QBits. Its state store admits one event, preserves uncertain identities and refuses silent reprint. Core protected process identity, exact media/template revision and owner read-only acceptance seams are under coordinated implementation; no physical printer or peer service has been touched.
Initial private offline build stopped on an unavailable aggregate JUnit artifact; dependency declarations now use the already available split API/engine. Current compilation and focused H2/process tests remain pending. There is no runnable fully wired demo or production-release claim. See [TODO](TODO.md) for remaining acceptance.
Verification now passes14 real QQQ USER process/mobile-approval/H2 tests, zero failures/errors/skips, and zero Checkstyle violations, against the qualified protected-process SDK4.1.0-pu.507b0d21e09d7eebb76782d14660483c45c77b11 and scanner facade JAR9A5728B7…. Tests cover setup once, two-type exact-value loop, canonical receipt, changed-type retry refusal, uncertain/crashed read-only receipt adoption without re-enqueue, deterministic in-flight concurrency, post-setup cancellation, same-user run substitution, media drift/too-small plan and enforced native source reads including forged reserved keys. Destination/layout/generation fixtures are synthetic; no real renderer, owner adapter, Android or physical-print acceptance is implied. Private evidence is under Ccache/qqq-echo-final-verify-e3929445f6a144e889ba12d90e4c7efb. The unqualified generation API dependency is compile-only provenance, not release adoption.
Final packaging follow-up: module-directory wrapper verification passes15 tests (including canonical cross-run replay refusal), zero failures/errors/skips, zero Checkstyle violations and deprecation/unchecked warnings-as-errors. Apache license metadata is present in the POM; the JAR contains byte-identical existing repository LICENSE/NOTICE under META-INF/qqq-echo and unchanged db/echo-h2.sql. No duplicate license source files were introduced. Real adapters, hosted integration and physical printing remain pending.
## Semantic scanner presentation companion
Echo now supplies acknowledgmentLabel=Print queued and manualEntryEnabled=false through the registered scanner extension 1.4. The companion Android implementation passes 167 JVM cases, strict compilation and lint with zero errors/warnings. The server passes 69 consumer cases plus two contract-generation cases. Echo's 15 strict process/state tests also pass against the qualified 1.4 scanner artifact. This remains synthetic owner-port qualification; actual pinned rendering, media/profile propagation, native Echo UI and physical output still require owner handoffs and acceptance.
2026-11-01: Added test-scope EchoHttpFixture + actual semantic HTTP smoke, reusing the qualified demo artifact, TABLE auth and mobile routes. No production row lock or service changed. UI fixture has random in-memory state and unmistakable simulated printing branding. Real HTTP retry exposed database precision changes in server receivedAt; removed that observation timestamp from print intent comparison while preserving the first persisted observation. Canonical scan identity, payload/type/source/capture/run bindings remain enforced. Final fixture verification pending; old qualified Echo15 proof is retained.
Fixture verification: private qqq-echo-http-fixture-e66c4b1bc35a434a8e668fa7cff49dd8, strict16/0/0/0 +Checkstyle0 PASS. Snapshot includes compiled test/main classes, fixture-java.args and source provenance. HTTP test stops its listener cleanly; no device or physical print run. Native execution is the next independent gate.
2026-11-01 13:15 local: Split only EchoScan reads from approved-device capture. EchoSourceReadPermissions admits current nonblank actor + echo.use for native Get/Query/Count/Aggregate and metadata discovery; native actor RecordSecurityLock remains unchanged and direct writes remain denied. Echo app/widget/process/capture and test-only run inspection retain the approved-device checker. Original-actor desktop read after run close succeeds without mobile.scan/device scope; revoked Echo grant, anonymous actor, different actor with spoofed reserved security key, direct update/delete and desktop capture/process attempts fail. EchoTest17/0fail/0error/0skip, Checkstyle0 and strict Java21 package PASS against core pu.507b0d21 + mobile1.4 FDDB118A; evidence source-read-split.log in the private Echo HTTP fixture cache. Frozen native fixture classes/args were not replaced.
Critical path: qualify this split's HTTP companion, finish frozen native TC22 UI gate, then compose a production adapter only after reviewed owner media-capability/adoption + exact-pinned template/stock/media + read-only acceptance lookup handoffs. Printing must qualify current original-actor/source reauthorization before pending delivery/retry bytes release. Actual rendering/physical label fidelity and rescan are still unqualified; simulated fixture receipts do not satisfy them.
Companion semantic HTTP smoke1/0/0/0 +Checkstyle0 PASS after the split (source-read-http.log). Combined bounded cohort18 tests passes; original frozen UI fixture bundle is unchanged.
+44
View File
@@ -0,0 +1,44 @@
# Echo remaining work
## Current checkpoint — November 2, copies live
- [x] Printer, paper/label size and Document selection, real installed Printing client connection and 4x1 document publication are complete on the normal local server57832.
- [x] Copies per scan is optional, defaults to 1 and accepts 1–100; pinned for the run and carried as one job's copy quantity. Null/blank/default, selected 3, mutation refusal, retry identity and additive migration tests pass.
- [x] Root gates pass: 68 Echo and 35 composed demo tests; 377 unique tests including the Generation/Printing/scanner/files dependencies across verified module runs.
- [x] Real TC22 semantic acceptance passes ten stages/116 requests using simulated scans and printing. The normal connected TC22 is left on setup showing Copies per scan = 1; normal server advertises mobile1.5.
- [ ] Physical quantity, trigger/print/rescan and AIM/EAN/UPC fidelity checks remain unqualified. The operator is remote.
- [ ] Canonical Generation copies adoption/publication and coherent public CI remain pending; qualified local source/handoff lives in C:/Users/james/.cache/qqq-generation-copies-20261102 and qqq-generation-copies-handoff.md.
The older ledger below is historical; its unrun-test, setup and mobile1.4 notes are superseded by this checkpoint.
- [ ] Root gate for the required Document choice (printer, paper or label size, then Document at run start; see DOCUMENT-CHOICE-HANDOFF.md): module and demo verify, then on-device check that the generic form shows the dependent Document field.
Verified by root (2026-11-02): module `verify` 33 tests, Checkstyle 0, against the complete private cohort `0.1.0-echo.gen64c9pu507`. Runnable demo `verify` 22 tests, Checkstyle 0, including the composed acceptance below, with the real new Echo read checker and the renderer owner's TL1 factory (52 owner tests pass, no copied transport). A local demo run was observed live on an ephemeral loopback port (scanner and admin native login and metadata 200). No printer or template on it, and no physical printing.
## Done
- [x] Process, state, mobile-approval and H2 tests against the protected-process-identity core API; concurrency, crash recovery, cancellation and run-substitution regressions.
- [x] Test-only isolated semantic HTTP fixture (simulated printing) and split read-only `echoScan` permission with actor-lock, revoked, anonymous and write negatives; the read rule also accepts Collaboration's and Record Files' plain capability probe for sessions with `echo.use`.
- [x] Owner adapters from actual public APIs: authorized printer/media from Printing, exact pinned template/revision/stock/media generation, read-only accepted-print lookup, `QUEUED` only for a durable available receipt.
- [x] Media choices filtered by the selected printer through the semantic dependency value.
- [x] Host composition `EchoHost.install` using `PrintingDispatch.forOwnerMediaEnforcement(source)`.
- [x] Publisher scene reviewed against the worker source and owner fixture shape; EAN13/EAN8/UPCA/UPCE0 refused before admission; six types admitted.
- [x] Runnable demo module and the owner's trusted-local renderer factory integration.
- [x] **Clean composed acceptance** (demo `EchoDemoAcceptanceTest`): authenticated approved scanner, real `echoPrint`, real Generation, Record Files retention and Printing job acceptance; two scans give two retained originals and two accepted jobs on one printer, a retry gives the same job, and leaving closes the run. Controlled synthetic renderer and simulated Printing queue; not physical.
## Open
- [ ] Actual native Printing client (Windows agent) connection to the composed server and the current stock/media handoff.
- [ ] Publish the ZPL template through the real renderer, then preview and independently decode Code 128 and a leading-zero QR (`001234567890`) at 4 x 6 in / 300 dpi.
- [ ] Real-server native TC22 run: setup once, scan twice, rotate/recreate, leave; one stable print identity after transport loss.
- [ ] Physical print and TC22 rescan with exact value, symbology, quiet zones and stock dimensions; native Printing acceptance. A queue acknowledgment is not paper.
- [x] Source: AIM persistence (`echo_scan.aimIdentifier`), EAN13/EAN8/UPCA/UPCE0 validation, and a scanner-fidelity document through the renderer's scanner mode; candidate Android AIM output profile (`AimOutputProfile`, driver config/readback/restore). Not yet run (root builds) and not physically qualified.
- [x] Source: Copies per scan (setup field, server-pinned `echo_run.copies`, scan-stage forgery refusal, widget text). Library tests added, not run.
- [x] Source: copies reach Generation's request and the accepted-lookup binding (needs the Generation copies cohort from `C:/Users/james/.cache/qqq-generation-copies-handoff.md`, unbuilt). Optional field with 1..100 core limits and real HTTP tests for default 1, chosen 3 and mutation rejection; not run.
- [ ] Root gate with the Generation copies cohort, the `20261102-print-copies-h2.sql` migration in the demo's `GENERATION_MIGRATIONS`, a Printing owner that honors `options.copies`, and a physical count check on the printer.
- [ ] TC22 pulls proving what `getData()` and `getRawData()` contain under `codeIdType=AIM`, then a physical EAN/UPC scan, print and rescan on the scanner-fidelity document. Normal server 1.5 adoption (the live server is 1.4 and drops AIM and raw bytes).
- [ ] GS1, ECI, structured, binary and non-ASCII payloads stay refused: the existing public renderer has no scanner-mode profile for them (see `C:/Users/james/.cache/qqq-finish-barcode-handoff.md`).
- [ ] Original-actor source reauthorization policy confirmed for delayed byte release (Printing retained source authority).
- [ ] Deployment: Windows agent exposing `printer-media-v1`, ZPL printer, tenant, operator permissions without `printing_admin` (the scanner never receives it).
- [ ] Public immutable SDK publication, Gitea CI and distribution, then signing, last. The private cohort is unpublished; PR 7 is on Gitea and earlier GitHub references are historical.
- [ ] Release and production readiness: not done and not claimed.
+49
View File
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Copyright (c) 2026 Kingsrook, LLC. SPDX-License-Identifier: Apache-2.0 -->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.kingsrook.examples</groupId>
<artifactId>qqq-echo</artifactId>
<version>0.1.0-SNAPSHOT</version>
<name>QQQ Echo scanner example</name>
<licenses>
<license>
<name>Apache License, Version 2.0</name>
<url>https://www.apache.org/licenses/LICENSE-2.0</url>
<distribution>repo</distribution>
</license>
</licenses>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<qqq.version>4.1.0-RC.21</qqq.version>
<mobile.version>0.1.0-SNAPSHOT</mobile.version>
<!-- Echo's copies per scan needs a Generation build with the print-copies change (appended copies on
DocumentGenerationService.Request and GenerationPorts.PrintRequest, supportsCopies on the printing bridge,
migration 20261102-print-copies-h2.sql). Until that is published at this coordinate, override
generation.version and printing.version with a coherent build that has it; no private build is named here. -->
<generation.version>0.1.0-SNAPSHOT</generation.version>
<printing.version>0.4.0-SNAPSHOT</printing.version>
</properties>
<dependencyManagement><dependencies>
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-bom-pom</artifactId><version>${qqq.version}</version><type>pom</type><scope>import</scope></dependency>
</dependencies></dependencyManagement>
<dependencies>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-mobile-scanners</artifactId><version>${mobile.version}</version></dependency>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-document-generation</artifactId><version>${generation.version}</version></dependency>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-printing</artifactId><version>${printing.version}</version></dependency>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-document-generation-printing</artifactId><version>${generation.version}</version></dependency>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-document-generation-record-files</artifactId><version>${generation.version}</version></dependency>
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-module-rdbms</artifactId></dependency>
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><version>2.2.224</version><scope>test</scope></dependency>
<dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter-api</artifactId><version>6.0.2</version><scope>test</scope></dependency>
<dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter-engine</artifactId><version>6.0.2</version><scope>test</scope></dependency>
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-frontend-next</artifactId><version>1.0.0-RC.35</version><scope>test</scope></dependency>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-mobile-scanners-demo</artifactId><version>${mobile.version}</version><scope>test</scope></dependency>
</dependencies>
<build><resources><resource><directory>src/main/resources</directory><filtering>false</filtering></resource><resource><directory>${project.basedir}/../..</directory><filtering>false</filtering><includes><include>LICENSE</include><include>NOTICE</include></includes><targetPath>META-INF/qqq-echo</targetPath></resource></resources><plugins>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.13.0</version><configuration><showWarnings>true</showWarnings><failOnWarning>true</failOnWarning><proc>none</proc><compilerArgs><arg>-Xlint:deprecation</arg><arg>-Xlint:unchecked</arg><arg>-Werror</arg></compilerArgs></configuration></plugin>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId><version>3.5.2</version></plugin>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-checkstyle-plugin</artifactId><version>3.6.0</version><dependencies><dependency><groupId>com.puppycrawl.tools</groupId><artifactId>checkstyle</artifactId><version>14.3.0</version></dependency></dependencies><configuration><configLocation>${project.basedir}/../../checkstyle/checkstyle.xml</configLocation><includeTestSourceDirectory>true</includeTestSourceDirectory><failOnViolation>true</failOnViolation><violationSeverity>warning</violationSeverity></configuration><executions><execution><phase>validate</phase><goals><goal>check</goal></goals></execution></executions></plugin>
</plugins></build>
</project>
@@ -0,0 +1,113 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo;
import javax.sql.DataSource;
import java.util.List;
import java.util.Objects;
import com.kingsrook.qbits.mobilescanners.MobileDeviceAuthorization;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
/*******************************************************************************
** Trusted host wiring. There are no fallback printers, fabricated media or
** in-process renderers when owner capabilities are unavailable.
*******************************************************************************/
public final class EchoConfig implements QBitConfig
{
final String backendName;
final String printerPossibleValues;
final String mediaPossibleValues;
final EchoService service;
final EchoDestinations destinations;
final IdentitySource identities;
/***************************************************************************
** Production always captures the mobile QBit's current verified identity.
***************************************************************************/
public EchoConfig(String backendName, DataSource dataSource, String printerPossibleValues, String mediaPossibleValues,
EchoDestinations destinations, EchoGenerator generator)
{
this(backendName, dataSource, printerPossibleValues, mediaPossibleValues, destinations, generator,
() -> MobileDeviceAuthorization.captureCurrent().orElseThrow(() -> new QException("Echo requires an approved scanner.")));
}
/***************************************************************************
** Package-private fixtures exercise ordinary process/persistence behavior;
** production callers cannot replace the scanner authorization boundary.
***************************************************************************/
EchoConfig(String backendName, DataSource dataSource, String printerPossibleValues, String mediaPossibleValues,
EchoDestinations destinations, EchoGenerator generator, IdentitySource identities)
{
this.backendName = Objects.requireNonNull(backendName);
this.printerPossibleValues = Objects.requireNonNull(printerPossibleValues);
this.mediaPossibleValues = Objects.requireNonNull(mediaPossibleValues);
this.identities = Objects.requireNonNull(identities);
this.destinations = Objects.requireNonNull(destinations);
service = new EchoService(dataSource, destinations, generator, identities);
}
/***************************************************************************
** Validate composition without changing a database or contacting a printer.
***************************************************************************/
@Override
public void validate(QInstance instance, List<String> errors)
{
if(instance.getBackend(backendName) == null || !Boolean.TRUE.equals(instance.getEnforcePermissionsInCoreActions()))
{
errors.add("Echo requires its configured backend and enforced core permissions.");
}
if(!hasChoices(instance, printerPossibleValues) || !hasChoices(instance, mediaPossibleValues))
{
errors.add("Echo requires authorized printer and supported-media possible-value sources.");
}
}
/***************************************************************************
** A table's implicit possible-value source exists once the table is registered.
***************************************************************************/
private static boolean hasChoices(QInstance instance, String name)
{
return instance.getPossibleValueSource(name) != null || instance.getTable(name) != null;
}
/***************************************************************************
** Resolve exactly one installed host configuration, never client values.
***************************************************************************/
static EchoConfig current() throws QException
{
return QContext.getQInstance().getQBits().values().stream().map(bit -> bit.getConfig()).filter(EchoConfig.class::isInstance)
.map(EchoConfig.class::cast).findFirst().orElseThrow(() -> new QException("Echo is not configured."));
}
/***************************************************************************
** Identity is captured before async execution and checked again per action.
***************************************************************************/
@FunctionalInterface
interface IdentitySource
{
/***********************************************************************
** Return the current server-authorized scanner identity.
***********************************************************************/
MobileDeviceAuthorization.Identity current() throws QException;
}
}
@@ -0,0 +1,132 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo;
import java.util.List;
import com.kingsrook.qbits.documentgeneration.GenerationPorts;
import com.kingsrook.qbits.mobilescanners.MobileDeviceAuthorization;
import com.kingsrook.qqq.backend.core.exceptions.QException;
/*******************************************************************************
** Application composition with the printing owner's authorized printer/media
** selector and the renderer owner's qualified fit capability. No queue or
** barcode encoder is implemented by Echo.
*******************************************************************************/
public interface EchoDestinations
{
/***************************************************************************
** Resolve current operator/device access and actual supported printer stock.
** Names from forms are identifiers, never asserted printer capabilities.
***************************************************************************/
Destination authorize(MobileDeviceAuthorization.Identity identity, String printerRecordId, String mediaId) throws QException;
/***************************************************************************
** Authorize the chosen document for this exact destination: a currently
** published, Echo-compatible template the actor may use, whose stock equals
** the media. The returned revision is what the run pins; ids from forms are
** never trusted and no other template is substituted.
***************************************************************************/
Document authorizeDocument(MobileDeviceAuthorization.Identity identity, Destination destination, Integer templateId) throws QException;
/***************************************************************************
** Reauthorize the selected destination and the run's pinned document, whose
** revision must still be the published one, and return the exact plan. The
** isolated renderer fits this value/type at integral module sizes; refuse
** unsupported types, withdrawn or changed documents.
***************************************************************************/
Plan plan(MobileDeviceAuthorization.Identity identity, Destination destination, Document document, EchoService.Scan scan) throws QException;
/***************************************************************************
** Documents the current actor may choose for this printer and paper or
** label size. Setup offers only these; authorizeDocument still rechecks.
***************************************************************************/
default List<DocumentChoice> documents(String printerRecordId, String mediaId) throws QException
{
return List.of();
}
/***************************************************************************
** Public identifier and label of one eligible document, never its source.
***************************************************************************/
record DocumentChoice(Integer id, String label)
{
}
/***************************************************************************
** The document and published revision chosen once for the run.
***************************************************************************/
record Document(Integer templateId, Integer templateRevisionId)
{
}
/***************************************************************************
** Paper or label sizes the current actor may select for exactly this printer.
** Setup offers only these; authorize still rechecks the exact pair.
***************************************************************************/
default List<MediaChoice> media(String printerRecordId) throws QException
{
return List.of();
}
/***************************************************************************
** Public identifier and label of one supported size, never its private pin.
***************************************************************************/
record MediaChoice(String id, String label)
{
}
/***************************************************************************
** Exact setup choice remains bound to the run, independent of later inputs.
***************************************************************************/
record Destination(String printerRecordId, String mediaId, String mediaRevision, int widthMicrons, int heightMicrons, int dpiX, int dpiY)
{
}
/***************************************************************************
** A generation-owned immutable template supplies stock and fitting policy.
***************************************************************************/
record Plan(Integer templateId, Integer templateRevisionId, GenerationPorts.Stock stock, GenerationPorts.Format format, int copies)
{
/************************************************************************
** Destinations plan the document only; the run's pinned copies are added
** by the service, so a plain plan is one copy.
************************************************************************/
public Plan(Integer templateId, Integer templateRevisionId, GenerationPorts.Stock stock, GenerationPorts.Format format)
{
this(templateId, templateRevisionId, stock, format, 1);
}
/************************************************************************
** The same exact document with the run's server-pinned copies per scan.
************************************************************************/
Plan withCopies(int count)
{
return new Plan(templateId, templateRevisionId, stock, format, count);
}
}
}
@@ -0,0 +1,128 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import com.kingsrook.qqq.backend.core.actions.values.QCustomPossibleValueProvider;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
/*******************************************************************************
** Semantic possible values for the setup step's Document: true INTEGER ids,
** Generation's template ids. QQQ posts the already selected printer and paper
** or label size as dependency values; only published Echo-compatible documents
** the actor may use for that pair are returned.
*******************************************************************************/
public class EchoDocumentChoices implements QCustomPossibleValueProvider<Integer>
{
public static final String NAME = "echoDocument";
static final String MEDIA_FIELD = "mediaId";
private static final int MAXIMUM = 50;
/***************************************************************************
** A bare document id has no printer or size; exact refresh uses search.
***************************************************************************/
@Override
public QPossibleValue<Integer> getPossibleValue(Serializable id) throws QException
{
throw new QBadRequestException("Resolve documents with the selected printer and paper or label size.");
}
/***************************************************************************
** Printing and Generation authorization decide every choice.
***************************************************************************/
@Override
public List<QPossibleValue<Integer>> search(SearchPossibleValueSourceInput input) throws QException
{
String printer = EchoMediaChoices.printer(input.getOtherValues());
String media = media(input.getOtherValues());
if(printer == null || media == null)
{
return List.of();
}
return select(EchoConfig.current().destinations.documents(printer, media), input.getIdList(), input.getSearchTerm(), input.getSkip(), input.getLimit());
}
/***************************************************************************
** Bounded exact-id (strict integers) or plain-text selection.
***************************************************************************/
static List<QPossibleValue<Integer>> select(List<EchoDestinations.DocumentChoice> choices, List<Serializable> ids, String searchTerm, Integer skipValue, Integer limitValue) throws QException
{
List<QPossibleValue<Integer>> result = new ArrayList<>();
if(ids != null)
{
if(ids.size() > MAXIMUM)
{
throw new QBadRequestException("Too many documents requested.");
}
List<Integer> wanted = new ArrayList<>();
for(Serializable id : ids)
{
wanted.add(EchoSteps.documentId(id));
}
for(EchoDestinations.DocumentChoice choice : choices)
{
if(wanted.contains(choice.id()))
{
result.add(new QPossibleValue<>(choice.id(), choice.label()));
}
}
return result;
}
String term = Objects.requireNonNullElse(searchTerm, "").toLowerCase(Locale.ROOT);
if(term.length() > 128)
{
throw new QBadRequestException("Narrow the document search.");
}
int skip = Math.max(0, Objects.requireNonNullElse(skipValue, 0));
int limit = Math.max(1, Math.min(MAXIMUM, Objects.requireNonNullElse(limitValue, MAXIMUM)));
int seen = 0;
for(EchoDestinations.DocumentChoice choice : choices)
{
if((term.isEmpty() || choice.label().toLowerCase(Locale.ROOT).contains(term) || String.valueOf(choice.id()).contains(term))
&& seen++ >= skip && result.size() < limit)
{
result.add(new QPossibleValue<>(choice.id(), choice.label()));
}
}
return result;
}
/***************************************************************************
** The selected size is a bounded identifier, never an expression.
***************************************************************************/
static String media(Map<String, Serializable> otherValues) throws QException
{
Serializable value = otherValues == null ? null : otherValues.get(MEDIA_FIELD);
if(value == null || String.valueOf(value).isBlank())
{
return null;
}
String text = String.valueOf(value);
if(text.length() > 200)
{
throw new QBadRequestException("Choose a valid paper or label size first.");
}
return text;
}
}
@@ -0,0 +1,147 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo;
import java.util.Objects;
import java.util.Optional;
import com.kingsrook.qbits.documentgeneration.DocumentGenerationService;
import com.kingsrook.qbits.documentgeneration.GenerationPorts;
import com.kingsrook.qqq.backend.core.exceptions.QException;
/*******************************************************************************
** Production generation through the owner's public service. Every request pins
** the exact template, revision, stock and selected media profile. Recovery uses
** only the owner's read-only accepted-print lookup, never generate/reconcile.
*******************************************************************************/
public final class EchoDocumentGenerator implements EchoGenerator
{
private final Owner owner;
/***************************************************************************
** Narrow delegate over the two owner operations Echo is allowed to use.
***************************************************************************/
interface Owner
{
/***********************************************************************
** Idempotent generate, retain and queue for one request key.
***********************************************************************/
DocumentGenerationService.Result generate(DocumentGenerationService.Request request) throws QException;
/***********************************************************************
** Side-effect-free committed acceptance lookup.
***********************************************************************/
Optional<DocumentGenerationService.AcceptedPrint> lookup(DocumentGenerationService.Request request) throws QException;
}
/***************************************************************************
** Compose with the host's configured Document Generation service.
***************************************************************************/
public EchoDocumentGenerator(DocumentGenerationService service)
{
this(new Owner()
{
@Override
public DocumentGenerationService.Result generate(DocumentGenerationService.Request request) throws QException
{
return service.generate(request);
}
@Override
public Optional<DocumentGenerationService.AcceptedPrint> lookup(DocumentGenerationService.Request request) throws QException
{
return service.lookupAcceptedPrint(request);
}
});
}
/***************************************************************************
** Package-private composition lets tests supply controlled owner facades.
***************************************************************************/
EchoDocumentGenerator(Owner owner)
{
this.owner = Objects.requireNonNull(owner);
}
/***************************************************************************
** Replays of the same key return the owner's existing outcome; an incomplete
** owner row refuses rather than rendering or printing again.
***************************************************************************/
@Override
public String accept(EchoDestinations.Plan plan, String scanId, String requestKey, EchoDestinations.Destination destination) throws QException
{
DocumentGenerationService.Request request = request(plan, scanId, requestKey, destination);
DocumentGenerationService.Result result = owner.generate(request);
if(result == null || !"QUEUED".equals(result.state()) || result.printJobId() == null || result.printJobId().isBlank()
|| result.retained() == null || !Boolean.TRUE.equals(result.retained().durable()) || !Boolean.TRUE.equals(result.retained().available())
|| !request.mediaProfile().equals(result.mediaProfile()) || (result.accepted() != null && !request.expected().equals(result.accepted())))
{
throw new QException("Print acceptance is unconfirmed. Retry the same scan or leave Echo.");
}
return result.printJobId();
}
/***************************************************************************
** Absence or an unavailable lookup is never proof; both refuse.
***************************************************************************/
@Override
public String lookupAccepted(EchoDestinations.Plan plan, String scanId, String requestKey, EchoDestinations.Destination destination) throws QException
{
DocumentGenerationService.Request request = request(plan, scanId, requestKey, destination);
DocumentGenerationService.AcceptedPrint accepted = owner.lookup(request).orElseThrow(
() -> new QException("Print acceptance requires owner reconciliation. Leave Echo to stop scanning; do not create a replacement print."));
GenerationPorts.PrintAcceptance printing = accepted.printing();
if(printing == null || printing.jobId() == null || printing.jobId().isBlank() || printing.request() == null
|| !request.mediaProfile().equals(printing.request().mediaProfile()) || !request.source().equals(printing.request().source())
|| !request.destination().equals(printing.request().destination()) || !Objects.equals(request.copies(), printing.request().copies()))
{
throw new QException("Print acceptance requires owner reconciliation. Leave Echo to stop scanning; do not create a replacement print.");
}
return printing.jobId();
}
/***************************************************************************
** Build the exact pinned intent; the same value is used for lookup.
***************************************************************************/
static DocumentGenerationService.Request request(EchoDestinations.Plan plan, String scanId, String requestKey, EchoDestinations.Destination destination) throws QException
{
GenerationPorts.MediaProfile media;
try
{
media = new GenerationPorts.MediaProfile(destination.mediaId(), destination.mediaRevision());
}
catch(IllegalArgumentException invalid)
{
throw new QException("Choose an authorized printer and supported paper or label size.");
}
/////////////////////////////////////////////////////////////////////////////////////////
// the run's pinned count rides in the same request as the media pin: Generation binds //
// it to the request key and submits ONE job whose options carry it (1..100) //
/////////////////////////////////////////////////////////////////////////////////////////
try
{
return new DocumentGenerationService.Request(plan.templateId(), new GenerationPorts.Source(EchoQBitProducer.SCAN_TABLE, scanId), plan.format(),
requestKey, destination.printerRecordId(), new DocumentGenerationService.Expected(plan.templateId(), plan.templateRevisionId(), plan.stock()), media, plan.copies());
}
catch(IllegalArgumentException invalid)
{
throw new QException("Copies per scan must be a whole number from 1 to " + EchoService.MAXIMUM_COPIES + ".");
}
}
}
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo;
import com.kingsrook.qqq.backend.core.exceptions.QException;
/*******************************************************************************
** The existing generation service retains output and uses its configured native
** printing bridge. Queue acceptance is not a claim of physical paper output.
*******************************************************************************/
@FunctionalInterface
public interface EchoGenerator
{
/***************************************************************************
** Keep the same key for retries; an unavailable result never means absence.
** The plan's copies is the run's pinned count, to be printed by ONE job; a
** generator never queues one job per copy or renders once per copy.
***************************************************************************/
String accept(EchoDestinations.Plan plan, String scanId, String requestKey, EchoDestinations.Destination destination) throws QException;
/***************************************************************************
** Read-only owner evidence may recover an accepted job after a process crash.
** Missing/unavailable evidence never authorizes another render or enqueue.
***************************************************************************/
default String lookupAccepted(EchoDestinations.Plan plan, String scanId, String requestKey, EchoDestinations.Destination destination) throws QException
{
throw new QException("Print acceptance requires owner reconciliation. Leave Echo to stop scanning; do not create a replacement print.");
}
}
@@ -0,0 +1,115 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo;
import javax.sql.DataSource;
import java.util.List;
import java.util.Objects;
import com.kingsrook.qbits.documentgeneration.DocumentGenerationQBitConfig;
import com.kingsrook.qbits.documentgeneration.DocumentGenerationQBitProducer;
import com.kingsrook.qbits.documentgeneration.DocumentGenerationService;
import com.kingsrook.qbits.documentgeneration.GenerationAudit;
import com.kingsrook.qbits.documentgeneration.GenerationPorts;
import com.kingsrook.qbits.documentgeneration.printing.PrintingDispatch;
import com.kingsrook.qbits.documentgeneration.printing.RecordFilesPrintSource;
import com.kingsrook.qbits.documentgeneration.recordfiles.RecordFilesOutputIntake;
import com.kingsrook.qbits.printing.PrintingQBitConfig;
import com.kingsrook.qbits.printing.PrintingQBitProducer;
import com.kingsrook.qbits.printing.PrintingService;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
/*******************************************************************************
** Complete host composition from the owners' public production classes: Record
** Files retained output and print source, Printing's retained facade with its
** media enforcement, Generation with its durable audit, and Echo itself. The
** isolated renderer and the Printing service are deployment-owned inputs; a
** missing one is refused, never replaced by an in-process fallback.
*******************************************************************************/
public final class EchoHost
{
/***************************************************************************
** Static composition only.
***************************************************************************/
private EchoHost()
{
}
/***************************************************************************
** Deployment-owned parts. printingBackend and generationBackend name backends
** registered in the instance; echoState shares the Echo state schema's database.
** Media enforcement comes only from the owner's explicit adoption factory.
** maximumOutputBytes must be the renderer's own configured output cap, which
** Generation sends with every render request; the owner bridge refuses a larger
** request before it renders. Null keeps the owner's default and is only correct
** when the renderer accepts that default.
***************************************************************************/
public record Deployment(String printingBackend, String generationBackend, String echoBackend, DataSource echoState, PrintingService printing,
GenerationPorts.IsolatedRenderer renderer, Integer maximumOutputBytes)
{
/** The owner's inclusive bounds for a configured output cap. */
static final int MAXIMUM_OUTPUT_BYTES_LIMIT = 100 * 1024 * 1024;
/***********************************************************************
** Refuse an absent deployment-owned part or an out-of-range output cap
** before any composition.
***********************************************************************/
public Deployment
{
if(maximumOutputBytes != null && (maximumOutputBytes < 1 || maximumOutputBytes > MAXIMUM_OUTPUT_BYTES_LIMIT))
{
throw new IllegalArgumentException("maximumOutputBytes must be between 1 and " + MAXIMUM_OUTPUT_BYTES_LIMIT + ".");
}
Objects.requireNonNull(printing);
Objects.requireNonNull(renderer);
}
/***********************************************************************
** Owner default output cap, for a renderer that accepts it.
***********************************************************************/
public Deployment(String printingBackend, String generationBackend, String echoBackend, DataSource echoState, PrintingService printing,
GenerationPorts.IsolatedRenderer renderer)
{
this(printingBackend, generationBackend, echoBackend, echoState, printing, renderer, null);
}
}
/***************************************************************************
** Register Record Files-backed Generation and Printing, then Echo. The mobile
** scanner QBit and Record Files metadata must already be installed.
***************************************************************************/
public static EchoConfig install(QInstance instance, Deployment deployment) throws QException
{
RecordFilesPrintSource source = new RecordFilesPrintSource();
RecordFilesOutputIntake intake = new RecordFilesOutputIntake();
PrintingDispatch dispatch = PrintingDispatch.forOwnerMediaEnforcement(source);
DocumentGenerationQBitConfig generation = new DocumentGenerationQBitConfig().withBackendName(deployment.generationBackend())
.withSourceTables(List.of(EchoQBitProducer.SCAN_TABLE)).withRenderer(deployment.renderer()).withIntake(intake).withRetainedAccess(intake)
.withPrinting(dispatch).withAudit(new GenerationAudit());
if(deployment.maximumOutputBytes() != null)
{
generation.withMaximumOutputBytes(deployment.maximumOutputBytes());
}
new PrintingQBitProducer().withConfig(new PrintingQBitConfig().withBackendName(deployment.printingBackend()).withService(deployment.printing())
.withRetainedSourceAuthority(source)).produce(instance, "printing");
EchoConfig config = EchoOwnerAdapters.config(instance, deployment.echoBackend(), deployment.echoState(), new DocumentGenerationService(generation),
GenerationPorts.Format.ZPL);
new EchoQBitProducer().produce(instance, config);
// Generation validates its configured source tables, so echoScan must be registered first.
new DocumentGenerationQBitProducer().withQBitConfig(generation).produce(instance);
return config;
}
}
@@ -0,0 +1,121 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import com.kingsrook.qqq.backend.core.actions.values.QCustomPossibleValueProvider;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.values.SearchPossibleValueSourceInput;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
/*******************************************************************************
** Semantic possible values for the setup step's paper or label size. QQQ posts
** the already selected printer as the printerId dependency value; only that
** printer's authorized sizes are returned. No printer yet means no choices.
*******************************************************************************/
public class EchoMediaChoices implements QCustomPossibleValueProvider<String>
{
public static final String NAME = "echoMedia";
static final String PRINTER_FIELD = "printerId";
private static final int MAXIMUM = 50;
/***************************************************************************
** A bare size id has no printer; exact refresh uses search with the printer.
***************************************************************************/
@Override
public QPossibleValue<String> getPossibleValue(Serializable id) throws QException
{
throw new QBadRequestException("Resolve paper or label sizes with the selected printer.");
}
/***************************************************************************
** Printing's authorization for the selected printer decides every choice.
***************************************************************************/
@Override
public List<QPossibleValue<String>> search(SearchPossibleValueSourceInput input) throws QException
{
String printer = printer(input.getOtherValues());
if(printer == null)
{
return List.of();
}
return select(EchoConfig.current().destinations.media(printer), input.getIdList(), input.getSearchTerm(), input.getSkip(), input.getLimit());
}
/***************************************************************************
** The selected printer is a bounded identifier, never an expression.
***************************************************************************/
static String printer(Map<String, Serializable> otherValues) throws QException
{
Serializable value = otherValues == null ? null : otherValues.get(PRINTER_FIELD);
if(value == null || String.valueOf(value).isBlank())
{
return null;
}
String text = String.valueOf(value);
if(text.length() > 200)
{
throw new QBadRequestException("Choose a valid printer first.");
}
return text;
}
/***************************************************************************
** Bounded exact-id or plain-text selection from one printer's choices.
***************************************************************************/
static List<QPossibleValue<String>> select(List<EchoDestinations.MediaChoice> choices, List<Serializable> ids, String searchTerm, Integer skipValue, Integer limitValue) throws QException
{
List<QPossibleValue<String>> result = new ArrayList<>();
if(ids != null)
{
if(ids.size() > MAXIMUM)
{
throw new QBadRequestException("Too many paper or label sizes requested.");
}
for(EchoDestinations.MediaChoice choice : choices)
{
if(ids.contains(choice.id()))
{
result.add(new QPossibleValue<>(choice.id(), choice.label()));
}
}
return result;
}
String term = Objects.requireNonNullElse(searchTerm, "").toLowerCase(Locale.ROOT);
if(term.length() > 128)
{
throw new QBadRequestException("Narrow the paper or label size search.");
}
int skip = Math.max(0, Objects.requireNonNullElse(skipValue, 0));
int limit = Math.max(1, Math.min(MAXIMUM, Objects.requireNonNullElse(limitValue, MAXIMUM)));
int seen = 0;
for(EchoDestinations.MediaChoice choice : choices)
{
if((term.isEmpty() || choice.label().toLowerCase(Locale.ROOT).contains(term) || choice.id().toLowerCase(Locale.ROOT).contains(term))
&& seen++ >= skip && result.size() < limit)
{
result.add(new QPossibleValue<>(choice.id(), choice.label()));
}
}
return result;
}
}
@@ -0,0 +1,51 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo;
import javax.sql.DataSource;
import com.kingsrook.qbits.documentgeneration.DocumentGenerationService;
import com.kingsrook.qbits.documentgeneration.GenerationPorts;
import com.kingsrook.qbits.printing.PrintingTables;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
/*******************************************************************************
** One-call host composition from the Printing and Document Generation owners'
** public APIs. Printers use Printing's own table possible values; media uses
** the custom authorized catalog source. Nothing here is a static catalog.
*******************************************************************************/
public final class EchoOwnerAdapters
{
/***************************************************************************
** Static composition only.
***************************************************************************/
private EchoOwnerAdapters()
{
}
/***************************************************************************
** Register the media possible values and return the Echo host configuration.
** Generation must already allow the echoScan table as a source table.
***************************************************************************/
public static EchoConfig config(QInstance instance, String backendName, DataSource dataSource, DocumentGenerationService generation,
GenerationPorts.Format format)
{
if(instance.getPossibleValueSource(EchoMediaChoices.NAME) == null)
{
instance.addPossibleValueSource(new QPossibleValueSource().withName(EchoMediaChoices.NAME).withType(QPossibleValueSourceType.CUSTOM)
.withIdType(QFieldType.STRING).withCustomCodeReference(new QCodeReference(EchoMediaChoices.class)));
}
return new EchoConfig(backendName, dataSource, PrintingTables.PRINTER, EchoMediaChoices.NAME,
new EchoPrintingDestinations(format), new EchoDocumentGenerator(generation));
}
}
@@ -0,0 +1,45 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo;
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
/*******************************************************************************
** Bind approved mobile authority before QQQ captures async process context.
** Ordinary operators can use Echo without printer-administration permission.
*******************************************************************************/
public final class EchoPermissions implements CustomPermissionChecker
{
/***************************************************************************
** Read locks are native metadata; all writes go through the example service.
***************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput action, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
{
try
{
if(QContext.getQSession() == null || !QContext.getQSession().hasPermission(EchoQBitProducer.PERMISSION)
|| action instanceof InsertInput || action instanceof UpdateInput || action instanceof DeleteInput)
{
throw new QPermissionDeniedException("Echo access is required; direct state writes are not allowed.");
}
EchoConfig.current().identities.current();
}
catch(Exception failure)
{
throw new QPermissionDeniedException("Echo requires an approved scanner and current Echo access.");
}
}
}
@@ -0,0 +1,388 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import com.kingsrook.qbits.documentgeneration.GenerationPorts;
import com.kingsrook.qbits.documentgeneration.GenerationTables;
import com.kingsrook.qbits.mobilescanners.MobileDeviceAuthorization;
import com.kingsrook.qbits.printing.PrintingMediaProfile;
import com.kingsrook.qbits.printing.PrintingOperatorAuthorization;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** Production destinations from the Printing owner's operator authorization and
** reported queue media, plus the Generation owner's published revision rows.
** No printer, stock or template is invented; unsupported choices are refused.
*******************************************************************************/
public final class EchoPrintingDestinations implements EchoDestinations
{
private static final int MAXIMUM_DOCUMENTS = 200;
private static final String UNAVAILABLE = "The chosen document is not available for this printer and paper or label size. Leave Echo and choose again.";
private final GenerationPorts.Format format;
/***************************************************************************
** The output format is fixed by the host and checked against each printer.
** Documents are discovered from the actor's published Generation templates.
***************************************************************************/
public EchoPrintingDestinations(GenerationPorts.Format format)
{
this.format = Objects.requireNonNull(format);
}
/***************************************************************************
** Current actor, printer row, device and format are authorized by Printing on
** every call; the exact reported media revision is then pinned.
***************************************************************************/
@Override
public Destination authorize(MobileDeviceAuthorization.Identity identity, String printerRecordId, String mediaId) throws QException
{
try
{
if(!identity.actorId().equals(PrintingOperatorAuthorization.principal()))
{
throw new QException("Scanner authorization changed. Leave Echo and sign in again.");
}
QRecord printer = PrintingOperatorAuthorization.printer(printerRecordId, format.name());
return destination(printerRecordId, printer.getValueString("media_profiles_json"), mediaId);
}
catch(RuntimeException failure)
{
throw new QException("Choose an authorized printer and supported paper or label size.");
}
}
/***************************************************************************
** The destination is reauthorized, then the chosen template must be a
** published, compatible one; the run pins its current revision.
***************************************************************************/
@Override
public Document authorizeDocument(MobileDeviceAuthorization.Identity identity, Destination destination, Integer templateId) throws QException
{
if(templateId == null || !destination.equals(authorize(identity, destination.printerRecordId(), destination.mediaId())))
{
throw new QException(UNAVAILABLE);
}
return resolve(destination, templateId).document();
}
/***************************************************************************
** Reauthorize the same destination and the pinned document. A withdrawn or
** republished template fails visibly; no other revision is substituted.
***************************************************************************/
@Override
public Plan plan(MobileDeviceAuthorization.Identity identity, Destination destination, Document document, EchoService.Scan scan) throws QException
{
if(!destination.equals(authorize(identity, destination.printerRecordId(), destination.mediaId())))
{
throw new QException("The selected printer or media is no longer available.");
}
if(document == null || document.templateId() == null)
{
throw new QException("This Echo run has no chosen document. Leave Echo and choose again.");
}
Resolved current = resolve(destination, document.templateId());
if(!current.document().equals(document))
{
throw new QException("The chosen document changed or was withdrawn. Leave Echo and choose again.");
}
String refusal = refusal(current.mode(), scan);
if(refusal != null)
{
throw new QException(refusal);
}
return current.plan();
}
/***************************************************************************
** Published Echo-compatible templates for the selected printer and size.
** Anything the actor cannot read or that does not reproduce the scanned
** symbology dynamically at this exact stock is simply not listed.
***************************************************************************/
@Override
public List<DocumentChoice> documents(String printerRecordId, String mediaId) throws QException
{
Destination destination;
try
{
destination = destination(printerRecordId, PrintingOperatorAuthorization.printer(printerRecordId, format.name()).getValueString("media_profiles_json"), mediaId);
}
catch(RuntimeException failure)
{
throw new QException("Choose an authorized printer and supported paper or label size.");
}
QueryInput input = new QueryInput();
input.setTableName(GenerationTables.TEMPLATE);
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
input.setFilter(new QQueryFilter(new QFilterCriteria("tableName", QCriteriaOperator.EQUALS, EchoQBitProducer.SCAN_TABLE))
.withCriteria(new QFilterCriteria("publishedRevisionId", QCriteriaOperator.IS_NOT_BLANK)).withOrderBy(new QFilterOrderBy("name")).withLimit(MAXIMUM_DOCUMENTS));
List<DocumentChoice> choices = new ArrayList<>();
for(QRecord template : new QueryAction().execute(input).getRecords())
{
try
{
resolve(destination, template.getValueInteger("id"));
choices.add(new DocumentChoice(template.getValueInteger("id"), template.getValueString("name")));
}
catch(QException skipped)
{
// not compatible with this printer media, or not readable by this actor
}
}
return choices;
}
/***************************************************************************
** Current published revision, stock and dynamic scene contract of one
** template, read under the actor's enforced permissions.
***************************************************************************/
private Resolved resolve(Destination destination, Integer templateId) throws QException
{
QRecord template = get(GenerationTables.TEMPLATE, templateId);
Integer revisionId = template.getValueInteger("publishedRevisionId");
if(revisionId == null || !EchoQBitProducer.SCAN_TABLE.equals(template.getValueString("tableName")))
{
throw new QException(UNAVAILABLE);
}
QRecord revision = get(GenerationTables.REVISION, revisionId);
String source = format == GenerationPorts.Format.PDF ? revision.getValueString("pdfSource") : revision.getValueString("zplSource");
GenerationPorts.Stock stock = stock(revision);
SceneMode mode = format == GenerationPorts.Format.ZPL ? sceneMode(source) : SceneMode.PLAIN;
if(!templateId.equals(revision.getValueInteger("templateId")) || !EchoQBitProducer.SCAN_TABLE.equals(revision.getValueString("tableName"))
|| "PREVIEW".equals(revision.getValueString("purpose")) || source == null || source.isBlank()
|| !Integer.valueOf(destination.widthMicrons()).equals(stock.widthMicrons()) || !Integer.valueOf(destination.heightMicrons()).equals(stock.heightMicrons())
|| !Integer.valueOf(destination.dpiX()).equals(stock.dpi()) || mode == null)
{
throw new QException(UNAVAILABLE);
}
return new Resolved(new Document(templateId, revisionId), new Plan(templateId, revisionId, stock, format), mode);
}
/***************************************************************************
** How a published scene feeds the renderer: PLAIN passes only the scanned
** text and type; SCANNER also passes the scanner label and AIM identifier so
** the worker verifies them (and converts EAN/UPC). Anything else is not an
** Echo document.
***************************************************************************/
enum SceneMode
{
PLAIN, SCANNER
}
/***************************************************************************
** The published scene must draw the scanned value with the scanned type via
** dynamic binding, and allow every symbology of its mode - never a fixed one.
***************************************************************************/
static SceneMode sceneMode(String zplScene)
{
try
{
JSONObject scene = new JSONObject(zplScene);
if(!"qqq-zpl-scene/1".equals(scene.optString("schema")))
{
return null;
}
for(Object item : scene.getJSONArray("elements"))
{
JSONObject element = (JSONObject) item;
if(!"barcode".equals(element.optString("type")) || !"/record/rendererSymbology".equals(element.optString("symbologyPath"))
|| !"/record/barcode".equals(element.optString("valuePath")) || element.optJSONArray("allowedSymbologies") == null)
{
continue;
}
java.util.List<Object> allowed = element.getJSONArray("allowedSymbologies").toList();
boolean scanner = element.has("scannerSymbologyPath") || element.has("aimIdentifierPath");
if(!scanner && allowed.containsAll(EchoTemplates.SYMBOLOGIES))
{
return SceneMode.PLAIN;
}
if(scanner && "/record/symbology".equals(element.optString("scannerSymbologyPath")) && "/record/aimIdentifier".equals(element.optString("aimIdentifierPath"))
&& allowed.containsAll(EchoTemplates.SYMBOLOGIES) && allowed.containsAll(EchoTemplates.SCANNER_SYMBOLOGIES))
{
return SceneMode.SCANNER;
}
}
return null;
}
catch(RuntimeException failure)
{
return null;
}
}
/***************************************************************************
** True for any scene that is an Echo document in either mode.
***************************************************************************/
static boolean reproducesScannedSymbology(String zplScene)
{
return sceneMode(zplScene) != null;
}
/***************************************************************************
** Plain scenes reproduce text only and refuse EAN/UPC outright; the
** scanner-fidelity scene needs this scan's AIM identifier. Null means usable.
***************************************************************************/
static String refusal(SceneMode mode, EchoService.Scan scan)
{
if(mode == SceneMode.PLAIN && EchoTemplates.SCANNER_SYMBOLOGIES.contains(scan.rendererSymbology()))
{
return "EAN and UPC need the scanner-fidelity document. Leave Echo and choose it; no print was requested.";
}
if(mode == SceneMode.SCANNER && scan.aimIdentifier() == null)
{
return "The scanner-fidelity document needs the scanner's AIM identifier and this scan has none. Leave Echo and choose the plain document; no print was requested.";
}
return null;
}
/***************************************************************************
** One template's pinned document, the plan derived from it and its scene mode.
***************************************************************************/
private record Resolved(Document document, Plan plan, SceneMode mode)
{
}
/***************************************************************************
** The selected printer's own reported sizes, after Printing's authorization
** of the actor, printer row, device and host output format.
***************************************************************************/
@Override
public List<MediaChoice> media(String printerRecordId) throws QException
{
QRecord printer;
try
{
printer = PrintingOperatorAuthorization.printer(printerRecordId, format.name());
}
catch(RuntimeException failure)
{
throw new QException("Choose an authorized printer and supported paper or label size.");
}
return choices(printer.getValueString("media_profiles_json"));
}
/***************************************************************************
** Public id and a label with the exact physical size and resolution.
***************************************************************************/
static List<MediaChoice> choices(String mediaProfilesJson) throws QException
{
List<MediaChoice> choices = new ArrayList<>();
try
{
for(Object item : new JSONArray(mediaProfilesJson == null ? "[]" : mediaProfilesJson))
{
PrintingMediaProfile profile = PrintingMediaProfile.parse((JSONObject) item);
choices.add(new MediaChoice(profile.id(), profile.name() + " (" + String.format(Locale.ROOT, "%.1f x %.1f mm, %d dpi",
profile.widthMicrons() / 1000.0, profile.heightMicrons() / 1000.0, profile.dpiX()) + ")"));
}
}
catch(RuntimeException failure)
{
throw new QException("Choose an authorized printer and supported paper or label size.");
}
return choices;
}
/***************************************************************************
** Select one reported media profile by identity; never guess dimensions.
***************************************************************************/
static Destination destination(String printerRecordId, String mediaProfilesJson, String mediaId) throws QException
{
try
{
for(Object item : new JSONArray(mediaProfilesJson == null ? "[]" : mediaProfilesJson))
{
PrintingMediaProfile profile = PrintingMediaProfile.parse((JSONObject) item);
if(profile.id().equals(mediaId))
{
return new Destination(printerRecordId, profile.id(), profile.revision(), profile.widthMicrons(), profile.heightMicrons(), profile.dpiX(), profile.dpiY());
}
}
}
catch(RuntimeException failure)
{
throw new QException("Choose an authorized printer and supported paper or label size.");
}
throw new QException("Choose an authorized printer and supported paper or label size.");
}
/***************************************************************************
** Immutable published stock is read from its revision row, not a draft.
***************************************************************************/
static GenerationPorts.Stock stock(QRecord revision)
{
return new GenerationPorts.Stock(revision.getValueInteger("widthMicrons"), revision.getValueInteger("heightMicrons"),
revision.getValueInteger("topMicrons"), revision.getValueInteger("rightMicrons"), revision.getValueInteger("bottomMicrons"),
revision.getValueInteger("leftMicrons"), revision.getValueInteger("dpi"));
}
/***************************************************************************
** Native read under the caller's enforced permissions; missing rows refuse.
***************************************************************************/
private static QRecord get(String table, Integer id) throws QException
{
GetInput input = new GetInput();
input.setTableName(table);
input.setPrimaryKey(id);
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
QRecord record = new GetAction().execute(input).getRecord();
if(record == null)
{
throw new QException(UNAVAILABLE);
}
return record;
}
}
@@ -0,0 +1,127 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo;
import java.util.List;
import com.kingsrook.qbits.printing.PrintingTables;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueRangeBehavior;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
/*******************************************************************************
** Optional example app composed from ordinary QQQ processes and existing QBits.
** Registration never migrates data, publishes templates or emits printer work.
*******************************************************************************/
public final class EchoQBitProducer
{
public static final String APP = "echo";
public static final String PROCESS = "echoPrint";
public static final String WIDGET = "echoScanner";
public static final String SCAN_TABLE = "echoScan";
public static final String PERMISSION = "echo.use";
/***************************************************************************
** Printer/media PVS are owner-supplied; their backend authority is rechecked.
***************************************************************************/
public void produce(QInstance instance, EchoConfig config) throws com.kingsrook.qqq.backend.core.exceptions.QException
{
config.validate(instance);
instance.addQBit(new QBitMetaData().withGroupId("com.kingsrook.examples").withArtifactId("qqq-echo").withVersion("0.1.0-SNAPSHOT").withConfig(config));
if(instance.getSecurityKeyType("qqq.session.userId") == null)
{
instance.addSecurityKeyType(new QSecurityKeyType().withName("qqq.session.userId"));
}
if(instance.getPossibleValueSource(EchoDocumentChoices.NAME) == null)
{
instance.addPossibleValueSource(new QPossibleValueSource().withName(EchoDocumentChoices.NAME).withType(QPossibleValueSourceType.CUSTOM)
.withIdType(QFieldType.INTEGER).withCustomCodeReference(new QCodeReference(EchoDocumentChoices.class)));
}
QFieldMetaData printer = text("printerId", 200).withLabel("Printer").withIsRequired(true).withPossibleValueSourceName(config.printerPossibleValues);
if(PrintingTables.PRINTER.equals(config.printerPossibleValues))
{
printer.withPossibleValueSourceFilter(new QQueryFilter(new QFilterCriteria("enabled", QCriteriaOperator.EQUALS, 1))
.withCriteria(new QFilterCriteria("available", QCriteriaOperator.EQUALS, 1)));
}
QTableMetaData scan = new QTableMetaData().withName(SCAN_TABLE).withLabel("Echo Scans").withBackendName(config.backendName)
.withBackendDetails(new RDBMSTableBackendDetails().withTableName("echo_scan")).withPrimaryKeyField("id")
.withRecordSecurityLock(new RecordSecurityLock().withFieldName("actorId").withSecurityKeyType("qqq.session.userId"))
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
.withCustomPermissionChecker(new QCodeReference(EchoSourceReadPermissions.class)));
for(String name : List.of("id", "runId", "eventId", "actorId", "deviceId", "mobileScanId", "symbology", "rendererSymbology", "scanSource", "capturedAt", "receivedAt", "state", "printJobId"))
{
scan.withField(text(name, 200).withIsEditable(false));
}
scan.withField(text("barcode", 4096).withIsEditable(false));
scan.withField(text("aimIdentifier", 3).withIsEditable(false));
instance.addTable(scan);
instance.addWidget(new QWidgetMetaData().withName(WIDGET).withType("composite").withLabel("Echo barcode scanner")
.withCodeReference(new QCodeReference(EchoWidget.class)).withPermissionRules(rules()));
instance.addProcess(new QProcessMetaData().withName(PROCESS).withLabel("Echo — scan to print").withPermissionRules(rules())
.withCancelStep(new QBackendStepMetaData().withName("cancel").withCode(new QCodeReference(EchoSteps.Cancel.class)))
.withStep(new QBackendStepMetaData().withName("begin").withCode(new QCodeReference(EchoSteps.Begin.class)))
.withStep(new QFrontendStepMetaData().withName("setup").withLabel("Choose printer, paper or label, and document")
.withFormField(printer)
.withFormField(text("mediaId", 200).withLabel("Paper or label size").withIsRequired(true)
.withPossibleValueSourceName(config.mediaPossibleValues).withPossibleValueSourceDependsOn(List.of("printerId")))
.withFormField(new QFieldMetaData("templateId", QFieldType.INTEGER).withLabel("Document").withIsRequired(true)
.withPossibleValueSourceName(EchoDocumentChoices.NAME).withPossibleValueSourceDependsOn(List.of("printerId", "mediaId")))
.withFormField(new QFieldMetaData("copies", QFieldType.INTEGER).withLabel("Copies per scan").withDefaultValue(1).withIsRequired(false)
.withBehavior(new ValueRangeBehavior().withMinValue(1).withMaxValue(EchoService.MAXIMUM_COPIES)))
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM)))
.withStep(new QBackendStepMetaData().withName("start").withCode(new QCodeReference(EchoSteps.Start.class)))
.withStep(new QFrontendStepMetaData().withName("scan").withLabel("Echo — scan to print").withFormat("scanner")
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.WIDGET).withValue("widgetName", WIDGET)))
.withStep(new QBackendStepMetaData().withName("print").withCode(new QCodeReference(EchoSteps.Print.class))));
instance.addApp(new QAppMetaData().withName(APP).withLabel("Echo").withPermissionRules(rules())
.withSection(new QAppSection().withName("echo").withLabel("Scan and print").withProcesses(List.of(PROCESS))));
}
/***************************************************************************
** No printer admin permission or raw table write grant is required.
***************************************************************************/
private static QPermissionRules rules()
{
return new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withCustomPermissionChecker(new QCodeReference(EchoPermissions.class));
}
/***************************************************************************
** Bound identifiers and exact data values in the normal metadata contract.
***************************************************************************/
private static QFieldMetaData text(String name, int maximum)
{
return new QFieldMetaData(name, QFieldType.STRING).withMaxLength(maximum);
}
}
@@ -0,0 +1,496 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo;
import javax.sql.DataSource;
import java.io.Serializable;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Arrays;
import java.util.Base64;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import com.kingsrook.qbits.mobilescanners.MobileDeviceAuthorization;
import com.kingsrook.qqq.backend.core.exceptions.QException;
/*******************************************************************************
** One approved scanner run repeatedly invokes existing generation/printing
** services. Only durable queue acceptance acknowledges a scan to the client.
*******************************************************************************/
public final class EchoService
{
private final EchoStore store;
private final EchoDestinations destinations;
private final EchoGenerator generator;
private final EchoConfig.IdentitySource identities;
/***************************************************************************
** Composition is host-owned and never constructed from process values.
***************************************************************************/
EchoService(DataSource dataSource, EchoDestinations destinations, EchoGenerator generator, EchoConfig.IdentitySource identities)
{
store = new EchoStore(dataSource);
this.destinations = Objects.requireNonNull(destinations);
this.generator = Objects.requireNonNull(generator);
this.identities = Objects.requireNonNull(identities);
}
/***************************************************************************
** Resolve both choices under current authority, then bind them once per run.
***************************************************************************/
public String start(String printerId, String mediaId, Integer templateId) throws QException
{
return start(printerId, mediaId, templateId, null);
}
/***************************************************************************
** As above, with the copies printed for every scan of this run (default 1).
***************************************************************************/
public String start(String printerId, String mediaId, Integer templateId, Integer copies) throws QException
{
String run = UUID.randomUUID().toString();
begin(run);
select(run, printerId, mediaId, templateId, copies);
return run;
}
/***************************************************************************
** Printing's own copies-per-job bounds (PrintingService.printOptions and the
** agent): an integer from 1 to 100, expressed as one job option.
***************************************************************************/
public static final int MAXIMUM_COPIES = 100;
/***************************************************************************
** The count this run pinned at setup (one before setup completes), read only
** for the run's own signed-in scanner.
***************************************************************************/
public int pinnedCopies(String runId) throws QException
{
requireText(runId, 36);
return store.copies(runId, identity());
}
/***************************************************************************
** Absent means the default of one. Anything outside Printing's 1..100 is
** refused during setup, before the run is pinned. Printing's cap is the only
** bound: Echo adds none of its own.
***************************************************************************/
private int copies(Integer requested) throws QException
{
int count = requested == null ? 1 : requested;
if(count < 1 || count > MAXIMUM_COPIES)
{
throw new QException("Copies per scan must be a whole number from 1 to " + MAXIMUM_COPIES + ".");
}
return count;
}
/***************************************************************************
** The backend passes only its protected native process UUID here.
***************************************************************************/
public void begin(String processId) throws QException
{
requireText(processId, 36);
store.begin(processId, identity());
}
/***************************************************************************
** A setup choice cannot be silently changed by repeating the setup step.
***************************************************************************/
public void select(String runId, String printerId, String mediaId, Integer templateId) throws QException
{
select(runId, printerId, mediaId, templateId, null);
}
/***************************************************************************
** The copies per scan are validated and frozen with the printer, media and
** document; repeating setup can confirm them but never change them.
***************************************************************************/
public void select(String runId, String printerId, String mediaId, Integer templateId, Integer copies) throws QException
{
MobileDeviceAuthorization.Identity identity = identity();
requireText(printerId, 200);
requireText(mediaId, 200);
int count = copies(copies);
Integer template = EchoSteps.documentId(templateId);
EchoDestinations.Destination destination = destinations.authorize(identity, printerId, mediaId);
if(destination == null || !printerId.equals(destination.printerRecordId()) || !mediaId.equals(destination.mediaId())
|| destination.mediaRevision() == null || !destination.mediaRevision().matches("[a-f0-9]{64}")
|| destination.widthMicrons() < 1000 || destination.heightMicrons() < 1000 || destination.widthMicrons() > 2000000 || destination.heightMicrons() > 2000000
|| destination.dpiX() < 72 || destination.dpiY() < 72 || destination.dpiX() > 1200 || destination.dpiY() > 1200 || destination.dpiX() != destination.dpiY())
{
throw new QException("Choose an authorized printer and supported paper or label size.");
}
EchoDestinations.Document document = destinations.authorizeDocument(identity, destination, template);
if(document == null || !template.equals(document.templateId()) || document.templateRevisionId() == null || document.templateRevisionId() < 1)
{
throw new QException("Choose an available document for this printer and paper or label size.");
}
check(identity);
store.select(runId, identity, destination, document, count);
}
/***************************************************************************
** Capture context is already canonical server persistence, not a client DTO.
** A stable event is admitted once and retains the same generation request key.
***************************************************************************/
public Acceptance print(String runId, Map<String, Serializable> context) throws QException
{
return print(runId, context, null);
}
/***************************************************************************
** A scan-stage copies value may only repeat the setup choice; the server's
** pinned count, never the value, decides what is printed.
***************************************************************************/
public Acceptance print(String runId, Map<String, Serializable> context, Integer claimedCopies) throws QException
{
requireText(runId, 36);
MobileDeviceAuthorization.Identity identity = identity();
Scan scan = Scan.from(context);
if(!identity.actorId().equals(context.get("userId")) || !identity.deviceId().equals(context.get("deviceId"))
|| !EchoQBitProducer.APP.equals(context.get("appId")) || !EchoQBitProducer.PROCESS.equals(context.get("processName")))
{
throw new QException("Scan context does not match this approved scanner sign-in.");
}
EchoStore.Admission admission = store.claim(runId, identity, scan, claimedCopies);
if(admission.acceptedJob() != null)
{
return new Acceptance(admission.scanId(), scan.eventId(), admission.acceptedJob(), admission.copies());
}
if(admission.reconciliationOnly())
{
if(admission.pinnedPlan() == null)
{
throw new QException("This scan stopped before its rendering intent was pinned. Leave Echo; no automatic replacement print is allowed.");
}
check(identity);
String recovered = generator.lookupAccepted(admission.pinnedPlan(), admission.scanId(), "echo:" + admission.scanId(), admission.destination());
requireText(recovered, 200);
//////////////////////////////////////////////////////////////////////////////////////
// the owner's returned receipt is durable fact: record it, then recheck authority //
// before acknowledging, so a late revocation never loses an accepted queue job //
//////////////////////////////////////////////////////////////////////////////////////
store.accepted(runId, admission.scanId(), scan.eventId(), recovered);
check(identity);
return new Acceptance(admission.scanId(), scan.eventId(), recovered, admission.copies());
}
try
{
EchoDestinations.Destination destination = admission.destination();
EchoDestinations.Destination current = destinations.authorize(identity, destination.printerRecordId(), destination.mediaId());
if(!destination.equals(current))
{
throw new QException("The selected printer or media is no longer available.");
}
if(admission.document() == null)
{
throw new QException("This Echo run has no chosen document. Leave Echo and choose again.");
}
EchoDestinations.Plan plan = destinations.plan(identity, destination, admission.document(), scan);
if(plan == null || plan.templateId() == null || plan.templateId() < 1 || plan.templateRevisionId() == null || plan.templateRevisionId() < 1
|| plan.format() == null || !stockMatches(plan.stock(), destination))
{
throw new QException("No qualified same-symbology layout fits the selected paper or label size.");
}
plan = plan.withCopies(admission.copies());
store.pin(admission.scanId(), plan);
check(identity);
String job = generator.accept(plan, admission.scanId(), "echo:" + admission.scanId(), destination);
requireText(job, 200);
store.accepted(runId, admission.scanId(), scan.eventId(), job);
check(identity);
return new Acceptance(admission.scanId(), scan.eventId(), job, admission.copies());
}
catch(EchoStore.PinNotWon failure)
{
throw failure;
}
catch(QException failure)
{
try
{
store.attention(admission.scanId());
}
catch(QException stateFailure)
{
failure.addSuppressed(stateFailure);
}
throw failure;
}
catch(RuntimeException failure)
{
store.attention(admission.scanId());
throw new QException("Print acceptance is unconfirmed. Retry the same scan or leave Echo.");
}
}
/***************************************************************************
** Physical stock and margins belong to the exact qualified template pin.
***************************************************************************/
private static boolean stockMatches(com.kingsrook.qbits.documentgeneration.GenerationPorts.Stock stock, EchoDestinations.Destination destination)
{
return stock != null && Integer.valueOf(destination.widthMicrons()).equals(stock.widthMicrons())
&& Integer.valueOf(destination.heightMicrons()).equals(stock.heightMicrons()) && Integer.valueOf(destination.dpiX()).equals(stock.dpi())
&& stock.topMicrons() != null && stock.rightMicrons() != null && stock.bottomMicrons() != null && stock.leftMicrons() != null
&& stock.topMicrons() >= 0 && stock.rightMicrons() >= 0 && stock.bottomMicrons() >= 0 && stock.leftMicrons() >= 0
&& (long) stock.topMicrons() + stock.bottomMicrons() < destination.heightMicrons()
&& (long) stock.leftMicrons() + stock.rightMicrons() < destination.widthMicrons();
}
/***************************************************************************
** Run closure does not claim to recall a job already accepted for printing.
***************************************************************************/
public void close(String runId) throws QException
{
store.close(runId, identity());
}
/***************************************************************************
** Never continue using a public identity after its private binding changes.
***************************************************************************/
private void check(MobileDeviceAuthorization.Identity identity) throws QException
{
if(!identity.equals(identity()))
{
throw new QException("Scanner authorization changed. Leave Echo and sign in again.");
}
}
/***************************************************************************
** Direct service calls retain the same Echo-use and mobile authorization.
***************************************************************************/
private MobileDeviceAuthorization.Identity identity() throws QException
{
var session = com.kingsrook.qqq.backend.core.context.QContext.getQSession();
if(session == null || !session.hasPermission(EchoQBitProducer.PERMISSION))
{
throw new QException("Echo access is required.");
}
return identities.current();
}
/***************************************************************************
** Bounded identifiers are distinct from exact untrimmed barcode values.
***************************************************************************/
private static String requireText(Object value, int maximum) throws QException
{
if(!(value instanceof String text) || text.isBlank() || text.length() > maximum)
{
throw new QException("A required Echo value is missing or exceeds its supported size.");
}
return text;
}
/***************************************************************************
** Same-value/type reproduction is explicit. GS1, binary and unknown scanner
** variants need qualified metadata and renderer support before admission.
***************************************************************************/
public record Scan(String eventId, String barcode, String symbology, String rendererSymbology, String scanSource, String capturedAt, String mobileScanId, String receivedAt, String aimIdentifier)
{
/////////////////////////////////////////////////////////////////////////////////////
// the renderer's own plain-mode AIM identifier per type (ScannerSymbology.AIM); //
// any other modifier means a GS1/ECI/structured mode the plain encoder would lose //
/////////////////////////////////////////////////////////////////////////////////////
private static final Map<String, String> PLAIN_AIM = Map.of("code128", "]C0", "code39", "]A0", "qr", "]Q1", "datamatrix", "]d1",
"pdf417", "]L0", "aztec", "]z0", "ean13", "]E0", "ean8", "]E4", "upca", "]E0", "upce", "]E0");
/***********************************************************************
** Preserve exact payload/type; map only known scanner enum aliases.
***********************************************************************/
static Scan from(Map<String, Serializable> context) throws QException
{
if(context == null)
{
throw new QException("Canonical scan context is required.");
}
String event = requireText(context.get("eventId"), 36);
String value = requireText(context.get("barcode"), 4096);
String type = requireText(context.get("symbology"), 200);
String source = requireText(context.get("scanSource"), 200);
String captured = requireText(context.get("capturedAt"), 40);
String mobileScan = requireText(context.get("scanId"), 64);
String received = requireText(context.get("receivedAt"), 40);
try
{
UUID.fromString(event);
Instant.parse(captured);
Instant.parse(received);
}
catch(IllegalArgumentException | java.time.format.DateTimeParseException failure)
{
throw new QException("Scan event identity and timestamp must be valid.");
}
String renderer = switch(type)
{
case "CODE128", "LABEL-TYPE-CODE128" -> "code128";
case "QRCODE", "LABEL-TYPE-QRCODE" -> "qr";
case "CODE39", "LABEL-TYPE-CODE39" -> "code39";
case "DATAMATRIX", "LABEL-TYPE-DATAMATRIX" -> "datamatrix";
case "PDF417", "LABEL-TYPE-PDF417" -> "pdf417";
case "AZTEC", "LABEL-TYPE-AZTEC" -> "aztec";
case "EAN13", "LABEL-TYPE-EAN13" -> "ean13";
case "EAN8", "LABEL-TYPE-EAN8" -> "ean8";
case "UPCA", "LABEL-TYPE-UPCA" -> "upca";
case "UPCE0", "LABEL-TYPE-UPCE0" -> "upce";
default -> throw new QException("This barcode type cannot yet be reproduced faithfully by Echo. No print was requested.");
};
///////////////////////////////////////////////////////////////////////////////////////
// the qualified first-physical profile is printable ASCII 0x20..0x7E for all types; //
// other characters can decode differently under the scanner charset. Never convert. //
///////////////////////////////////////////////////////////////////////////////////////
if(value.chars().anyMatch(character -> character < 0x20 || character > 0x7E))
{
throw new QException("This scan needs control/binary/GS1 or non-ASCII payload handling that Echo has not qualified; only printable ASCII is supported. No print was requested.");
}
String aim = requireFidelityMetadata(context, renderer, value);
return new Scan(event, value, type, renderer, source, captured, mobileScan, received, aim);
}
/***********************************************************************
** Optional mobile-scanners 1.5 evidence may only confirm the plain profile.
** Absence is legacy/manual capture and proves nothing, so it is not trusted.
** EAN/UPC need it, with a valid check digit, because the renderer converts
** their full-length values only in scanner mode. Returns the AIM, if any.
***********************************************************************/
private static String requireFidelityMetadata(Map<String, Serializable> context, String renderer, String value) throws QException
{
String aim = context.get("aimIdentifier") == null ? null : requireText(context.get("aimIdentifier"), 3);
if(aim != null && !PLAIN_AIM.get(renderer).equals(aim))
{
throw new QException("This scan reports a barcode mode (GS1, ECI, structured or full-ASCII) that Echo cannot reproduce faithfully. No print was requested.");
}
if(EchoTemplates.SCANNER_SYMBOLOGIES.contains(renderer))
{
if(aim == null)
{
throw new QException("EAN and UPC need the scanner's AIM identifier to be reproduced faithfully, and this scan has none. No print was requested.");
}
if(!validRetailValue(renderer, value))
{
throw new QException("This EAN or UPC value has the wrong length, number system or check digit. No print was requested.");
}
}
Object raw = context.get("rawDataBase64");
if(raw != null)
{
byte[] bytes;
try
{
bytes = Base64.getDecoder().decode(requireText(raw, 10924));
}
catch(IllegalArgumentException failure)
{
throw new QException("The scanner's raw bytes are malformed. No print was requested.");
}
///////////////////////////////////////////////////////////////////////////////////
// SDK bytes are the value itself or the value behind the configured AIM prefix; //
// both are exact comparisons, and anything else cannot be tied to the value. //
///////////////////////////////////////////////////////////////////////////////////
byte[] unframed = value.getBytes(StandardCharsets.US_ASCII);
byte[] framed = aim == null ? null : (aim + value).getBytes(StandardCharsets.US_ASCII);
if(!Arrays.equals(bytes, unframed) && (framed == null || !Arrays.equals(bytes, framed)))
{
throw new QException("The scanner's raw bytes differ from its display value, so the original cannot be reproduced exactly. No print was requested.");
}
}
return aim;
}
/***********************************************************************
** Exact length, digits, UPC-E number system 0 and GS1 check digit, so the
** renderer is never asked to print a value it would reject or recompute.
***********************************************************************/
static boolean validRetailValue(String renderer, String value)
{
int length = switch(renderer)
{
case "ean13" -> 13;
case "ean8", "upce" -> 8;
case "upca" -> 12;
default -> 0;
};
if(length == 0 || value.length() != length || !value.chars().allMatch(character -> character >= '0' && character <= '9'))
{
return false;
}
String full = value;
if("upce".equals(renderer))
{
if(value.charAt(0) != '0')
{
return false;
}
String digits = value.substring(1, 7);
String body = switch(digits.charAt(5))
{
case '0', '1', '2' -> digits.substring(0, 2) + digits.charAt(5) + "0000" + digits.substring(2, 5);
case '3' -> digits.substring(0, 3) + "00000" + digits.substring(3, 5);
case '4' -> digits.substring(0, 4) + "00000" + digits.charAt(4);
default -> digits.substring(0, 5) + "0000" + digits.charAt(5);
};
full = "0" + body + value.charAt(7);
}
int sum = 0;
for(int index = full.length() - 2, weight = 3; index >= 0; index--, weight = 4 - weight)
{
sum += (full.charAt(index) - '0') * weight;
}
return (10 - sum % 10) % 10 == full.charAt(full.length() - 1) - '0';
}
}
/***************************************************************************
** Only these queue-confirmed values enable the SCANNER acknowledgment.
***************************************************************************/
public record Acceptance(String scanId, String eventId, String printJobId, int copies)
{
}
}
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo;
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
/*******************************************************************************
** Retained source reads use the current actor's ordinary Echo permission and
** the table's native actor lock; reading never grants approved-device capture.
*******************************************************************************/
public final class EchoSourceReadPermissions implements CustomPermissionChecker
{
/***************************************************************************
** Metadata discovery and explicit read actions only; no saved scanner session
** or device identifier is reconstructed for desktop or retained-source reads.
** The exact base AbstractActionInput is core's capability/discovery READ probe
** (it carries no subtype); every concrete write or unknown subclass is refused.
***************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput action, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
{
var session = QContext.getQSession();
if(session == null || session.getUser() == null || session.getUser().getIdReference() == null
|| session.getUser().getIdReference().isBlank() || !session.hasPermission(EchoQBitProducer.PERMISSION)
|| (action != null && action.getClass() != AbstractActionInput.class
&& !(action instanceof GetInput || action instanceof QueryInput || action instanceof CountInput || action instanceof AggregateInput)))
{
throw new QPermissionDeniedException("Current Echo access is required; retained scan sources are read-only.");
}
}
}
@@ -0,0 +1,259 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo;
import java.util.HashMap;
import com.kingsrook.qbits.mobilescanners.MobileScannerActions;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
/*******************************************************************************
** Ordinary server process steps own setup and loop state. The native client
** only renders existing semantic metadata and confirms matching event receipts.
*******************************************************************************/
public final class EchoSteps
{
/***************************************************************************
** Namespace for independently addressable metadata code references.
***************************************************************************/
private EchoSteps()
{
}
/***************************************************************************
** Create server-owned run state before setup, so cancellation also closes a
** run whose printer/media selection has not yet been completed.
***************************************************************************/
public static final class Begin implements BackendStep
{
/***********************************************************************
** The protected framework identity is never read from process values.
***********************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
String run = processId(output);
EchoConfig.current().service.begin(run);
output.addValue("echoRunId", run);
}
}
/***************************************************************************
** Setup runs once; subsequent scans loop directly back to the scan frontend.
***************************************************************************/
public static final class Start implements BackendStep
{
/***********************************************************************
** Persist current authorized selection before the first scanner control.
***********************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
String run = null;
try
{
run = boundRun(input, output);
Integer chosen = copies(input.getValue("copies"));
int copies = chosen == null ? 1 : chosen;
EchoConfig.current().service.select(run, input.getValueString("printerId"), input.getValueString("mediaId"), documentId(input.getValue("templateId")), copies);
output.addValue("copies", copies);
output.addValue(EchoQBitProducer.WIDGET, new EchoWidget.Data(copies));
}
catch(QException failure)
{
restorePinnedCopies(run, output);
recoverable(output, failure);
}
}
}
/***************************************************************************
** Canonical capture precedes generation; saved-scan acknowledgment is delayed
** until the print job has a durable acceptance receipt.
***************************************************************************/
public static final class Print implements BackendStep
{
/***********************************************************************
** Preserve actual type/context and let the same event reconcile failures.
***********************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
String run = null;
try
{
run = boundRun(input, output);
Integer claimed = copies(input.getValue("copies"));
var context = MobileScannerActions.capture(input, EchoQBitProducer.APP);
EchoService.Acceptance accepted = EchoConfig.current().service.print(run, context, claimed);
output.addValue("scanContext", new HashMap<>(context));
output.addValue("scanId", context.get("scanId"));
output.addValue("echoScanId", accepted.scanId());
output.addValue("acknowledgedEventId", accepted.eventId());
output.addValue("scanSaved", true);
output.addValue("printJobId", accepted.printJobId());
output.addValue("printStatus", "QUEUED");
output.addValue("copies", accepted.copies());
output.addValue(EchoQBitProducer.WIDGET, new EchoWidget.Data(accepted.copies()));
output.getProcessState().setNextStepName("scan");
}
catch(QException failure)
{
restorePinnedCopies(run, output);
recoverable(output, failure);
}
}
}
/***************************************************************************
** Native Leave/Cancel closes admission without recalling accepted work.
***************************************************************************/
public static final class Cancel implements BackendStep
{
/***********************************************************************
** Cancellation uses actual server state, never a caller's alternate run.
***********************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
EchoConfig.current().service.close(processId(output));
}
}
/***************************************************************************
** An ordinary business refusal (invalid choice, changed document, printer
** or authorization problem) is reported to the client as the normal process
** error, but must not be thrown out of the step: QQQ replaces the stored
** process state with an empty one when step code throws, which drops its
** ownership record and then denies every later request, including Leave.
** Recording the exception on the output keeps the state, so the operator
** can correct the choice, retry the same scan, or leave.
***************************************************************************/
private static void recoverable(RunBackendStepOutput output, QException failure)
{
output.setException(failure);
}
/***************************************************************************
** Strict integer Document id: an integral number, or plain digits from a
** legacy text form. Fractions, exponents, blanks and overflow are refused.
***************************************************************************/
static Integer documentId(java.io.Serializable raw) throws QException
{
Long value = null;
if(raw instanceof Integer || raw instanceof Long || raw instanceof Short || raw instanceof Byte)
{
value = ((Number) raw).longValue();
}
else if(raw instanceof String text && text.matches("[0-9]{1,10}"))
{
value = Long.parseLong(text);
}
if(value == null || value < 1 || value > Integer.MAX_VALUE)
{
throw new QException("Choose an available document for this printer and paper or label size.");
}
return value.intValue();
}
/***************************************************************************
** A refused or invalid value must not linger in the stored process values
** and block later, legitimate scans: put the server's own count back.
***************************************************************************/
private static void restorePinnedCopies(String run, RunBackendStepOutput output)
{
if(run == null)
{
return;
}
try
{
output.addValue("copies", EchoConfig.current().service.pinnedCopies(run));
}
catch(QException unavailable)
{
// nothing to restore; the original failure is still reported
}
}
/***************************************************************************
** Strict whole-number copies: an integral number or plain digits. Absent or
** blank (a cleared optional field) is null, which setup resolves to one and
** the scan stage treats as no claim. Fractions, exponents, signs, other types
** and out-of-range values are refused rather than coerced.
***************************************************************************/
static Integer copies(java.io.Serializable raw) throws QException
{
if(raw == null || raw instanceof String blank && blank.isBlank())
{
return null;
}
Long value = null;
if(raw instanceof Integer || raw instanceof Long || raw instanceof Short || raw instanceof Byte)
{
value = ((Number) raw).longValue();
}
else if(raw instanceof String text && text.matches("[0-9]{1,6}"))
{
value = Long.parseLong(text);
}
if(value == null || value < 1 || value > EchoService.MAXIMUM_COPIES)
{
throw new QException("Copies per scan must be a whole number from 1 to " + EchoService.MAXIMUM_COPIES + ".");
}
return value.intValue();
}
/***************************************************************************
** Protect the printer/media run from substitution by the same signed-in user.
***************************************************************************/
private static String boundRun(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
String actual = processId(output);
if(!actual.equals(input.getValueString("echoRunId")))
{
throw new QException("Echo run does not match this process. Leave Echo and reopen it.");
}
return actual;
}
/***************************************************************************
** Require the framework's protected session/process binding before using ID.
***************************************************************************/
private static String processId(RunBackendStepOutput output) throws QException
{
var access = output.getProcessState().getStateAccess();
if(access == null)
{
throw new QException("Echo requires protected native process state.");
}
return access.requireCurrentProcessUUID(EchoQBitProducer.PROCESS);
}
}
@@ -0,0 +1,453 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Objects;
import com.kingsrook.qbits.mobilescanners.MobileDeviceAuthorization;
import com.kingsrook.qqq.backend.core.exceptions.QException;
/*******************************************************************************
** Example-owned run/event state. Short database transactions serialize admission;
** rendering and print acceptance execute only after their connection is closed.
** A crash leaves IN_FLIGHT evidence requiring reconciliation, never auto-reprint.
*******************************************************************************/
final class EchoStore
{
private final DataSource dataSource;
/***************************************************************************
** The host owns the data source and migration lifecycle.
***************************************************************************/
EchoStore(DataSource dataSource)
{
this.dataSource = Objects.requireNonNull(dataSource);
}
/***************************************************************************
** One server-generated run binds the operator, sign-in and setup choices.
***************************************************************************/
void begin(String id, MobileDeviceAuthorization.Identity identity) throws QException
{
try(Connection connection = dataSource.getConnection())
{
write(connection, "INSERT INTO echo_run(id,actorId,deviceId,publicSessionId,printerRecordId,mediaId,state) VALUES (?,?,?,?,?,?,?)",
id, identity.actorId(), identity.deviceId(), identity.publicSessionId(), "", "", "SETUP");
}
catch(SQLException failure)
{
throw unavailable();
}
}
/***************************************************************************
** Freeze setup under the run lock; replay may confirm but never retarget it.
***************************************************************************/
void select(String id, MobileDeviceAuthorization.Identity identity, EchoDestinations.Destination destination, EchoDestinations.Document document, int copies) throws QException
{
try(Connection connection = dataSource.getConnection())
{
connection.setAutoCommit(false);
try
{
Run current = run(connection, id, identity);
if("OPEN".equals(current.state()) && current.destination().equals(destination) && document.equals(current.document()) && copies == current.copies())
{
connection.commit();
return;
}
if(!"SETUP".equals(current.state()))
{
throw new QException("Echo setup is already complete or closed. Leave Echo to choose again.");
}
write(connection, "UPDATE echo_run SET state='OPEN',printerRecordId=?,mediaId=?,mediaRevision=?,widthMicrons=?,heightMicrons=?,dpiX=?,dpiY=?,templateId=?,templateRevisionId=?,copies=? WHERE id=?",
destination.printerRecordId(), destination.mediaId(), destination.mediaRevision(), Integer.toString(destination.widthMicrons()),
Integer.toString(destination.heightMicrons()), Integer.toString(destination.dpiX()), Integer.toString(destination.dpiY()),
document.templateId().toString(), document.templateRevisionId().toString(), Integer.toString(copies), id);
connection.commit();
}
catch(Exception failure)
{
rollback(connection, failure);
if(failure instanceof QException expected)
{
throw expected;
}
throw unavailable();
}
}
catch(SQLException failure)
{
throw unavailable();
}
}
/***************************************************************************
** Admit one event per run; same-event accepted retries return their receipt.
** Different payloads, concurrent callbacks and other operators cannot reuse it.
***************************************************************************/
Admission claim(String runId, MobileDeviceAuthorization.Identity identity, EchoService.Scan scan, Integer claimedCopies) throws QException
{
try(Connection connection = dataSource.getConnection())
{
connection.setAutoCommit(false);
try
{
Run run = run(connection, runId, identity);
if(!"OPEN".equals(run.state()))
{
throw new QException("This Echo run is closed. Open Echo again to choose a printer.");
}
///////////////////////////////////////////////////////////////////////////////
// the count was pinned at setup; a scan-stage value can only echo it, and //
// a different one is refused before any event row or pending state exists //
///////////////////////////////////////////////////////////////////////////////
if(claimedCopies != null && claimedCopies != run.copies())
{
throw new QException("Copies per scan was chosen at setup and cannot change. Leave Echo and open it again to choose another count.");
}
String id = scan.mobileScanId();
String acceptedJob = null;
boolean exists = false;
String state = null;
EchoDestinations.Plan pinned = null;
try(PreparedStatement query = connection.prepareStatement("SELECT * FROM echo_scan WHERE id=?"))
{
query.setString(1, id);
try(ResultSet row = query.executeQuery())
{
if(row.next())
{
if(!runId.equals(row.getString("runId")))
{
throw new QException("This scan already belongs to another Echo run. Use a new physical scan event for another label.");
}
exists = true;
state = row.getString("state");
if(row.getObject("templateId") != null && row.getObject("templateRevisionId") != null && row.getString("format") != null)
{
pinned = new EchoDestinations.Plan(row.getInt("templateId"), row.getInt("templateRevisionId"),
new com.kingsrook.qbits.documentgeneration.GenerationPorts.Stock(run.destination().widthMicrons(), run.destination().heightMicrons(),
row.getInt("topMicrons"), row.getInt("rightMicrons"), row.getInt("bottomMicrons"), row.getInt("leftMicrons"), run.destination().dpiX()),
com.kingsrook.qbits.documentgeneration.GenerationPorts.Format.valueOf(row.getString("format")), run.copies());
}
if(!scan.barcode().equals(row.getString("barcode")) || !scan.symbology().equals(row.getString("symbology"))
|| !scan.scanSource().equals(row.getString("scanSource")) || !scan.capturedAt().equals(row.getString("capturedAt"))
|| !scan.mobileScanId().equals(row.getString("mobileScanId")) || !java.util.Objects.equals(scan.aimIdentifier(), row.getString("aimIdentifier")))
{
throw new QException("This scan event was already used for different barcode context.");
}
if("QUEUED".equals(row.getString("state")))
{
acceptedJob = row.getString("printJobId");
}
}
}
}
EchoDestinations.Destination destination = run.destination();
if(acceptedJob != null)
{
connection.commit();
return new Admission(id, destination, acceptedJob, false, pinned, run.document(), run.copies());
}
if(run.pending() != null && !run.pending().equals(scan.eventId()))
{
throw new QException("The previous print is unconfirmed. Retry that scan or leave Echo before starting another.");
}
if(exists)
{
connection.commit();
////////////////////////////////////////////////////////////////////////////////
// no external call can precede the pin, so an unpinned ATTENTION/IN_FLIGHT //
// event may safely be retried; the pin compare-and-set admits only one caller //
////////////////////////////////////////////////////////////////////////////////
boolean unpinnedRetry = pinned == null && ("ATTENTION".equals(state) || "IN_FLIGHT".equals(state));
return new Admission(id, destination, null, !unpinnedRetry, pinned, run.document(), run.copies());
}
else
{
write(connection, "INSERT INTO echo_scan(id,runId,eventId,actorId,deviceId,barcode,symbology,rendererSymbology,scanSource,capturedAt,mobileScanId,receivedAt,state,aimIdentifier) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
id, runId, scan.eventId(), identity.actorId(), identity.deviceId(), scan.barcode(), scan.symbology(), scan.rendererSymbology(), scan.scanSource(), scan.capturedAt(), scan.mobileScanId(), scan.receivedAt(), "IN_FLIGHT", scan.aimIdentifier());
}
write(connection, "UPDATE echo_run SET pendingEventId=? WHERE id=?", scan.eventId(), runId);
connection.commit();
return new Admission(id, destination, null, false, null, run.document(), run.copies());
}
catch(Exception failure)
{
rollback(connection, failure);
if(failure instanceof QException expected)
{
throw expected;
}
throw unavailable();
}
}
catch(SQLException failure)
{
throw unavailable();
}
}
/***************************************************************************
** Persist exact rendering intent before external work can accept a job.
** Exactly one caller may pin an unpinned event (reviving ATTENTION to
** IN_FLIGHT); any other outcome means no external request may be made.
***************************************************************************/
void pin(String scanId, EchoDestinations.Plan plan) throws QException
{
try(Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement(
"UPDATE echo_scan SET state='IN_FLIGHT',templateId=?,templateRevisionId=?,format=?,topMicrons=?,rightMicrons=?,bottomMicrons=?,leftMicrons=? WHERE id=? AND state IN ('IN_FLIGHT','ATTENTION') AND templateId IS NULL"))
{
String[] values = {plan.templateId().toString(), plan.templateRevisionId().toString(), plan.format().name(), plan.stock().topMicrons().toString(),
plan.stock().rightMicrons().toString(), plan.stock().bottomMicrons().toString(), plan.stock().leftMicrons().toString(), scanId};
for(int index = 0; index < values.length; index++)
{
statement.setString(index + 1, values[index]);
}
if(statement.executeUpdate() != 1)
{
throw new PinNotWon();
}
}
catch(SQLException failure)
{
throw unavailable();
}
}
/***************************************************************************
** Commit the queue receipt before acknowledging the scanner's event identity.
** Closing a run concurrently never reopens it or cancels an accepted job.
***************************************************************************/
void accepted(String runId, String scanId, String eventId, String job) throws QException
{
try(Connection connection = dataSource.getConnection())
{
connection.setAutoCommit(false);
try
{
write(connection, "UPDATE echo_scan SET state='QUEUED', printJobId=? WHERE id=?", job, scanId);
write(connection, "UPDATE echo_run SET pendingEventId=NULL WHERE id=? AND pendingEventId=?", runId, eventId);
connection.commit();
}
catch(SQLException failure)
{
rollback(connection, failure);
throw unavailable();
}
}
catch(SQLException failure)
{
throw unavailable();
}
}
/***************************************************************************
** A failed or lost response keeps its event identity and pending run binding.
** No automatic retry, replacement print request or success acknowledgment.
***************************************************************************/
void attention(String scanId) throws QException
{
try(Connection connection = dataSource.getConnection())
{
write(connection, "UPDATE echo_scan SET state='ATTENTION' WHERE id=? AND state='IN_FLIGHT'", scanId);
}
catch(SQLException failure)
{
throw unavailable();
}
}
/***************************************************************************
** Read the run's pinned count under the same ownership checks as every call.
***************************************************************************/
int copies(String runId, MobileDeviceAuthorization.Identity identity) throws QException
{
try(Connection connection = dataSource.getConnection())
{
connection.setAutoCommit(false);
try
{
int copies = run(connection, runId, identity).copies();
connection.commit();
return copies;
}
catch(Exception failure)
{
rollback(connection, failure);
if(failure instanceof QException expected)
{
throw expected;
}
throw unavailable();
}
}
catch(SQLException failure)
{
throw unavailable();
}
}
/***************************************************************************
** Explicit end stops admission; existing queue receipts and jobs are retained.
***************************************************************************/
void close(String runId, MobileDeviceAuthorization.Identity identity) throws QException
{
try(Connection connection = dataSource.getConnection())
{
connection.setAutoCommit(false);
try
{
run(connection, runId, identity);
write(connection, "UPDATE echo_run SET state='CLOSED' WHERE id=?", runId);
connection.commit();
}
catch(Exception failure)
{
rollback(connection, failure);
if(failure instanceof QException expected)
{
throw expected;
}
throw unavailable();
}
}
catch(SQLException failure)
{
throw unavailable();
}
}
/***************************************************************************
** Lock and check actual persisted ownership, never process-supplied actors.
***************************************************************************/
private Run run(Connection connection, String id, MobileDeviceAuthorization.Identity identity) throws Exception
{
try(PreparedStatement query = connection.prepareStatement("SELECT * FROM echo_run WHERE id=? FOR UPDATE"))
{
query.setString(1, id);
try(ResultSet row = query.executeQuery())
{
if(!row.next() || !identity.actorId().equals(row.getString("actorId")) || !identity.deviceId().equals(row.getString("deviceId"))
|| !identity.publicSessionId().equals(row.getString("publicSessionId")))
{
throw new QException("This Echo run does not belong to this scanner sign-in.");
}
return new Run(row.getString("state"), row.getString("pendingEventId"), new EchoDestinations.Destination(row.getString("printerRecordId"),
row.getString("mediaId"), row.getString("mediaRevision"), row.getInt("widthMicrons"), row.getInt("heightMicrons"), row.getInt("dpiX"), row.getInt("dpiY")),
row.getObject("templateId") == null || row.getObject("templateRevisionId") == null ? null
: new EchoDestinations.Document(row.getInt("templateId"), row.getInt("templateRevisionId")), row.getInt("copies"));
}
}
}
/***************************************************************************
** All SQL identifiers are fixed source; external values are bound parameters.
***************************************************************************/
private static void write(Connection connection, String sql, String... values) throws SQLException
{
try(PreparedStatement statement = connection.prepareStatement(sql))
{
for(int index = 0; index < values.length; index++)
{
statement.setString(index + 1, values[index]);
}
statement.executeUpdate();
}
}
/***************************************************************************
** Preserve the original failure; raw database diagnostics are not user text.
***************************************************************************/
private static void rollback(Connection connection, Exception original)
{
try
{
connection.rollback();
}
catch(SQLException failure)
{
original.addSuppressed(failure);
}
}
/***************************************************************************
** Never expose SQL payloads, private identifiers or vendor exception text.
***************************************************************************/
private static QException unavailable()
{
return new QException("Echo state is unavailable. Do not resubmit as a new scan; reconcile the existing event.");
}
/***************************************************************************
** Another attempt owns or already pinned this event; it must not be marked
** ATTENTION or reach the generator from this caller.
***************************************************************************/
static final class PinNotWon extends QException
{
/*********************************************************************
** Fixed user-safe text; no identifiers.
*********************************************************************/
PinNotWon()
{
super("Another attempt is already handling this scan. Retry the same scan shortly or leave Echo.");
}
}
/***************************************************************************
** State is loaded only under its database admission lock.
***************************************************************************/
private record Run(String state, String pending, EchoDestinations.Destination destination, EchoDestinations.Document document, int copies)
{
}
/***************************************************************************
** Accepted replay has a job; new/reconciled work does not yet acknowledge.
***************************************************************************/
record Admission(String scanId, EchoDestinations.Destination destination, String acceptedJob, boolean reconciliationOnly, EchoDestinations.Plan pinnedPlan,
EchoDestinations.Document document, int copies)
{
}
}
@@ -0,0 +1,141 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo;
import java.util.List;
import com.kingsrook.qbits.documentgeneration.DocumentDefinitionService;
import com.kingsrook.qbits.documentgeneration.GenerationPorts;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import org.json.JSONArray;
import org.json.JSONObject;
/*******************************************************************************
** One-time operator action: publish the ZPL barcode template for one reported
** media profile through Generation's ordinary definition service. The renderer
** worker fits the exact scanned symbology and value at integral module sizes
** with quiet zones, and refuses what does not fit. Never run per scan.
*******************************************************************************/
public final class EchoTemplatePublisher
{
static final int STOCK_MARGIN_MICRONS = 3000;
static final int INSET_MICRONS = 4000;
static final int QUIET_MODULES = 10;
/***************************************************************************
** Static operator action only.
***************************************************************************/
private EchoTemplatePublisher()
{
}
/***************************************************************************
** Stock exactly equal to the reported media, with fixed printable margins.
***************************************************************************/
static GenerationPorts.Stock stock(EchoDestinations.Destination media) throws QException
{
if(!List.of(203, 300, 600).contains(media.dpiX()) || media.dpiX() != media.dpiY()
|| media.widthMicrons() <= 2 * INSET_MICRONS || media.heightMicrons() <= 2 * INSET_MICRONS)
{
throw new QException("ZPL Echo labels require 203, 300 or 600 dpi media larger than the printable inset.");
}
return new GenerationPorts.Stock(media.widthMicrons(), media.heightMicrons(), STOCK_MARGIN_MICRONS, STOCK_MARGIN_MICRONS,
STOCK_MARGIN_MICRONS, STOCK_MARGIN_MICRONS, media.dpiX());
}
/***************************************************************************
** Dynamic type requires fit and an allowlist. Scanner attestation fields are
** omitted: client text is not trusted decoder configuration.
***************************************************************************/
static String zplScene(EchoDestinations.Destination media) throws QException
{
stock(media);
int dots = (int) Math.ceil(INSET_MICRONS * media.dpiX() / 25400.0);
JSONObject element = new JSONObject().put("type", "barcode").put("symbologyPath", "/record/rendererSymbology")
.put("allowedSymbologies", new JSONArray(EchoTemplates.SYMBOLOGIES)).put("valuePath", "/record/barcode")
.put("x", dots).put("y", dots).put("quiet", QUIET_MODULES)
.put("fit", new JSONObject().put("widthMicrons", media.widthMicrons() - 2 * INSET_MICRONS).put("heightMicrons", media.heightMicrons() - 2 * INSET_MICRONS));
return new JSONObject().put("schema", "qqq-zpl-scene/1").put("elements", new JSONArray().put(element)).toString();
}
/***************************************************************************
** Scanner-fidelity scene: the renderer's scanner mode verifies the recorded
** label and AIM identifier for every type and converts EAN/UPC full values.
** The paths are the echoScan row fields the Generation record exposes.
***************************************************************************/
static String zplScannerScene(EchoDestinations.Destination media) throws QException
{
JSONObject scene = new JSONObject(zplScene(media));
JSONObject element = scene.getJSONArray("elements").getJSONObject(0);
List<String> allowed = new java.util.ArrayList<>(EchoTemplates.SYMBOLOGIES);
allowed.addAll(EchoTemplates.SCANNER_SYMBOLOGIES);
element.put("allowedSymbologies", new JSONArray(allowed)).put("scannerSymbologyPath", "/record/symbology").put("aimIdentifierPath", "/record/aimIdentifier");
return scene.toString();
}
/***************************************************************************
** Publish immutable stock and source; the owner validates it in the isolated
** renderer. Returns the template id for EchoTemplates.forMedia.
***************************************************************************/
public static Integer publish(DocumentDefinitionService definitions, EchoDestinations.Destination media) throws QException
{
return publish(definitions, media, null);
}
/***************************************************************************
** Publish one more document for the same media under its own operator-facing
** name; each published template becomes a separate choice in Echo's setup.
***************************************************************************/
public static Integer publish(DocumentDefinitionService definitions, EchoDestinations.Destination media, String name) throws QException
{
return publish(definitions, media, name, zplScene(media), "Echo ", "Echo scan label");
}
/***************************************************************************
** Publish the separate, clearly named scanner-fidelity document: it passes the
** recorded scanner label and AIM identifier to the renderer's scanner mode,
** which verifies them and is the only way EAN/UPC reproduce. It needs an AIM
** identifier on every scan, so the plain document stays the choice for older
** scanners and servers.
***************************************************************************/
public static Integer publishScannerFidelity(DocumentDefinitionService definitions, EchoDestinations.Destination media, String name) throws QException
{
return publish(definitions, media, name, zplScannerScene(media), "Echo scanner-fidelity (AIM, EAN/UPC) ", "Echo scanner-fidelity label");
}
/***************************************************************************
** Shared definition steps; only the scene and the operator-facing names differ.
***************************************************************************/
private static Integer publish(DocumentDefinitionService definitions, EchoDestinations.Destination media, String name, String scene, String prefix, String typeName) throws QException
{
GenerationPorts.Stock stock = stock(media);
String label = name == null || name.isBlank() ? media.mediaId() : name.strip();
Integer type = definitions.createType(name == null || name.isBlank() ? typeName : typeName + " " + label);
Integer stockId = definitions.createStock(prefix + label, stock);
definitions.bind(type, EchoQBitProducer.SCAN_TABLE, false, true);
Integer template = definitions.createTemplate(prefix + label, type, stockId, EchoQBitProducer.SCAN_TABLE);
definitions.saveSources(template, stockId, null, scene, definitions.loadDraft(template).fingerprint());
definitions.publish(template, definitions.loadDraft(template).fingerprint());
return template;
}
}
@@ -0,0 +1,87 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo;
import java.util.Map;
import com.kingsrook.qqq.backend.core.exceptions.QException;
/*******************************************************************************
** Host-owned choice of a published Document Generation template for one exact
** symbology and reported printer media revision. Templates carry their own
** stock, so a changed media revision needs a newly qualified mapping.
*******************************************************************************/
@FunctionalInterface
public interface EchoTemplates
{
/***************************************************************************
** Return the template whose published revision was qualified for this tuple.
***************************************************************************/
Integer templateId(String rendererSymbology, EchoDestinations.Destination destination) throws QException;
/***************************************************************************
** Exact lookup by renderer symbology, media identity and media revision.
***************************************************************************/
static EchoTemplates of(Map<String, Integer> templates)
{
Map<String, Integer> copy = Map.copyOf(templates);
return (symbology, destination) ->
{
Integer id = copy.get(key(symbology, destination.mediaId(), destination.mediaRevision()));
if(id == null)
{
throw new QException("No qualified same-symbology layout fits the selected paper or label size.");
}
return id;
};
}
/***************************************************************************
** One published template, whose renderer allowlist covers every symbology
** Echo admits, for exactly this reported media identity and revision.
***************************************************************************/
static EchoTemplates forMedia(String mediaId, String mediaRevision, Integer templateId)
{
Map<String, Integer> map = new java.util.HashMap<>();
for(String symbology : SYMBOLOGIES)
{
map.put(key(symbology, mediaId, mediaRevision), templateId);
}
return of(map);
}
/***************************************************************************
** Fit-profile symbologies the plain-mode template reproduces from the exact
** scanned text, with no scanner metadata.
***************************************************************************/
java.util.List<String> SYMBOLOGIES = java.util.List.of("code128", "qr", "code39", "datamatrix", "pdf417", "aztec");
/***************************************************************************
** EAN/UPC: the worker converts their full-length scanned values only in
** scanner mode, which needs the recorded label and AIM identifier. Only the
** scanner-fidelity document (see EchoTemplatePublisher) reproduces them.
***************************************************************************/
java.util.List<String> SCANNER_SYMBOLOGIES = java.util.List.of("ean13", "ean8", "upca", "upce");
/***************************************************************************
** Stable map key shared by hosts and the lookup above.
***************************************************************************/
static String key(String rendererSymbology, String mediaId, String mediaRevision)
{
return rendererSymbology + ":" + mediaId + ":" + mediaRevision;
}
}
@@ -0,0 +1,76 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo;
import java.io.Serializable;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.mobilescanners.MobileScannerWidget;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
/*******************************************************************************
** Reuse the existing registered semantic SCANNER control, with Echo instructions.
*******************************************************************************/
public final class EchoWidget extends AbstractWidgetRenderer
{
/***************************************************************************
** No new client control, process-name inference or custom business endpoint.
***************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input)
{
return new RenderWidgetOutput(new Data());
}
/***************************************************************************
** Input geometry and event acknowledgments are the mobile extension contract.
***************************************************************************/
public static final class Data extends MobileScannerWidget.ScannerWidgetData
{
private final int copies;
/***********************************************************************
** Before setup completes the count is unknown: the plain instructions.
***********************************************************************/
public Data()
{
this(1);
}
/***********************************************************************
** The run's server-pinned copies per scan, shown while scanning.
***********************************************************************/
public Data(int copies)
{
this.copies = copies;
}
/***********************************************************************
** Make queue versus paper semantics and leave behavior visible.
***********************************************************************/
@Override
public List<Map<String, Serializable>> getSubBlocks()
{
return List.of(Map.of("blockId", "echoScanner", "blockType", "SCANNER", "values",
new java.util.HashMap<>(Map.of("title", "Echo — scan to print", "instructions",
"Scan one barcode and wait for print queue acceptance."
+ (copies > 1 ? " Each scan prints " + copies + " copies." : "")
+ " Leaving stops new scans; work already processing or accepted may still print.",
"acknowledgmentLabel", "Print queued", "manualEntryEnabled", false, "maxLength", 4096))));
}
}
}
@@ -0,0 +1,26 @@
-- Copyright (c) 2026 Kingsrook, LLC. SPDX-License-Identifier: Apache-2.0
-- Host migration input, not automatically executed by metadata registration.
CREATE TABLE IF NOT EXISTS echo_run (
id VARCHAR(36) PRIMARY KEY, actorId VARCHAR(200) NOT NULL, deviceId VARCHAR(36) NOT NULL,
publicSessionId VARCHAR(36) NOT NULL, printerRecordId VARCHAR(200) NOT NULL, mediaId VARCHAR(200) NOT NULL,
mediaRevision VARCHAR(64) NOT NULL DEFAULT '', widthMicrons INT NOT NULL DEFAULT 0, heightMicrons INT NOT NULL DEFAULT 0,
dpiX INT NOT NULL DEFAULT 0, dpiY INT NOT NULL DEFAULT 0,
state VARCHAR(30) NOT NULL, pendingEventId VARCHAR(36)
);
-- Preserving migration: the document chosen once at setup. Existing rows keep NULL and are never cleared.
ALTER TABLE echo_run ADD COLUMN IF NOT EXISTS templateId INT;
ALTER TABLE echo_run ADD COLUMN IF NOT EXISTS templateRevisionId INT;
CREATE TABLE IF NOT EXISTS echo_scan (
id VARCHAR(64) PRIMARY KEY, runId VARCHAR(36) NOT NULL REFERENCES echo_run(id),
eventId VARCHAR(36) NOT NULL, actorId VARCHAR(200) NOT NULL, deviceId VARCHAR(36) NOT NULL, mobileScanId VARCHAR(64) NOT NULL,
barcode VARCHAR(4096) NOT NULL, symbology VARCHAR(200) NOT NULL, rendererSymbology VARCHAR(100) NOT NULL,
scanSource VARCHAR(200) NOT NULL, capturedAt VARCHAR(40) NOT NULL, receivedAt VARCHAR(40) NOT NULL, state VARCHAR(30) NOT NULL,
printJobId VARCHAR(200), UNIQUE(runId, eventId)
,templateId INT, templateRevisionId INT, format VARCHAR(10), topMicrons INT, rightMicrons INT, bottomMicrons INT, leftMicrons INT
);
-- Preserving migration: the scanner's AIM identifier (for example ]E0), when the capture carried one. Existing rows keep NULL
-- (no AIM was recorded) and are never rewritten. Other databases need the equivalent additive nullable VARCHAR(3) column.
ALTER TABLE echo_scan ADD COLUMN IF NOT EXISTS aimIdentifier VARCHAR(3);
-- Preserving migration: copies printed per scan, chosen once at setup. Existing runs keep exactly one copy (their prior behaviour).
-- Other databases need the equivalent additive INT NOT NULL DEFAULT 1 column.
ALTER TABLE echo_run ADD COLUMN IF NOT EXISTS copies INT NOT NULL DEFAULT 1;
@@ -0,0 +1,484 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import com.kingsrook.qbits.documentgeneration.DocumentGenerationService;
import com.kingsrook.qbits.documentgeneration.GenerationPorts;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** Controlled owner facades qualify Echo's adapter mapping, exact pins and
** no-reprint recovery. They do not render or print anything.
*******************************************************************************/
class EchoOwnerAdaptersTest
{
private static final String REVISION = "a".repeat(64);
private static final GenerationPorts.Stock STOCK = new GenerationPorts.Stock(101600, 152400, 3000, 3000, 3000, 3000, 300);
private static final EchoDestinations.Destination DESTINATION = new EchoDestinations.Destination("printer-row", "4x6", REVISION, 101600, 152400, 300, 300);
private static final EchoDestinations.Plan PLAN = new EchoDestinations.Plan(7, 11, STOCK, GenerationPorts.Format.ZPL);
private static final String PROFILES = "[{\"id\":\"4x6\",\"revision\":\"" + REVISION + "\",\"name\":\"4 x 6\",\"widthMicrons\":101600,"
+ "\"heightMicrons\":152400,\"dpiX\":300,\"dpiY\":300,\"source\":\"WINDOWS_QUEUE_DEFAULT\",\"applyMode\":\"DRIVER_DEFAULT\"}]";
/***************************************************************************
** The reported profile identity, revision and exact dimensions are used.
***************************************************************************/
@Test
void reportedProfileBecomesExactDestination() throws Exception
{
assertEquals(DESTINATION, EchoPrintingDestinations.destination("printer-row", PROFILES, "4x6"));
}
/***************************************************************************
** Unknown, malformed or absent media never receives guessed dimensions.
***************************************************************************/
@Test
void unknownOrMalformedMediaIsRefused()
{
assertThrows(QException.class, () -> EchoPrintingDestinations.destination("printer-row", PROFILES, "2x1"));
assertThrows(QException.class, () -> EchoPrintingDestinations.destination("printer-row", "[{\"id\":\"4x6\"}]", "4x6"));
assertThrows(QException.class, () -> EchoPrintingDestinations.destination("printer-row", "not json", "4x6"));
assertThrows(QException.class, () -> EchoPrintingDestinations.destination("printer-row", null, "4x6"));
}
/***************************************************************************
** Template choice is exact for symbology, media and media revision.
***************************************************************************/
@Test
void templateMappingIsExact() throws Exception
{
EchoTemplates templates = EchoTemplates.of(Map.of(EchoTemplates.key("qr", "4x6", REVISION), 7));
assertEquals(7, templates.templateId("qr", DESTINATION));
assertThrows(QException.class, () -> templates.templateId("code128", DESTINATION));
assertThrows(QException.class, () -> templates.templateId("qr", new EchoDestinations.Destination("printer-row", "4x6", "b".repeat(64), 101600, 152400, 300, 300)));
}
/***************************************************************************
** Generation receives the exact tuple, selected media and stable key.
***************************************************************************/
@Test
void acceptSendsExactPinsAndReturnsTheQueuedJob() throws Exception
{
List<DocumentGenerationService.Request> sent = new ArrayList<>();
EchoDocumentGenerator generator = new EchoDocumentGenerator(owner(sent, "QUEUED", "job-1", Optional.empty()));
assertEquals("job-1", generator.accept(PLAN, "scan-1", "echo:scan-1", DESTINATION));
DocumentGenerationService.Request request = sent.getFirst();
assertEquals(7, request.templateId());
assertEquals(new GenerationPorts.Source("echoScan", "scan-1"), request.source());
assertEquals(GenerationPorts.Format.ZPL, request.format());
assertEquals("echo:scan-1", request.requestKey());
assertEquals("printer-row", request.destination());
assertEquals(new DocumentGenerationService.Expected(7, 11, STOCK), request.expected());
assertEquals(new GenerationPorts.MediaProfile("4x6", REVISION), request.mediaProfile());
}
/***************************************************************************
** The run's pinned count reaches Generation's request, one request per scan,
** beside the exact media pin; a plain plan is the one-copy request.
***************************************************************************/
@Test
void pinnedCopiesReachTheGenerationRequestInOneRequest() throws Exception
{
List<DocumentGenerationService.Request> sent = new ArrayList<>();
EchoDocumentGenerator generator = new EchoDocumentGenerator(owner(sent, "QUEUED", "job-3", Optional.empty()));
assertEquals("job-3", generator.accept(PLAN.withCopies(3), "scan-3", "echo:scan-3", DESTINATION));
assertEquals(1, sent.size());
assertEquals(3, sent.getFirst().copies());
assertEquals(new GenerationPorts.MediaProfile("4x6", REVISION), sent.getFirst().mediaProfile());
assertEquals("echo:scan-3", sent.getFirst().requestKey());
generator.accept(PLAN, "scan-1", "echo:scan-1", DESTINATION);
assertEquals(1, sent.get(1).copies());
generator.accept(PLAN.withCopies(100), "scan-100", "echo:scan-100", DESTINATION);
assertEquals(100, sent.get(2).copies());
assertEquals(1, new EchoDestinations.Plan(7, 11, STOCK, GenerationPorts.Format.ZPL).copies());
for(int invalid : new int[] { 0, -1, 101, Integer.MAX_VALUE })
{
assertThrows(QException.class, () -> generator.accept(PLAN.withCopies(invalid), "scan-x", "echo:scan-x", DESTINATION));
}
assertEquals(3, sent.size());
}
/***************************************************************************
** Recovery trusts an accepted print only when the owner's committed intent
** has the same count, so a lost response never adopts another count's job.
***************************************************************************/
@Test
void acceptedLookupIsBoundToTheSameCopies() throws Exception
{
GenerationPorts.MediaProfile media = new GenerationPorts.MediaProfile("4x6", REVISION);
for(int accepted : new int[] { 1, 2, 3, 4, 100 })
{
GenerationPorts.PrintAcceptance printing = new GenerationPorts.PrintAcceptance(new GenerationPorts.PrintRequest("op", "actor",
new GenerationPorts.Source("echoScan", "scan-3"), null, "printer-row", media, accepted), "job-" + accepted, "event-" + accepted, Instant.now());
EchoDocumentGenerator generator = new EchoDocumentGenerator(owner(new ArrayList<>(), "QUEUED", "job-1",
Optional.of(new DocumentGenerationService.AcceptedPrint(null, printing))));
for(int pinned : new int[] { 1, 2, 3, 4, 100 })
{
if(pinned == accepted)
{
assertEquals("job-" + accepted, generator.lookupAccepted(PLAN.withCopies(pinned), "scan-3", "echo:scan-3", DESTINATION));
}
else
{
assertThrows(QException.class, () -> generator.lookupAccepted(PLAN.withCopies(pinned), "scan-3", "echo:scan-3", DESTINATION));
}
}
}
}
/***************************************************************************
** An incomplete or unqueued owner result is never reported as accepted.
***************************************************************************/
@Test
void unqueuedOrBlankJobIsRefused()
{
assertThrows(QException.class, () -> new EchoDocumentGenerator(owner(new ArrayList<>(), "INCOMPLETE", null, Optional.empty())).accept(PLAN, "s", "echo:s", DESTINATION));
assertThrows(QException.class, () -> new EchoDocumentGenerator(owner(new ArrayList<>(), "QUEUED", " ", Optional.empty())).accept(PLAN, "s", "echo:s", DESTINATION));
}
/***************************************************************************
** QUEUED is reported only with an exact durable, available retained receipt.
***************************************************************************/
@Test
void missingOrUndurableReceiptIsNeverQueued()
{
GenerationPorts.Receipt undurable = new GenerationPorts.Receipt("file", "version", "e".repeat(64), 10L, false, true);
GenerationPorts.Receipt quarantined = new GenerationPorts.Receipt("file", "version", "e".repeat(64), 10L, true, false);
for(GenerationPorts.Receipt receipt : new GenerationPorts.Receipt[] { null, undurable, quarantined })
{
EchoDocumentGenerator generator = new EchoDocumentGenerator(owner(new ArrayList<>(), "QUEUED", "job-1", Optional.empty(), receipt));
assertThrows(QException.class, () -> generator.accept(PLAN, "s", "echo:s", DESTINATION));
}
}
/***************************************************************************
** Only the selected printer's sizes are offered, by id or plain-text search.
***************************************************************************/
@Test
void mediaChoicesAreFilteredByPrinterChoicesOnly() throws Exception
{
List<EchoDestinations.MediaChoice> choices = EchoPrintingDestinations.choices(PROFILES);
assertEquals(1, choices.size());
assertEquals("4x6", choices.getFirst().id());
assertTrue(choices.getFirst().label().contains("101.6 x 152.4 mm, 300 dpi"));
assertEquals(1, EchoMediaChoices.select(choices, null, "4 x", 0, 50).size());
assertEquals(0, EchoMediaChoices.select(choices, null, "label", 0, 50).size());
assertEquals(0, EchoMediaChoices.select(choices, List.of("2x1"), null, 0, 50).size());
assertEquals("4x6", EchoMediaChoices.select(choices, List.of("4x6"), null, 0, 50).getFirst().getId());
assertEquals(0, EchoMediaChoices.select(choices, null, null, 1, 50).size());
}
/***************************************************************************
** The dependency value selects the printer; no value means no choices.
***************************************************************************/
@Test
void printerDependencyValueIsRequiredAndBounded() throws Exception
{
assertEquals(null, EchoMediaChoices.printer(null));
assertEquals(null, EchoMediaChoices.printer(Map.of("printerId", " ")));
assertEquals("row-1", EchoMediaChoices.printer(Map.of("printerId", "row-1")));
assertThrows(QException.class, () -> EchoMediaChoices.printer(Map.of("printerId", "x".repeat(201))));
}
/***************************************************************************
** The published scene fits inside the exact stock and allowlists only the
** symbologies Echo admits, with no client scanner attestation.
***************************************************************************/
@Test
void publishedSceneFitsTheExactStockAndAllowlist() throws Exception
{
org.json.JSONObject scene = new org.json.JSONObject(EchoTemplatePublisher.zplScene(DESTINATION));
org.json.JSONObject element = scene.getJSONArray("elements").getJSONObject(0);
assertEquals("qqq-zpl-scene/1", scene.getString("schema"));
assertEquals("/record/rendererSymbology", element.getString("symbologyPath"));
assertEquals("/record/barcode", element.getString("valuePath"));
assertEquals(EchoTemplates.SYMBOLOGIES.size(), element.getJSONArray("allowedSymbologies").length());
assertEquals(93600, element.getJSONObject("fit").getInt("widthMicrons"));
assertEquals(144400, element.getJSONObject("fit").getInt("heightMicrons"));
assertEquals(48, element.getInt("x"));
assertTrue(!element.has("scannerSymbologyPath"));
assertEquals(new GenerationPorts.Stock(101600, 152400, 3000, 3000, 3000, 3000, 300), EchoTemplatePublisher.stock(DESTINATION));
assertThrows(QException.class, () -> EchoTemplatePublisher.stock(new EchoDestinations.Destination("p", "m", REVISION, 101600, 152400, 250, 250)));
assertEquals(7, EchoTemplates.forMedia("4x6", REVISION, 7).templateId("aztec", DESTINATION));
assertThrows(QException.class, () -> EchoTemplates.forMedia("4x6", REVISION, 7).templateId("maxicode", DESTINATION));
assertThrows(QException.class, () -> EchoTemplates.forMedia("4x6", REVISION, 7).templateId("ean13", DESTINATION));
}
/***************************************************************************
** The scene has exactly the key shape of the renderer owner's own dynamic
** fit fixture (BarcodeFitTest.dynamicZplFitIsBoundedByTemplateAllowlist) and
** satisfies the worker's static rules, with its integer dot arithmetic, for
** 4 x 6 in at 300 dpi: allowlisted fit types, quiet >= 10 for linear types,
** x/y at or past the left/top margin, and the fitted box inside the right and
** bottom printable limits. Worker acceptance itself needs the renderer.
***************************************************************************/
@Test
void sceneMatchesOwnerFixtureShapeAndWorkerGeometryRules() throws Exception
{
org.json.JSONObject scene = new org.json.JSONObject(EchoTemplatePublisher.zplScene(DESTINATION));
org.json.JSONObject element = scene.getJSONArray("elements").getJSONObject(0);
assertEquals(java.util.Set.of("schema", "elements"), scene.keySet());
assertEquals(java.util.Set.of("type", "symbologyPath", "allowedSymbologies", "valuePath", "x", "y", "quiet", "fit"), element.keySet());
assertEquals(java.util.Set.of("widthMicrons", "heightMicrons"), element.getJSONObject("fit").keySet());
java.util.List<String> fitTypes = java.util.List.of("code128", "code39", "code93", "itf", "gs1-128", "qr", "ean13", "ean8", "upca", "upce", "datamatrix", "pdf417", "aztec");
for(Object type : element.getJSONArray("allowedSymbologies"))
{
assertTrue(fitTypes.contains(type));
}
assertTrue(element.getJSONArray("allowedSymbologies").toList().containsAll(java.util.List.of("code128", "qr")));
assertTrue(element.getInt("quiet") >= 10 && element.getInt("quiet") <= 64);
GenerationPorts.Stock stock = EchoTemplatePublisher.stock(DESTINATION);
int dpi = stock.dpi();
long minimumX = ((long) stock.leftMicrons() * dpi + 25399) / 25400;
long minimumY = ((long) stock.topMicrons() * dpi + 25399) / 25400;
long boxWidth = (long) element.getJSONObject("fit").getInt("widthMicrons") * dpi / 25400;
long boxHeight = (long) element.getJSONObject("fit").getInt("heightMicrons") * dpi / 25400;
long rightLimit = (long) (stock.widthMicrons() - stock.rightMicrons()) * dpi / 25400;
long bottomLimit = (long) (stock.heightMicrons() - stock.bottomMicrons()) * dpi / 25400;
assertTrue(element.getInt("x") >= minimumX && element.getInt("y") >= minimumY);
assertTrue(element.getInt("x") + boxWidth <= rightLimit && element.getInt("y") + boxHeight <= bottomLimit);
assertTrue(element.getJSONObject("fit").getInt("widthMicrons") <= stock.widthMicrons() - stock.leftMicrons() - stock.rightMicrons());
assertTrue(element.getJSONObject("fit").getInt("heightMicrons") <= stock.heightMicrons() - stock.topMicrons() - stock.bottomMicrons());
}
/***************************************************************************
** The scanner-fidelity scene is the plain scene plus exactly the renderer's
** scanner-mode path pair, and still fits the stock and the worker's rules.
***************************************************************************/
@Test
void scannerFidelitySceneAddsOnlyTheScannerPathsAndEanUpc() throws Exception
{
org.json.JSONObject plain = new org.json.JSONObject(EchoTemplatePublisher.zplScene(DESTINATION)).getJSONArray("elements").getJSONObject(0);
org.json.JSONObject scene = new org.json.JSONObject(EchoTemplatePublisher.zplScannerScene(DESTINATION));
org.json.JSONObject element = scene.getJSONArray("elements").getJSONObject(0);
assertEquals(java.util.Set.of("schema", "elements"), scene.keySet());
java.util.Set<String> keys = new java.util.HashSet<>(plain.keySet());
keys.addAll(java.util.List.of("scannerSymbologyPath", "aimIdentifierPath"));
assertEquals(keys, element.keySet());
assertEquals("/record/symbology", element.getString("scannerSymbologyPath"));
assertEquals("/record/aimIdentifier", element.getString("aimIdentifierPath"));
assertEquals(EchoTemplates.SYMBOLOGIES.size() + EchoTemplates.SCANNER_SYMBOLOGIES.size(), element.getJSONArray("allowedSymbologies").length());
assertTrue(element.getJSONArray("allowedSymbologies").toList().containsAll(java.util.List.of("ean13", "ean8", "upca", "upce", "code128", "qr")));
assertEquals(plain.getJSONObject("fit").toMap(), element.getJSONObject("fit").toMap());
assertEquals(plain.getInt("x"), element.getInt("x"));
assertEquals(plain.getString("valuePath"), element.getString("valuePath"));
assertEquals(EchoPrintingDestinations.SceneMode.SCANNER, EchoPrintingDestinations.sceneMode(scene.toString()));
assertEquals(EchoPrintingDestinations.SceneMode.PLAIN, EchoPrintingDestinations.sceneMode(EchoTemplatePublisher.zplScene(DESTINATION)));
assertTrue(EchoPrintingDestinations.reproducesScannedSymbology(scene.toString()));
}
/***************************************************************************
** A scene must pair both scanner paths at the row's own fields and cover both
** symbology lists; a half-wired or mis-pointed scene is not an Echo document.
***************************************************************************/
@Test
void malformedScannerScenesAreNotDocuments() throws Exception
{
String good = EchoTemplatePublisher.zplScannerScene(DESTINATION);
for(String change : java.util.List.of("scannerSymbologyPath", "aimIdentifierPath"))
{
org.json.JSONObject scene = new org.json.JSONObject(good);
scene.getJSONArray("elements").getJSONObject(0).remove(change);
assertEquals(null, EchoPrintingDestinations.sceneMode(scene.toString()));
scene = new org.json.JSONObject(good);
scene.getJSONArray("elements").getJSONObject(0).put(change, "/record/barcode");
assertEquals(null, EchoPrintingDestinations.sceneMode(scene.toString()));
}
org.json.JSONObject noRetail = new org.json.JSONObject(good);
noRetail.getJSONArray("elements").getJSONObject(0).put("allowedSymbologies", new org.json.JSONArray(EchoTemplates.SYMBOLOGIES));
assertEquals(null, EchoPrintingDestinations.sceneMode(noRetail.toString()));
org.json.JSONObject plainWithPaths = new org.json.JSONObject(EchoTemplatePublisher.zplScene(DESTINATION));
plainWithPaths.getJSONArray("elements").getJSONObject(0).put("aimIdentifierPath", "/record/aimIdentifier");
assertEquals(null, EchoPrintingDestinations.sceneMode(plainWithPaths.toString()));
}
/***************************************************************************
** EAN/UPC print only from the scanner-fidelity document, which in turn needs
** the AIM identifier; the plain document keeps serving the legacy types.
***************************************************************************/
@Test
void documentModeAndScanMustAgree() throws Exception
{
EchoService.Scan retail = new EchoService.Scan("e", "5901234123457", "LABEL-TYPE-EAN13", "ean13", "zebra-emdk", "c", "m", "r", "]E0");
EchoService.Scan legacyCode128 = new EchoService.Scan("e", "001234", "CODE128", "code128", "zebra-emdk", "c", "m", "r", null);
EchoService.Scan aimCode128 = new EchoService.Scan("e", "001234", "CODE128", "code128", "zebra-emdk", "c", "m", "r", "]C0");
assertTrue(EchoPrintingDestinations.refusal(EchoPrintingDestinations.SceneMode.PLAIN, retail).contains("scanner-fidelity"));
assertEquals(null, EchoPrintingDestinations.refusal(EchoPrintingDestinations.SceneMode.SCANNER, retail));
assertEquals(null, EchoPrintingDestinations.refusal(EchoPrintingDestinations.SceneMode.PLAIN, legacyCode128));
assertEquals(null, EchoPrintingDestinations.refusal(EchoPrintingDestinations.SceneMode.PLAIN, aimCode128));
assertEquals(null, EchoPrintingDestinations.refusal(EchoPrintingDestinations.SceneMode.SCANNER, aimCode128));
assertTrue(EchoPrintingDestinations.refusal(EchoPrintingDestinations.SceneMode.SCANNER, legacyCode128).contains("AIM"));
}
/***************************************************************************
** Only a scene that draws the scanned value with the scanned type, allowing
** every admitted symbology, is an eligible document - never a static one.
***************************************************************************/
@Test
void onlyDynamicSameSymbologyScenesAreEligibleDocuments() throws Exception
{
assertTrue(EchoPrintingDestinations.reproducesScannedSymbology(EchoTemplatePublisher.zplScene(DESTINATION)));
org.json.JSONObject scene = new org.json.JSONObject(EchoTemplatePublisher.zplScene(DESTINATION));
org.json.JSONObject element = scene.getJSONArray("elements").getJSONObject(0);
org.json.JSONObject fixedType = new org.json.JSONObject(scene.toString());
fixedType.getJSONArray("elements").getJSONObject(0).remove("symbologyPath");
fixedType.getJSONArray("elements").getJSONObject(0).put("symbology", "code128");
assertTrue(!EchoPrintingDestinations.reproducesScannedSymbology(fixedType.toString()));
org.json.JSONObject fixedValue = new org.json.JSONObject(scene.toString());
fixedValue.getJSONArray("elements").getJSONObject(0).put("valuePath", "/record/other");
assertTrue(!EchoPrintingDestinations.reproducesScannedSymbology(fixedValue.toString()));
org.json.JSONObject narrow = new org.json.JSONObject(scene.toString());
narrow.getJSONArray("elements").getJSONObject(0).put("allowedSymbologies", new org.json.JSONArray(List.of("code128")));
assertTrue(!EchoPrintingDestinations.reproducesScannedSymbology(narrow.toString()));
assertTrue(!EchoPrintingDestinations.reproducesScannedSymbology("not json"));
assertTrue(!EchoPrintingDestinations.reproducesScannedSymbology("{\"schema\":\"other\",\"elements\":[]}"));
assertEquals("barcode", element.getString("type"));
}
/***************************************************************************
** Absent deployment-owned printing, renderer or templates are refused, and
** the owner's plain dispatch constructor still denies selected profiles.
***************************************************************************/
@Test
void absentDeploymentPartsAreRefused()
{
assertThrows(NullPointerException.class, () -> new EchoHost.Deployment("p", "g", "e", null, null, null));
// the renderer's output cap must be inside the owner's 1 byte to 100 MiB bounds
for(int invalid : new int[] { 0, -1, 100 * 1024 * 1024 + 1 })
{
assertThrows(IllegalArgumentException.class, () -> new EchoHost.Deployment("p", "g", "e", null, null, null, invalid));
}
assertTrue(!com.kingsrook.qbits.documentgeneration.printing.PrintingDispatch.MediaCapability.UNSUPPORTED.enforcesSelectedProfile());
}
/***************************************************************************
** Lookup is read-only; absence refuses and presence returns the owner job.
***************************************************************************/
@Test
void lookupNeverGeneratesAndRefusesAbsence() throws Exception
{
List<DocumentGenerationService.Request> sent = new ArrayList<>();
EchoDocumentGenerator absent = new EchoDocumentGenerator(owner(sent, "QUEUED", "job-1", Optional.empty()));
assertThrows(QException.class, () -> absent.lookupAccepted(PLAN, "scan-1", "echo:scan-1", DESTINATION));
assertTrue(sent.isEmpty());
DocumentGenerationService.Request request = EchoDocumentGenerator.request(PLAN, "scan-1", "echo:scan-1", DESTINATION);
GenerationPorts.PrintAcceptance accepted = new GenerationPorts.PrintAcceptance(
new GenerationPorts.PrintRequest("op", "actor", request.source(), null, "printer-row", request.mediaProfile()), "job-9", "event-9", Instant.now());
EchoDocumentGenerator present = new EchoDocumentGenerator(owner(sent, "QUEUED", "job-1",
Optional.of(new DocumentGenerationService.AcceptedPrint(null, accepted))));
assertEquals("job-9", present.lookupAccepted(PLAN, "scan-1", "echo:scan-1", DESTINATION));
assertTrue(sent.isEmpty());
}
/***************************************************************************
** A lookup bound to different media is not accepted evidence.
***************************************************************************/
@Test
void lookupWithDifferentMediaIsRefused()
{
GenerationPorts.PrintAcceptance other = new GenerationPorts.PrintAcceptance(new GenerationPorts.PrintRequest("op", "actor",
new GenerationPorts.Source("echoScan", "scan-1"), null, "printer-row", new GenerationPorts.MediaProfile("4x6", "c".repeat(64))), "job-9", "event-9", Instant.now());
EchoDocumentGenerator generator = new EchoDocumentGenerator(owner(new ArrayList<>(), "QUEUED", "job-1",
Optional.of(new DocumentGenerationService.AcceptedPrint(null, other))));
assertThrows(QException.class, () -> generator.lookupAccepted(PLAN, "scan-1", "echo:scan-1", DESTINATION));
}
/***************************************************************************
** Media identifiers outside Printing's grammar fail as a normal refusal.
***************************************************************************/
@Test
void invalidMediaIdentityIsAQException()
{
EchoDestinations.Destination bad = new EchoDestinations.Destination("printer-row", "bad media!", REVISION, 101600, 152400, 300, 300);
assertThrows(QException.class, () -> EchoDocumentGenerator.request(PLAN, "s", "echo:s", bad));
}
/***************************************************************************
** Controlled owner returning the given state and recording requests.
***************************************************************************/
private static EchoDocumentGenerator.Owner owner(List<DocumentGenerationService.Request> sent, String state, String job,
Optional<DocumentGenerationService.AcceptedPrint> lookup)
{
return owner(sent, state, job, lookup, new GenerationPorts.Receipt("file", "version", "e".repeat(64), 10L, true, true));
}
/***************************************************************************
** Controlled owner with an explicit retained receipt.
***************************************************************************/
private static EchoDocumentGenerator.Owner owner(List<DocumentGenerationService.Request> sent, String state, String job,
Optional<DocumentGenerationService.AcceptedPrint> lookup, GenerationPorts.Receipt receipt)
{
return new EchoDocumentGenerator.Owner()
{
@Override
public DocumentGenerationService.Result generate(DocumentGenerationService.Request request)
{
sent.add(request);
return new DocumentGenerationService.Result(1, state, receipt, job, request.expected(), "d".repeat(64), request.mediaProfile(), null);
}
@Override
public Optional<DocumentGenerationService.AcceptedPrint> lookup(DocumentGenerationService.Request request)
{
return lookup;
}
};
}
}
@@ -0,0 +1,144 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.Statement;
import java.util.UUID;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** The additive H2 migration keeps existing rows and can be applied repeatedly.
*******************************************************************************/
class EchoSchemaMigrationTest
{
/***************************************************************************
** A database created before the AIM column keeps its rows unchanged (NULL AIM),
** gains the nullable column, and a second application changes nothing.
***************************************************************************/
@Test
void aimColumnIsAddedToExistingScansWithoutRewritingThem() throws Exception
{
JdbcDataSource dataSource = new JdbcDataSource();
dataSource.setURL("jdbc:h2:mem:echo_migration_" + UUID.randomUUID().toString().replace("-", "") + ";DATABASE_TO_UPPER=FALSE;DB_CLOSE_DELAY=-1");
dataSource.setUser("sa");
try(Connection connection = dataSource.getConnection())
{
apply(connection);
execute(connection, "ALTER TABLE echo_scan DROP COLUMN aimIdentifier");
execute(connection, "INSERT INTO echo_run(id,actorId,deviceId,publicSessionId,printerRecordId,mediaId,state) VALUES ('run','actor','device','session','printer','4x6','OPEN')");
execute(connection, "INSERT INTO echo_scan(id,runId,eventId,actorId,deviceId,mobileScanId,barcode,symbology,rendererSymbology,scanSource,capturedAt,receivedAt,state)"
+ " VALUES ('legacy','run','event','actor','device','legacy','001234','CODE128','code128','zebra-emdk','2026-11-01T17:00:00Z','2026-11-01T17:00:01Z','QUEUED')");
apply(connection);
apply(connection);
try(Statement query = connection.createStatement(); var rows = query.executeQuery("SELECT barcode,aimIdentifier,state FROM echo_scan"))
{
assertTrue(rows.next());
assertEquals("001234", rows.getString("barcode"));
assertNull(rows.getString("aimIdentifier"));
assertEquals("QUEUED", rows.getString("state"));
assertTrue(!rows.next());
}
try(Statement query = connection.createStatement(); var columns = query.executeQuery(
"SELECT IS_NULLABLE, CHARACTER_MAXIMUM_LENGTH FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='echo_scan' AND COLUMN_NAME='aimIdentifier'"))
{
assertTrue(columns.next());
assertEquals("YES", columns.getString(1));
assertEquals(3, columns.getInt(2));
}
execute(connection, "UPDATE echo_scan SET aimIdentifier=']E0' WHERE id='legacy'");
apply(connection);
try(Statement query = connection.createStatement(); var rows = query.executeQuery("SELECT aimIdentifier FROM echo_scan"))
{
assertTrue(rows.next());
assertEquals("]E0", rows.getString(1));
}
}
}
/***************************************************************************
** A run created before copies existed reads back exactly one copy, new runs
** default to one, and repeating the script keeps every chosen count.
***************************************************************************/
@Test
void copiesColumnDefaultsExistingRunsToOne() throws Exception
{
JdbcDataSource dataSource = new JdbcDataSource();
dataSource.setURL("jdbc:h2:mem:echo_copies_" + UUID.randomUUID().toString().replace("-", "") + ";DATABASE_TO_UPPER=FALSE;DB_CLOSE_DELAY=-1");
dataSource.setUser("sa");
try(Connection connection = dataSource.getConnection())
{
apply(connection);
execute(connection, "ALTER TABLE echo_run DROP COLUMN copies");
execute(connection, "INSERT INTO echo_run(id,actorId,deviceId,publicSessionId,printerRecordId,mediaId,state) VALUES ('legacy','actor','device','session','printer','4x6','OPEN')");
apply(connection);
apply(connection);
execute(connection, "INSERT INTO echo_run(id,actorId,deviceId,publicSessionId,printerRecordId,mediaId,state) VALUES ('fresh','actor','device','session','printer','4x6','SETUP')");
execute(connection, "INSERT INTO echo_run(id,actorId,deviceId,publicSessionId,printerRecordId,mediaId,state,copies) VALUES ('chosen','actor','device','session','printer','4x6','OPEN',7)");
apply(connection);
try(Statement query = connection.createStatement(); var rows = query.executeQuery("SELECT id,copies FROM echo_run ORDER BY id"))
{
assertTrue(rows.next());
assertEquals("chosen", rows.getString(1));
assertEquals(7, rows.getInt(2));
assertTrue(rows.next());
assertEquals("fresh", rows.getString(1));
assertEquals(1, rows.getInt(2));
assertTrue(rows.next());
assertEquals("legacy", rows.getString(1));
assertEquals(1, rows.getInt(2));
assertTrue(!rows.next());
}
try(Statement query = connection.createStatement(); var columns = query.executeQuery(
"SELECT IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='echo_run' AND COLUMN_NAME='copies'"))
{
assertTrue(columns.next());
assertEquals("NO", columns.getString(1));
}
}
}
/***************************************************************************
** Run the shipped script statement by statement, as the host and tests do.
***************************************************************************/
private void apply(Connection connection) throws Exception
{
try(var resource = getClass().getResourceAsStream("/db/echo-h2.sql"))
{
for(String statement : new String(resource.readAllBytes(), StandardCharsets.UTF_8).split(";"))
{
if(!statement.isBlank())
{
execute(connection, statement);
}
}
}
}
/***************************************************************************
** One statement on its own handle.
***************************************************************************/
private void execute(Connection connection, String sql) throws Exception
{
try(Statement statement = connection.createStatement())
{
statement.execute(sql);
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,358 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo.fixture;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import com.kingsrook.examples.echo.EchoConfig;
import com.kingsrook.examples.echo.EchoDestinations;
import com.kingsrook.examples.echo.EchoGenerator;
import com.kingsrook.examples.echo.EchoPermissions;
import com.kingsrook.examples.echo.EchoQBitProducer;
import com.kingsrook.examples.echo.EchoService;
import com.kingsrook.qbits.documentgeneration.GenerationPorts;
import com.kingsrook.qbits.mobilescanners.MobileDeviceAuthorization;
import com.kingsrook.qbits.mobilescanners.demo.DemoAuthenticationModule;
import com.kingsrook.qbits.mobilescanners.demo.MobileScannersDemo;
import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer;
import org.apache.logging.log4j.LogManager;
import org.h2.jdbcx.JdbcDataSource;
/*******************************************************************************
** Test-only semantic HTTP host. Authentication/device approval are the existing
** demo implementation; output acceptance is visibly simulated and never prints.
*******************************************************************************/
public final class EchoHttpFixture implements AutoCloseable
{
public static final String TYPE = "echo-ui-e2e";
public static final String RUN_TABLE = "echoFixtureRun";
public static final String PRINTER = "fixture-printer";
public static final String MEDIA = "fixture-4x6";
public static final int DOCUMENT_A = 7;
public static final int DOCUMENT_B = 8;
private final MobileScannersDemo application;
private final Generator generator = new Generator();
private QApplicationJavalinServer server;
/***************************************************************************
** Random memory databases and caller-owned secrets cannot reference normal
** operator data. All metadata is composed before serving any request.
***************************************************************************/
public EchoHttpFixture(String scannerPassword, String adminPassword) throws Exception
{
requirePassword(scannerPassword);
requirePassword(adminPassword);
String jdbc = "jdbc:h2:mem:echo_ui_" + UUID.randomUUID().toString().replace("-", "") + ";DATABASE_TO_UPPER=FALSE;DB_CLOSE_DELAY=-1";
JdbcDataSource dataSource = new JdbcDataSource();
dataSource.setURL(jdbc);
dataSource.setUser("sa");
application = new MobileScannersDemo(jdbc).withBranding("Echo UI E2E — simulated printing", "#375A7F", null);
application.initializeDatabase(scannerPassword, adminPassword);
QInstance instance = application.defineQInstance();
instance.getAuthentication().withModuleCodeReference(new QCodeReference(Authentication.class));
instance.addPossibleValueSource(new QPossibleValueSource().withName("echoFixturePrinters").withType(QPossibleValueSourceType.ENUM)
.withEnumValues(List.of(new QPossibleValue<>(PRINTER, "Simulated printer"))));
instance.addPossibleValueSource(new QPossibleValueSource().withName("echoFixtureMedia").withType(QPossibleValueSourceType.ENUM)
.withEnumValues(List.of(new QPossibleValue<>(MEDIA, "Simulated 4 x 6 in — 300 dpi"))));
EchoConfig config = new EchoConfig("mobileDb", dataSource, "echoFixturePrinters", "echoFixtureMedia", new Destinations(), generator);
new EchoQBitProducer().produce(instance, config);
QTableMetaData run = new QTableMetaData().withName(RUN_TABLE).withLabel("Simulated Echo run inspection").withBackendName("mobileDb")
.withBackendDetails(new RDBMSTableBackendDetails().withTableName("echo_run")).withPrimaryKeyField("id")
.withRecordSecurityLock(new RecordSecurityLock().withFieldName("actorId").withSecurityKeyType("qqq.session.userId"))
.withPermissionRules(readRules());
for(String name : List.of("id", "actorId", "deviceId", "state", "pendingEventId", "printerRecordId", "mediaId"))
{
run.withField(new QFieldMetaData(name, QFieldType.STRING).withMaxLength(200).withIsEditable(false));
}
run.withField(new QFieldMetaData("templateId", QFieldType.INTEGER).withIsEditable(false));
run.withField(new QFieldMetaData("templateRevisionId", QFieldType.INTEGER).withIsEditable(false));
run.withField(new QFieldMetaData("copies", QFieldType.INTEGER).withIsEditable(false));
instance.addTable(run);
try(var connection = dataSource.getConnection(); var resource = EchoHttpFixture.class.getResourceAsStream("/db/echo-h2.sql"))
{
if(resource == null)
{
throw new IllegalStateException("Echo test schema is missing.");
}
String sql = new String(resource.readAllBytes(), StandardCharsets.UTF_8).replaceAll("(?m)^--.*$", "");
for(String statement : sql.split(";"))
{
if(!statement.isBlank())
{
try(var command = connection.createStatement())
{
command.execute(statement);
}
}
}
}
}
/***************************************************************************
** Only a fresh loopback listener is allowed; protected runtimes are refused.
***************************************************************************/
public void start(int port) throws Exception
{
if(port < 1024 || port > 65535 || Set.of(57832, 51631, 18081, 18086, 18443, 19443, 18447, 18448, 18547, 18549, 18593).contains(port))
{
throw new IllegalArgumentException("Echo UI tests require a fresh unprotected loopback port.");
}
if(server != null)
{
throw new IllegalStateException("Echo UI fixture already started.");
}
server = application.start(port);
LogManager.getLogger(EchoHttpFixture.class).info("ECHO_UI_E2E_READY port={} simulatedPrinting=true", port);
}
/***************************************************************************
** Distinct simulated acceptances so far: one per scan, however many copies.
***************************************************************************/
public int acceptedJobCount()
{
return generator.receipts.size();
}
/***************************************************************************
** The copies each simulated acceptance was asked for, in no fixed order.
***************************************************************************/
public List<Integer> acceptedCopies()
{
return generator.receipts.values().stream().map(receipt -> receipt.plan().copies()).toList();
}
/***************************************************************************
** Stop only the server constructed by this fixture.
***************************************************************************/
@Override
public void close()
{
if(server != null)
{
server.stop();
}
}
/***************************************************************************
** Explicit test opt-in and environment-only secrets; no fallback passwords.
***************************************************************************/
public static void main(String[] args) throws Exception
{
if(!TYPE.equals(System.getenv("ECHO_FIXTURE_TYPE")) || args.length != 1)
{
throw new IllegalArgumentException("Set ECHO_FIXTURE_TYPE=echo-ui-e2e and provide one fresh loopback port.");
}
System.setProperty("qqq.logger.logSessionId.disabled", "true");
EchoHttpFixture fixture = new EchoHttpFixture(System.getenv("ECHO_FIXTURE_SCANNER_PASSWORD"), System.getenv("ECHO_FIXTURE_ADMIN_PASSWORD"));
Runtime.getRuntime().addShutdownHook(new Thread(fixture::close));
fixture.start(Integer.parseInt(args[0]));
}
/***************************************************************************
** Missing secrets fail before any server listener is created.
***************************************************************************/
private static void requirePassword(String value)
{
if(value == null || value.length() < 12)
{
throw new IllegalArgumentException("Provide private ECHO_FIXTURE_SCANNER_PASSWORD and ECHO_FIXTURE_ADMIN_PASSWORD values of at least 12 characters.");
}
}
/***************************************************************************
** Native queries are real QQQ operations; no fixture business API is added.
***************************************************************************/
private static QPermissionRules readRules()
{
return new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withCustomPermissionChecker(new QCodeReference(EchoPermissions.class));
}
/***************************************************************************
** Existing TABLE password/session validation, with narrow fixture role grants.
***************************************************************************/
public static final class Authentication extends DemoAuthenticationModule
{
/***********************************************************************
** The validated account selects its role, never a request parameter.
***********************************************************************/
@Override
public QSession createSession(QInstance instance, Map<String, String> context) throws QAuthenticationException
{
return permissions(super.createSession(instance, context));
}
/***********************************************************************
** Keep direct native action session revalidation consistent with login.
***********************************************************************/
@Override
public boolean isSessionValid(QInstance instance, QSession session)
{
boolean valid = super.isSessionValid(instance, session);
if(valid)
{
permissions(session);
}
return valid;
}
/***********************************************************************
** No printing administration permission exists in this fixture.
***********************************************************************/
private static QSession permissions(QSession session)
{
if(session.getUser() != null && List.of("scanner", "admin").contains(session.getUser().getIdReference()))
{
session.withPermission(EchoQBitProducer.PERMISSION);
}
return session;
}
}
/***************************************************************************
** Explicit synthetic profile is never discovered from a physical printer.
***************************************************************************/
private static final class Destinations implements EchoDestinations
{
/***********************************************************************
** Submitted selection IDs are checked independently from the form labels.
***********************************************************************/
@Override
public Destination authorize(MobileDeviceAuthorization.Identity identity, String printer, String media) throws QException
{
if(!PRINTER.equals(printer) || !MEDIA.equals(media))
{
throw new QException("Choose the simulated printer and simulated media in this test fixture.");
}
return new Destination(PRINTER, MEDIA, "f".repeat(64), 101600, 152400, 300, 300);
}
/***********************************************************************
** This fixture exercises orchestration, not actual fitting or rendering.
***********************************************************************/
@Override
public Plan plan(MobileDeviceAuthorization.Identity identity, Destination destination, Document document, EchoService.Scan scan) throws QException
{
if(!authorizeDocument(identity, destination, document.templateId()).equals(document))
{
throw new QException("The chosen simulated document changed.");
}
return new Plan(document.templateId(), document.templateRevisionId(), new GenerationPorts.Stock(101600, 152400, 5000, 5000, 5000, 5000, 300), GenerationPorts.Format.PDF);
}
/***********************************************************************
** Two simulated published documents, selected by operator choice.
***********************************************************************/
@Override
public Document authorizeDocument(MobileDeviceAuthorization.Identity identity, Destination destination, Integer templateId) throws QException
{
if(!List.of(DOCUMENT_A, DOCUMENT_B).contains(templateId))
{
throw new QException("Choose a simulated document in this test fixture.");
}
return new Document(templateId, 1);
}
/***********************************************************************
** Simulated choices for the dependent Document field.
***********************************************************************/
@Override
public List<DocumentChoice> documents(String printer, String media) throws QException
{
return PRINTER.equals(printer) && MEDIA.equals(media) ? List.of(new DocumentChoice(DOCUMENT_A, "Simulated label A"), new DocumentChoice(DOCUMENT_B, "Simulated label B")) : List.of();
}
}
/***************************************************************************
** Test-only acceptance recorder, not a production queue or printing adapter.
***************************************************************************/
private static final class Generator implements EchoGenerator
{
private final Map<String, Accepted> receipts = new ConcurrentHashMap<>();
/***********************************************************************
** Namespaced receipt IDs make simulated output unmistakable in admin rows.
***********************************************************************/
@Override
public String accept(EchoDestinations.Plan plan, String source, String key, EchoDestinations.Destination destination) throws QException
{
String actor = MobileDeviceAuthorization.captureCurrent().orElseThrow(() -> new QException("A verified scanner is required.")).actorId();
Accepted value = new Accepted(plan, source, destination, actor, "fixture-job-" + source);
Accepted prior = receipts.putIfAbsent(key, value);
if(prior != null && !prior.equals(value))
{
throw new QException("Simulated receipt identity was reused for a different intent.");
}
return value.job();
}
/***********************************************************************
** Lookup is strictly read-only and binds current actor plus exact intent.
***********************************************************************/
@Override
public String lookupAccepted(EchoDestinations.Plan plan, String source, String key, EchoDestinations.Destination destination) throws QException
{
Accepted receipt = receipts.get(key);
String actor = MobileDeviceAuthorization.captureCurrent().orElseThrow(() -> new QException("A verified scanner is required.")).actorId();
if(receipt == null || !receipt.plan().equals(plan) || !receipt.source().equals(source) || !receipt.destination().equals(destination) || !receipt.actor().equals(actor))
{
throw new QException("No exact simulated acceptance receipt is available.");
}
return receipt.job();
}
}
/***************************************************************************
** Complete immutable fixture observation; no printer or document bytes exist.
***************************************************************************/
private record Accepted(EchoDestinations.Plan plan, String source, EchoDestinations.Destination destination, String actor, String job)
{
}
}
@@ -0,0 +1,588 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.examples.echo.fixture;
import java.net.HttpCookie;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Base64;
import java.util.UUID;
import com.kingsrook.examples.echo.EchoQBitProducer;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** Actual loopback HTTP uses only registered versioned native QQQ operations.
** The test generator simulates acceptance; no render or printer is contacted.
*******************************************************************************/
class EchoHttpFixtureTest
{
private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).followRedirects(HttpClient.Redirect.NEVER).build();
private String origin;
private String deviceId;
private String deviceToken;
/***************************************************************************
** Login, enroll/approve, setup once, two scans, retry, query and cancel all
** cross the same semantic HTTP surface used by the Android client.
***************************************************************************/
@Test
void nativeSemanticHttpRunsEchoWithSimulatedAcceptance() throws Exception
{
com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager.resetConnectionProviders();
String scannerPassword = UUID.randomUUID().toString();
String adminPassword = UUID.randomUUID().toString();
int port;
try(ServerSocket reservation = new ServerSocket(0, 0, InetAddress.getLoopbackAddress()))
{
port = reservation.getLocalPort();
}
origin = "http://127.0.0.1:" + port;
try(EchoHttpFixture fixture = new EchoHttpFixture(scannerPassword, adminPassword))
{
fixture.start(port);
JSONObject version = new JSONObject(request("GET", "/qqq/v1/version", null, null, false).body());
assertEquals("1.10.0", version.getString("apiVersion"));
boolean mobile15 = false;
for(Object extension : version.getJSONArray("extensions"))
{
JSONObject item = (JSONObject) extension;
if("mobile-scanners".equals(item.getString("name")))
{
mobile15 = "1.5.0".equals(item.getString("version"));
}
}
assertTrue(mobile15);
String scanner = enrollApprovedScanner(scannerPassword, adminPassword);
HttpResponse<String> metadata = request("GET", "/qqq/v1/metaData", scanner, null, true);
assertEquals(200, metadata.statusCode());
assertTrue(metadata.body().contains("\"echo\""));
JSONObject launched = complete(form("/qqq/v1/processes/echoPrint/init", scanner, new JSONObject(), true), EchoQBitProducer.PROCESS, scanner, true);
assertEquals("setup", launched.getString("nextStep"));
String process = launched.getString("processUUID");
// the published searchProcessPossibleValues contract: POST JSON {values, processValues, useCase}
HttpResponse<String> documents = request("POST", "/qqq/v1/processes/echoPrint/possibleValues/templateId", scanner, new JSONObject()
.put("values", new JSONObject().put("printerId", EchoHttpFixture.PRINTER).put("mediaId", EchoHttpFixture.MEDIA))
.put("processValues", new JSONObject()).put("useCase", "form").toString(), true);
assertEquals(200, documents.statusCode());
JSONArray options = new JSONObject(documents.body()).getJSONArray("options");
assertEquals(2, options.length());
assertEquals(EchoHttpFixture.DOCUMENT_A, options.getJSONObject(0).getInt("id"));
assertEquals(EchoHttpFixture.DOCUMENT_B, options.getJSONObject(1).getInt("id"));
assertTrue(options.getJSONObject(0).get("id") instanceof Number, "Document ids are JSON integers.");
HttpResponse<String> withoutMedia = request("POST", "/qqq/v1/processes/echoPrint/possibleValues/templateId", scanner, new JSONObject()
.put("values", new JSONObject().put("printerId", EchoHttpFixture.PRINTER)).put("processValues", new JSONObject()).put("useCase", "form").toString(), true);
assertEquals(200, withoutMedia.statusCode());
// the semantic API may omit optional empty options
JSONArray noOptions = new JSONObject(withoutMedia.body()).optJSONArray("options");
assertTrue(noOptions == null || noOptions.length() == 0, "No document is offered without a chosen size.");
JSONObject selected = complete(form("/qqq/v1/processes/echoPrint/" + process + "/step/setup", scanner,
new JSONObject().put("printerId", EchoHttpFixture.PRINTER).put("mediaId", EchoHttpFixture.MEDIA)
.put("templateId", EchoHttpFixture.DOCUMENT_B), true), EchoQBitProducer.PROCESS, scanner, true);
assertEquals("scan", selected.getString("nextStep"));
String run = selected.getJSONObject("values").getString("echoRunId");
JSONObject block = selected.getJSONObject("values").getJSONObject(EchoQBitProducer.WIDGET).getJSONArray("subBlocks").getJSONObject(0).getJSONObject("values");
assertEquals("Print queued", block.getString("acknowledgmentLabel"));
assertFalse(block.getBoolean("manualEntryEnabled"));
JSONObject firstEvent = event("LABEL-TYPE-CODE128");
JSONObject first = complete(form("/qqq/v1/processes/echoPrint/" + process + "/step/scan", scanner, firstEvent, true), EchoQBitProducer.PROCESS, scanner, true);
JSONObject firstValues = first.getJSONObject("values");
assertTrue(firstValues.getBoolean("scanSaved"));
assertEquals("QUEUED", firstValues.getString("printStatus"));
assertTrue(firstValues.getString("printJobId").startsWith("fixture-job-"));
JSONObject retry = complete(form("/qqq/v1/processes/echoPrint/" + process + "/step/scan", scanner, firstEvent, true), EchoQBitProducer.PROCESS, scanner, true);
assertEquals(firstValues.getString("printJobId"), retry.getJSONObject("values").getString("printJobId"));
JSONObject second = complete(form("/qqq/v1/processes/echoPrint/" + process + "/step/scan", scanner, event("QRCODE"), true), EchoQBitProducer.PROCESS, scanner, true);
assertEquals(run, second.getJSONObject("values").getString("echoRunId"));
assertEquals("scan", second.getString("nextStep"));
JSONArray scans = query(EchoQBitProducer.SCAN_TABLE, scanner);
assertEquals(2, scans.length());
for(Object item : scans)
{
JSONObject values = ((JSONObject) item).getJSONObject("values");
assertEquals("001234567890", values.getString("barcode"));
assertEquals("QUEUED", values.getString("state"));
assertTrue(values.getString("printJobId").startsWith("fixture-job-"));
}
HttpResponse<String> canceled = form("/qqq/v1/processes/echoPrint/" + process + "/cancel", scanner, new JSONObject(), true);
assertEquals(200, canceled.statusCode());
JSONArray runs = query(EchoHttpFixture.RUN_TABLE, scanner);
assertEquals(1, runs.length());
JSONObject runValues = runs.getJSONObject(0).getJSONObject("values");
assertEquals(run, runValues.getString("id"));
assertEquals("CLOSED", runValues.getString("state"));
assertEquals(EchoHttpFixture.PRINTER, runValues.getString("printerRecordId"));
assertEquals(EchoHttpFixture.MEDIA, runValues.getString("mediaId"));
assertEquals(EchoHttpFixture.DOCUMENT_B, runValues.getInt("templateId"), "The chosen document is persisted as the same integer id.");
}
}
/***************************************************************************
** Real login, device enrollment and administrator approval; returns the
** scanner's session and sets this test's approved device headers.
***************************************************************************/
private String enrollApprovedScanner(String scannerPassword, String adminPassword) throws Exception
{
String scanner = login("scanner", scannerPassword);
String admin = login("admin", adminPassword);
HttpResponse<String> enrolled = request("POST", "/qqq/v1/qbit/mobile-scanners/devices/enroll", scanner,
new JSONObject().put("installationId", UUID.randomUUID().toString()).toString(), false);
assertTrue(enrolled.statusCode() == 200 || enrolled.statusCode() == 201);
JSONObject device = new JSONObject(enrolled.body());
deviceId = device.getString("deviceId");
deviceToken = device.getString("deviceToken");
JSONObject approval = complete(form("/qqq/v1/processes/mobileDeviceApprove/init", admin,
new JSONObject().put("recordsParam", "recordIds").put("recordIds", deviceId).put("tableName", "mobileDevice"), false), "mobileDeviceApprove", admin, false);
complete(form("/qqq/v1/processes/mobileDeviceApprove/" + approval.getString("processUUID") + "/step/input", admin, new JSONObject(), false),
"mobileDeviceApprove", admin, false);
return scanner;
}
/***************************************************************************
** A reserved loopback port, released immediately for the fixture to bind.
***************************************************************************/
private int freePort() throws Exception
{
try(ServerSocket reservation = new ServerSocket(0, 0, InetAddress.getLoopbackAddress()))
{
origin = "http://127.0.0.1:" + reservation.getLocalPort();
return reservation.getLocalPort();
}
}
/***************************************************************************
** Start the Echo process and return its protected process id.
***************************************************************************/
private String open(String scanner) throws Exception
{
JSONObject launched = complete(form("/qqq/v1/processes/echoPrint/init", scanner, new JSONObject(), true), EchoQBitProducer.PROCESS, scanner, true);
assertEquals("setup", launched.getString("nextStep"));
return launched.getString("processUUID");
}
/***************************************************************************
** The setup form's values: the simulated printer, media and second document,
** plus whatever copies entry a test supplies (or none).
***************************************************************************/
private static JSONObject setupValues(Object... copies)
{
JSONObject values = new JSONObject().put("printerId", EchoHttpFixture.PRINTER).put("mediaId", EchoHttpFixture.MEDIA).put("templateId", EchoHttpFixture.DOCUMENT_B);
if(copies.length == 1)
{
values.put("copies", copies[0]);
}
return values;
}
/***************************************************************************
** One native step request; the finished response, whether it succeeded or not.
***************************************************************************/
private JSONObject attempt(String process, String step, String scanner, JSONObject values) throws Exception
{
return finish(form("/qqq/v1/processes/echoPrint/" + process + "/step/" + step, scanner, values, true), EchoQBitProducer.PROCESS, scanner, true);
}
/***************************************************************************
** True when the server reported the step as failed rather than completed.
***************************************************************************/
private static boolean failed(JSONObject response)
{
return response.has("error") || response.has("errorMessage") || "ERROR".equals(response.optString("type"));
}
/***************************************************************************
** The pinned copies, instructions and accepted printing of one finished step.
***************************************************************************/
private static String instructions(JSONObject finished)
{
return finished.getJSONObject("values").getJSONObject(EchoQBitProducer.WIDGET).getJSONArray("subBlocks").getJSONObject(0).getJSONObject("values").getString("instructions");
}
/***************************************************************************
** The persisted run rows, by id, as ordinary authorized native queries.
***************************************************************************/
private JSONObject runRow(String scanner, String run) throws Exception
{
for(Object item : query(EchoHttpFixture.RUN_TABLE, scanner))
{
JSONObject values = ((JSONObject) item).getJSONObject("values");
if(run.equals(values.getString("id")))
{
return values;
}
}
throw new AssertionError("The run row is not visible to its scanner.");
}
/***************************************************************************
** The setup form advertises one optional integer with explicit 1..100 limits
** and the default of one, from the same registered process metadata the
** native client reads.
***************************************************************************/
@Test
void setupMetadataAdvertisesOptionalCopiesWithExplicitLimits() throws Exception
{
com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager.resetConnectionProviders();
String scannerPassword = UUID.randomUUID().toString();
String adminPassword = UUID.randomUUID().toString();
int port = freePort();
try(EchoHttpFixture fixture = new EchoHttpFixture(scannerPassword, adminPassword))
{
fixture.start(port);
String scanner = enrollApprovedScanner(scannerPassword, adminPassword);
HttpResponse<String> response = request("GET", "/qqq/v1/metaData/process/echoPrint", scanner, null, true);
assertEquals(200, response.statusCode());
JSONObject field = findField(new JSONObject(response.body()), "copies");
assertEquals("Copies per scan", field.getString("label"));
assertEquals("INTEGER", field.getString("type").toUpperCase(java.util.Locale.ROOT));
assertEquals("1", String.valueOf(field.get("defaultValue")));
assertFalse(field.optBoolean("isRequired", false));
JSONObject validation = field.getJSONObject("validation");
assertEquals("1", String.valueOf(validation.get("minValue")));
assertEquals("100", String.valueOf(validation.get("maxValue")));
assertTrue(validation.optBoolean("minInclusive", true));
assertTrue(validation.optBoolean("maxInclusive", true));
}
}
/***************************************************************************
** Depth-first search for the named form field in the process metadata.
***************************************************************************/
private static JSONObject findField(Object node, String name)
{
if(node instanceof JSONObject object)
{
if(name.equals(object.optString("name")) && object.has("label"))
{
return object;
}
for(String key : object.keySet())
{
JSONObject found = findField(object.get(key), name);
if(found != null)
{
return found;
}
}
}
else if(node instanceof JSONArray array)
{
for(Object item : array)
{
JSONObject found = findField(item, name);
if(found != null)
{
return found;
}
}
}
return null;
}
/***************************************************************************
** Omitted, null and blank setup entries all mean one copy, shown and pinned.
***************************************************************************/
@Test
void copiesDefaultToOneWhenOmittedClearedOrBlank() throws Exception
{
com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager.resetConnectionProviders();
String scannerPassword = UUID.randomUUID().toString();
String adminPassword = UUID.randomUUID().toString();
int port = freePort();
try(EchoHttpFixture fixture = new EchoHttpFixture(scannerPassword, adminPassword))
{
fixture.start(port);
String scanner = enrollApprovedScanner(scannerPassword, adminPassword);
int expectedJobs = 0;
for(Object entry : new Object[] { "omitted", JSONObject.NULL, "", " " })
{
String process = open(scanner);
JSONObject selected = attempt(process, "setup", scanner, "omitted".equals(entry) ? setupValues() : setupValues(entry));
assertFalse(failed(selected));
assertEquals(1, selected.getJSONObject("values").getInt("copies"));
assertFalse(instructions(selected).contains("copies"));
String run = selected.getJSONObject("values").getString("echoRunId");
JSONObject scanned = attempt(process, "scan", scanner, event("LABEL-TYPE-CODE128"));
assertFalse(failed(scanned));
assertEquals(1, scanned.getJSONObject("values").getInt("copies"));
assertEquals(++expectedJobs, fixture.acceptedJobCount());
assertEquals(1, runRow(scanner, run).getInt("copies"));
assertEquals(200, form("/qqq/v1/processes/echoPrint/" + process + "/cancel", scanner, new JSONObject(), true).statusCode());
}
assertEquals(java.util.List.of(1, 1, 1, 1), fixture.acceptedCopies());
}
}
/***************************************************************************
** Three copies are chosen once, shown, pinned in the run, and used by every
** scan as ONE job each; retries add no job and every mutation is refused.
***************************************************************************/
@Test
void chosenCopiesStayPinnedAcrossScansRetriesAndMutations() throws Exception
{
com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager.resetConnectionProviders();
String scannerPassword = UUID.randomUUID().toString();
String adminPassword = UUID.randomUUID().toString();
int port = freePort();
try(EchoHttpFixture fixture = new EchoHttpFixture(scannerPassword, adminPassword))
{
fixture.start(port);
String scanner = enrollApprovedScanner(scannerPassword, adminPassword);
String process = open(scanner);
JSONObject selected = attempt(process, "setup", scanner, setupValues(3));
assertFalse(failed(selected));
assertEquals("scan", selected.getString("nextStep"));
assertEquals(3, selected.getJSONObject("values").getInt("copies"));
assertTrue(instructions(selected).contains("Each scan prints 3 copies."));
String run = selected.getJSONObject("values").getString("echoRunId");
assertEquals(3, runRow(scanner, run).getInt("copies"));
JSONObject firstEvent = event("LABEL-TYPE-CODE128");
JSONObject first = attempt(process, "scan", scanner, firstEvent);
assertFalse(failed(first));
assertEquals(3, first.getJSONObject("values").getInt("copies"));
String job = first.getJSONObject("values").getString("printJobId");
assertEquals(1, fixture.acceptedJobCount());
JSONObject retry = attempt(process, "scan", scanner, firstEvent);
assertFalse(failed(retry));
assertEquals(job, retry.getJSONObject("values").getString("printJobId"));
assertEquals(1, fixture.acceptedJobCount());
JSONObject second = attempt(process, "scan", scanner, event("QRCODE"));
assertFalse(failed(second));
assertEquals(3, second.getJSONObject("values").getInt("copies"));
assertEquals(2, fixture.acceptedJobCount());
assertEquals(java.util.List.of(3, 3), fixture.acceptedCopies());
// a scan-stage value that differs from the pinned count is refused: no event, no job
for(Object forged : new Object[] { 1, 2, 4, 9, 100, 0, 101, "abc", 2.5 })
{
JSONObject mutation = event("LABEL-TYPE-CODE128").put("copies", forged);
assertTrue(failed(attempt(process, "scan", scanner, mutation)), "forged copies " + forged);
}
// a repeated setup cannot change the pinned count either
for(Object other : new Object[] { 1, 5 })
{
assertTrue(failed(attempt(process, "setup", scanner, setupValues(other))), "setup replay " + other);
}
assertEquals(2, fixture.acceptedJobCount());
assertEquals(2, query(EchoQBitProducer.SCAN_TABLE, scanner).length());
assertEquals(3, runRow(scanner, run).getInt("copies"));
// the refused values do not poison the run: the next ordinary scan still prints three
JSONObject third = attempt(process, "scan", scanner, event("QRCODE"));
assertFalse(failed(third), third.toString());
assertEquals(3, third.getJSONObject("values").getInt("copies"));
assertEquals(3, fixture.acceptedJobCount());
assertEquals(java.util.List.of(3, 3, 3), fixture.acceptedCopies());
assertEquals(3, query(EchoQBitProducer.SCAN_TABLE, scanner).length());
assertEquals(200, form("/qqq/v1/processes/echoPrint/" + process + "/cancel", scanner, new JSONObject(), true).statusCode());
assertEquals(3, runRow(scanner, run).getInt("copies"));
assertEquals("CLOSED", runRow(scanner, run).getString("state"));
}
}
/***************************************************************************
** Fractions, zero, out-of-range and text are rejected at setup; the run stays
** in setup, so a valid count then completes it.
***************************************************************************/
@Test
void invalidCopiesAreRejectedAtSetupBeforeAnyRunIsPinned() throws Exception
{
com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager.resetConnectionProviders();
String scannerPassword = UUID.randomUUID().toString();
String adminPassword = UUID.randomUUID().toString();
int port = freePort();
try(EchoHttpFixture fixture = new EchoHttpFixture(scannerPassword, adminPassword))
{
fixture.start(port);
String scanner = enrollApprovedScanner(scannerPassword, adminPassword);
String process = open(scanner);
for(Object invalid : new Object[] { 0, -1, 101, 1000, 2.5, "2.5", "abc", "1e1", true })
{
assertTrue(failed(attempt(process, "setup", scanner, setupValues(invalid))), "setup copies " + invalid);
}
JSONObject selected = attempt(process, "setup", scanner, setupValues(100));
assertFalse(failed(selected), selected.toString());
assertEquals(100, selected.getJSONObject("values").getInt("copies"));
assertEquals(100, runRow(scanner, selected.getJSONObject("values").getString("echoRunId")).getInt("copies"));
assertEquals(0, fixture.acceptedJobCount());
}
}
/***************************************************************************
** QQQ validates Basic credentials; secrets never appear in assertion output.
***************************************************************************/
private String login(String username, String password) throws Exception
{
String basic = Base64.getEncoder().encodeToString((username + ":" + password).getBytes(StandardCharsets.UTF_8));
HttpResponse<String> response = client.send(headers("/qqq/v1/manageSession", null, false).header("Authorization", "Basic " + basic)
.header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString("{}")).build(), HttpResponse.BodyHandlers.ofString());
assertEquals(200, response.statusCode(), "Native fixture login failed.");
return response.headers().allValues("Set-Cookie").stream().flatMap(value -> HttpCookie.parse(value).stream())
.filter(cookie -> "sessionUUID".equals(cookie.getName())).findFirst().orElseThrow().getValue();
}
/***************************************************************************
** Test observations use ordinary authorized QQQ table queries, never SQL.
***************************************************************************/
private JSONArray query(String table, String session) throws Exception
{
HttpResponse<String> response = request("POST", "/qqq/v1/table/" + table + "/query", session,
new JSONObject().put("filter", new JSONObject().put("limit", 20)).toString(), true);
assertEquals(200, response.statusCode(), "Native fixture inspection failed.");
return new JSONObject(response.body()).getJSONArray("records");
}
/***************************************************************************
** Async process state retains approved device headers across status polling.
***************************************************************************/
private JSONObject complete(HttpResponse<String> response, String process, String session, boolean device) throws Exception
{
assertEquals(200, response.statusCode(), "Native fixture process request failed.");
JSONObject body = finish(response, process, session, device);
assertFalse(body.has("error"), "Native fixture process returned an error.");
return body;
}
/***************************************************************************
** Poll an async step to its end and return what the server said, including
** a refusal, which is reported as an object with an error key.
***************************************************************************/
private JSONObject finish(HttpResponse<String> response, String process, String session, boolean device) throws Exception
{
if(response.statusCode() != 200)
{
return new JSONObject().put("error", "HTTP " + response.statusCode());
}
JSONObject body = new JSONObject(response.body());
String processId = body.optString("processUUID");
String jobId = body.optString("jobUUID");
long deadline = System.nanoTime() + Duration.ofSeconds(15).toNanos();
while("JOB_STARTED".equals(body.optString("type")) || "RUNNING".equals(body.optString("type")))
{
assertTrue(System.nanoTime() < deadline, "Native fixture process timed out.");
Thread.sleep(25);
response = request("GET", "/qqq/v1/processes/" + process + "/" + processId + "/status/" + jobId, session, null, device);
if(response.statusCode() != 200)
{
return new JSONObject().put("error", "HTTP " + response.statusCode());
}
body = new JSONObject(response.body());
}
return body;
}
/***************************************************************************
** The same multipart JSON-values body as the native QQQ process client.
***************************************************************************/
private HttpResponse<String> form(String path, String session, JSONObject values, boolean device) throws Exception
{
String boundary = "echo-fixture-" + UUID.randomUUID();
String body = "--" + boundary + "\r\nContent-Disposition: form-data; name=\"values\"\r\n\r\n" + values
+ "\r\n--" + boundary + "\r\nContent-Disposition: form-data; name=\"stepTimeoutMillis\"\r\n\r\n1\r\n";
for(String key : new String[] { "recordsParam", "recordIds", "filterJSON" })
{
if(values.has(key))
{
body += "--" + boundary + "\r\nContent-Disposition: form-data; name=\"" + key + "\"\r\n\r\n" + values.getString(key) + "\r\n";
}
}
body += "--" + boundary + "--\r\n";
return client.send(headers(path, session, device).header("Content-Type", "multipart/form-data; boundary=" + boundary)
.POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString());
}
/***************************************************************************
** All observed requests target this test's selected loopback origin.
***************************************************************************/
private HttpResponse<String> request(String method, String path, String session, String body, boolean device) throws Exception
{
return client.send(headers(path, session, device).header("Content-Type", "application/json")
.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString());
}
/***************************************************************************
** Credentials and device tokens remain request headers, never process values.
***************************************************************************/
private HttpRequest.Builder headers(String path, String session, boolean device)
{
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(origin + path)).timeout(Duration.ofSeconds(20)).header("X-QQQ-Client", "android/qqq-mobile");
if(session != null)
{
request.header("Cookie", "sessionUUID=" + session);
}
if(device)
{
request.header("X-QQQ-Device-Id", deviceId).header("X-QQQ-Device-Token", deviceToken);
}
return request;
}
/***************************************************************************
** Public synthetic value checks preservation of leading zeroes and type.
***************************************************************************/
private static JSONObject event(String type)
{
return new JSONObject().put("barcode", "001234567890").put("symbology", type).put("scanSource", "zebra-emdk")
.put("eventId", UUID.randomUUID().toString()).put("capturedAt", "2026-11-01T18:00:00Z");
}
}
@@ -0,0 +1,118 @@
/*
* Copyright (c) 2026 Kingsrook, LLC
* SPDX-License-Identifier: Apache-2.0
*/
package com.kingsrook.qbits.mobilescanners;
import java.nio.charset.StandardCharsets;
import java.sql.DriverManager;
import java.util.Map;
import java.util.UUID;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.AuthScope;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
/*******************************************************************************
** Test-only fixture in the mobile package exercises its real approval/session
** binding. No production class exposes enrollment secrets or private sessions.
*******************************************************************************/
public final class EchoMobileFixture
{
private final QInstance instance;
private final QSession scanner;
private final String device;
/***************************************************************************
** Actual H2 mobile tables and ordinary native user permissions are used.
***************************************************************************/
public EchoMobileFixture(QInstance instance, String jdbc) throws Exception
{
this.instance = instance;
instance.setEnforcePermissionsInCoreActions(true);
instance.registerAuthenticationProvider(AuthScope.instanceDefault(), new QAuthenticationMetaData().withName("fixture").withType(QAuthenticationType.MOCK));
instance.addBackend(new RDBMSBackendMetaData().withName("db").withVendor("h2").withJdbcUrl(jdbc).withUsername("sa"));
new MobileScannersQBitProducer().withConfig(new MobileScannersQBitConfig().withBackendName("db")).produce(instance, "mobile");
for(var table : instance.getTables().values())
{
table.setBackendDetails(new RDBMSTableBackendDetails().withTableName(table.getName()));
}
try(var connection = DriverManager.getConnection(jdbc, "sa", "");
var resource = getClass().getResourceAsStream("/db/mobile-scanners-h2.sql"))
{
String sql = new String(resource.readAllBytes(), StandardCharsets.UTF_8);
for(String statement : sql.split(";"))
{
if(!statement.isBlank())
{
try(var command = connection.createStatement())
{
command.execute(statement);
}
}
}
}
scanner = session("operator");
QContext.init(instance, scanner);
Map<String, Object> enrolled = MobileService.enroll(Map.of("installationId", UUID.randomUUID().toString()));
device = (String) enrolled.get("deviceId");
QContext.init(instance, session("administrator").withPermission(MobilePermissions.ADMIN));
MobileService.setApproval(device, true);
bind();
}
/***************************************************************************
** Mirror the route's verified request attribute before process permission.
***************************************************************************/
public void bind()
{
QContext.init(instance, scanner);
MobilePermissions.REQUEST_DEVICE.set(device);
}
/***************************************************************************
** Preserve the same device with a different authenticated operator.
***************************************************************************/
public void otherOperator()
{
QContext.init(instance, session("other-operator"));
MobilePermissions.REQUEST_DEVICE.set(device);
}
/***************************************************************************
** Test cleanup removes both public and private request context.
***************************************************************************/
public static void clear()
{
MobilePermissions.REQUEST_DEVICE.remove();
QContext.clear();
}
/***************************************************************************
** No printer administration grant is present in the scanner fixture.
***************************************************************************/
private static QSession session(String user)
{
QSession session = new QSession().withUser(new QUser().withIdReference(user)).withPermissions(MobilePermissions.SCAN, "echo.use");
session.setUuid(UUID.randomUUID().toString());
return session;
}
}
+8
View File
@@ -98,6 +98,14 @@ after the DDL connection closes (`ConditionInConstantSet` references its closed
session). A complete runtime manifest is necessary; matching only a core version
label is insufficient evidence of compatibility.
Host authentication order: after validating the login (in both `createSession` and
session revalidation such as `isSessionValid`) and assigning the host's own roles and
keys, call `CollaborationSecurity.applyToSession(QInstance, QSession)`
(`com.kingsrook.qbits.collaboration`). This must happen before the first Mobile
capture; the scanner compares security keys as sets, so repeating the call is safe, but
adding or changing keys after capture is treated as an authority change and is rejected.
There is no late wrapper mutation or exemption.
The HTTP fixture uses real QQQ table/password sessions, native enrollment and
record-screen approval processes, native Record Files capabilities/create/chunks/
finalize, disk-backed H2 metadata and the standard attachment owner with a
+22 -2
View File
@@ -142,7 +142,27 @@ try
}
$compiled | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $target 'compiled-sha256.json') -Encoding utf8
$scannerArtifact = $manifest | Where-Object { [IO.Path]::GetFileName($_.path) -eq 'qbit-mobile-scanners-0.1.0-SNAPSHOT.jar' }
@{bundleName=(Split-Path -Leaf $bundleRoot); repositoryName=$RepositoryName; duplicateQqqClasses=0; classCount=$classes.Count; h2Version='2.2.224'; baseBuildMode=$(if($SkipBaseBuild) { 'module-only-prior-scanner-binary' } else { 'fresh-source-clean-base-build' }); scannerJarSha256=$scannerArtifact.sha256; derivedCollaborationPomSha256=(Get-FileHash -LiteralPath $collaborationPom -Algorithm SHA256).Hash.ToLowerInvariant() } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $target 'runtime-validation.json') -Encoding utf8
@{verifiedAtUtc=[DateTime]::UtcNow.ToString('O'); runtimeManifestSha256=(Get-FileHash -LiteralPath (Join-Path $target 'runtime-sha256.json')).Hash; compiledManifestSha256=(Get-FileHash -LiteralPath (Join-Path $target 'compiled-sha256.json')).Hash } | ConvertTo-Json | Set-Content -LiteralPath $successMarker -Encoding utf8
$baseBuildMode = 'fresh-source-clean-base-build'
if($SkipBaseBuild) { $baseBuildMode = 'module-only-prior-scanner-binary' }
$collaborationPomSha256 = (Get-FileHash -LiteralPath $collaborationPom -Algorithm SHA256).Hash.ToLowerInvariant()
$validation = @{
bundleName = (Split-Path -Leaf $bundleRoot)
repositoryName = $RepositoryName
duplicateQqqClasses = 0
classCount = $classes.Count
h2Version = '2.2.224'
baseBuildMode = $baseBuildMode
scannerJarSha256 = $scannerArtifact.sha256
derivedCollaborationPomSha256 = $collaborationPomSha256
}
$validation | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $target 'runtime-validation.json') -Encoding utf8
$runtimeManifestSha256 = (Get-FileHash -LiteralPath (Join-Path $target 'runtime-sha256.json')).Hash
$compiledManifestSha256 = (Get-FileHash -LiteralPath (Join-Path $target 'compiled-sha256.json')).Hash
$marker = @{
verifiedAtUtc = [DateTime]::UtcNow.ToString('O')
runtimeManifestSha256 = $runtimeManifestSha256
compiledManifestSha256 = $compiledManifestSha256
}
$marker | ConvertTo-Json | Set-Content -LiteralPath $successMarker -Encoding utf8
}
finally { Pop-Location }
+16 -7
View File
@@ -9,20 +9,29 @@
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<qqq.frozen.version>4.1.0-isolated-record-files-SNAPSHOT</qqq.frozen.version>
<!-- the same property names the root POM and the Echo demo use, with their defaults -->
<qqq.version>4.1.0-RC.21</qqq.version>
<mobile.version>0.1.0-SNAPSHOT</mobile.version>
<recordfiles.version>0.1.0-SNAPSHOT</recordfiles.version>
<collaboration.version>0.1.0-record-files-SNAPSHOT</collaboration.version>
<!-- developer-script override for core and rdbms only; by default it is the single qqq line, never a second one -->
<qqq.frozen.version>${qqq.version}</qqq.frozen.version>
</properties>
<dependencyManagement><dependencies>
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-bom-pom</artifactId><version>${qqq.version}</version><type>pom</type><scope>import</scope></dependency>
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-core</artifactId><version>${qqq.frozen.version}</version></dependency>
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-module-rdbms</artifactId><version>${qqq.frozen.version}</version></dependency>
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-middleware-javalin</artifactId><version>4.1.0-RC.20</version></dependency>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-collaboration</artifactId><version>0.1.0-record-files-SNAPSHOT</version></dependency>
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-middleware-javalin</artifactId><version>${qqq.version}</version></dependency>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-collaboration</artifactId><version>${collaboration.version}</version></dependency>
</dependencies></dependencyManagement>
<dependencies>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-mobile-scanners</artifactId><version>0.1.0-SNAPSHOT</version></dependency>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-record-files</artifactId><version>0.1.0-SNAPSHOT</version></dependency>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-record-files</artifactId><version>0.1.0-SNAPSHOT</version><classifier>tests</classifier><type>test-jar</type><scope>test</scope></dependency>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-mobile-scanners</artifactId><version>${mobile.version}</version></dependency>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-record-files</artifactId><version>${recordfiles.version}</version></dependency>
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-record-files</artifactId><version>${recordfiles.version}</version><classifier>tests</classifier><type>test-jar</type><scope>test</scope></dependency>
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><version>2.2.224</version><scope>test</scope></dependency>
<dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter</artifactId><version>5.11.4</version><scope>test</scope></dependency>
<!-- no own version: the imported qqq BOM manages the Jupiter engine and params (6.0.2); the engine brings the matching API -->
<dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter-engine</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter-params</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.assertj</groupId><artifactId>assertj-core</artifactId><version>3.27.7</version><scope>test</scope></dependency>
</dependencies>
<build><directory>${project.basedir}/target/module</directory><plugins>
@@ -24,6 +24,7 @@ import java.util.Set;
import java.util.UUID;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kingsrook.qbits.collaboration.CollaborationSecurity;
import com.kingsrook.qbits.mobilescanners.recordfiles.MobileCaptureIdentityProvider;
import com.kingsrook.qbits.mobilescanners.recordfiles.MobileRecordFilesContract;
import com.kingsrook.qbits.mobilescanners.recordfiles.RecordFilesCaptureWidgetData;
@@ -678,7 +679,7 @@ public class MobileRecordFilesIntegrationTest extends RecordFilesFixture
@Override
public QSession createSession(QInstance instance, Map<String, String> context) throws QAuthenticationException
{
return grant(super.createSession(instance, context));
return grant(instance, super.createSession(instance, context));
}
@@ -692,7 +693,7 @@ public class MobileRecordFilesIntegrationTest extends RecordFilesFixture
boolean valid = super.isSessionValid(instance, session);
if(valid)
{
grant(session);
grant(instance, session);
}
return valid;
}
@@ -702,7 +703,7 @@ public class MobileRecordFilesIntegrationTest extends RecordFilesFixture
/***********************************************************************
** Request fields cannot supply role or tenant grants.
***********************************************************************/
private QSession grant(QSession session)
private QSession grant(QInstance instance, QSession session)
{
if(session.getUser() == null)
{
@@ -717,6 +718,7 @@ public class MobileRecordFilesIntegrationTest extends RecordFilesFixture
{
session.getPermissions().removeAll(deniedPermissions);
}
CollaborationSecurity.applyToSession(instance, session);
return session;
}
}
+1 -1
View File
@@ -33,7 +33,7 @@
<properties>
<revision>0.1.0-SNAPSHOT</revision>
<maven.compiler.release>21</maven.compiler.release>
<qqq.version>4.1.0-RC.20</qqq.version>
<qqq.version>4.1.0-RC.21</qqq.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -6,6 +6,7 @@
package com.kingsrook.qbits.mobilescanners;
import java.io.Serializable;
import java.util.Optional;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException;
@@ -53,6 +54,8 @@ public final class MobileDeviceAuthorization
{
throw new QAuthenticationException("Sign in to continue.");
}
Serializable expected = authentication instanceof VerifiedDeviceSession checked ? checked.checkedAuthority() : VerifiedDeviceSession.authorityOf(authentication);
requireCurrent(authentication, expected);
QRecord binding = MobileService.get(MobileScannersQBitProducer.SESSION_BINDING, MobileService.hash(authentication.getUuid()));
String device = MobilePermissions.REQUEST_DEVICE.get();
if(authentication instanceof VerifiedDeviceSession verified)
@@ -81,13 +84,17 @@ public final class MobileDeviceAuthorization
{
throw new QPermissionDeniedException("Device session binding could not be verified.");
}
try
requireCurrent(authentication, expected);
if(!(authentication instanceof VerifiedDeviceSession))
{
QContext.setQSession(new VerifiedDeviceSession(authentication, device));
}
catch(CloneNotSupportedException failure)
{
throw new QException("Device context could not be retained.", failure);
try
{
QContext.setQSession(new VerifiedDeviceSession(authentication, device));
}
catch(CloneNotSupportedException failure)
{
throw new QException("Device context could not be retained.", failure);
}
}
return Optional.of(new Identity(MobileService.userId(), device, session.getValueString("id")));
});
@@ -95,6 +102,20 @@ public final class MobileDeviceAuthorization
/***************************************************************************
** Approval, observation and host hooks may refresh or replace the session;
** only the same session with identical authority may be retained or used.
***************************************************************************/
private static void requireCurrent(QSession authentication, Serializable expected) throws QPermissionDeniedException
{
if(QContext.getQSession() != authentication || !expected.equals(VerifiedDeviceSession.authorityOf(authentication)))
{
throw new QPermissionDeniedException("Device authorization changed; sign in again.");
}
}
/***************************************************************************
** A persisted/public DTO must still match this authenticated private binding.
***************************************************************************/
@@ -58,6 +58,7 @@ public final class MobileScannerActions
}
Map<String, Serializable> submitted = new HashMap<>(input.getValues());
input.getValues().keySet().removeAll(List.of("barcode", "symbology", "scanSource", "eventId", "capturedAt", "receivedAt",
"aimIdentifier", "rawDataBase64",
"scanContext", "scanSaved", "scanId", "acknowledgedEventId", "userId", "deviceId", "appId", "processName"));
if(MobileDeviceAuthorization.captureCurrent().isEmpty())
{
@@ -69,6 +70,15 @@ public final class MobileScannerActions
{
context.put(field, saved.getValueString(field));
}
for(String field : List.of("aimIdentifier", "rawDataBase64"))
{
// Optional metadata: omit the key entirely for older clients and manual capture.
String value = saved.getValueString(field);
if(value != null)
{
context.put(field, value);
}
}
context.put("capturedAt", saved.getValueInstant("capturedAt").toString());
context.put("receivedAt", saved.getValueInstant("receivedAt").toString());
context.put("scanId", saved.getValueString("id"));
@@ -100,6 +100,7 @@ public class MobileScannersQBitProducer implements QBitProducer
scan.withField(text(field, 200));
}
scan.withField(text("barcode", 4096)).withField(time("capturedAt")).withField(time("receivedAt"));
scan.withField(text("aimIdentifier", MobileService.AIM_IDENTIFIER_LENGTH)).withField(text("rawDataBase64", MobileService.RAW_DATA_MAXIMUM_CHARACTERS));
instance.addTable(scan);
MobileSessionMetadata.register(instance, config.getBackendName());
instance.addWidget(new QWidgetMetaData().withName(WIDGET).withLabel("Scan a barcode").withType("composite")
@@ -13,6 +13,7 @@ import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.ClientChannelsMetaData;
import com.kingsrook.qqq.middleware.javalin.QJavalinRouteProviderInterface;
import com.kingsrook.qqq.middleware.javalin.QJavalinUtils;
import com.kingsrook.qqq.middleware.javalin.executors.ExecutorSessionUtils;
@@ -76,6 +77,30 @@ public class MobileScannersRouteProvider implements QJavalinRouteProviderInterfa
/***************************************************************************
** The shared device headers also belong to other extensions' own device
** registries. Only a request for another extension's route (not scanner
** routes, and not generic metadata/process/widget calls) from a distinct
** registered client channel is left to that extension's authenticator. The
** channel is attribution that QQQ already validated against its registry;
** it grants nothing here: no scanner device is bound, so scanner capture
** still requires its own verified credential, and Android stays verified.
***************************************************************************/
private boolean isForeignExtensionClient(Context context)
{
String path = context.path();
if(!path.startsWith("/qqq/v1/qbit/") || path.startsWith("/qqq/v1" + MobileScannersUiApi.PREFIX + "/"))
{
return false;
}
var session = QContext.getQSession();
String channel = session == null ? null : session.getClientChannelName();
return channel != null && session.getCredentialScope() == null && !"android".equals(channel) && !"web".equals(channel)
&& ClientChannelsMetaData.of(instance).isAllowedChannel(channel);
}
/***************************************************************************
** Verify credentials before native handlers; authentication remains QQQ's.
***************************************************************************/
@@ -123,6 +148,10 @@ public class MobileScannersRouteProvider implements QJavalinRouteProviderInterfa
{
return;
}
if(isForeignExtensionClient(context))
{
return;
}
QRecord device = MobileService.authenticateDevice(id, token);
MobilePermissions.REQUEST_DEVICE.set(device.getValueString("id"));
if(!("/qqq/v1" + MobileScannersUiApi.PREFIX + "/sessions/heartbeat").equals(context.path().replaceFirst("/+$", "")))
@@ -45,8 +45,10 @@ public final class MobileScannersSchemas
"deviceId", uuid(), "deviceToken", text(128), "status", status(), "shortName", text(32), "displayName", text(200), "description", text(2000)), "deviceId", "deviceToken", "status"));
schemas.put("MobileScannersDeviceV1", object("All properties present. No credential material is returned.", Map.of(
"deviceId", uuid(), "displayName", text(200), "status", status(), "shortName", text(32), "description", text(2000)), "deviceId", "displayName", "status"));
schemas.put("MobileScannersScannerValuesV1", object("SCANNER presentation values. Optional manualLabel defaults to Manual barcode; submitLabel defaults to Submit barcode; maxLength defaults to 4096. Unknown additive properties may be ignored.", Map.of(
schemas.put("MobileScannersScannerValuesV1", object("SCANNER presentation values. Optional manualLabel defaults to Manual barcode; submitLabel defaults to Submit barcode; maxLength defaults to 4096. Extension 1.4 adds acknowledgmentLabel (default Saved scan) and manualEntryEnabled (default true). Presentation never changes acknowledgment requirements. Unknown additive properties may be ignored.", Map.of(
"title", text(200), "instructions", text(2000), "manualLabel", text(200), "submitLabel", text(200),
"acknowledgmentLabel", text(80).withDescription("Trimmed nonempty single-line plain text, without controls, formatting characters or markup. For example Print queued; this does not mean physical output completed."),
"manualEntryEnabled", new Schema().withType(Type.BOOLEAN).withDescription("Whether to show manual entry controls. False does not disable hardware scanning or relax backend validation."),
"maxLength", new ScannerMaximumSchema().withType(Type.INTEGER).withDescription("Inclusive supported range 1..4096; reject invalid server values.")), "title", "instructions"));
schemas.put("MobileScannersScannerBlockV1", object("SCANNER child block of a core COMPOSITE widget, returned by core process/widget operations. Requires mobile-scanners extension major 1. All properties present; do not infer this contract from process or widget names.", Map.of(
"blockId", text(200), "blockType", text(7).withEnumValues(List.of("SCANNER")),
@@ -54,12 +56,16 @@ public final class MobileScannersSchemas
schemas.put("MobileScannersScanEventV1", object("Values submitted to the current core process frontend step. barcode, eventId and capturedAt required. eventId remains unchanged on retry; identity comes only from verified user/device context, never these values. Barcode is a nonempty string, preserving leading zeroes.", Map.of(
"barcode", text(4096), "eventId", uuid(), "capturedAt", text(40).withFormat("date-time"),
"symbology", text(200).withDescription("Exact decoder identifier, never inferred from barcode text. If absent or blank, the server uses MANUAL for scanSource manual, otherwise UNKNOWN."),
"scanSource", text(200).withDescription("Capture method, for example datawedge, camera or manual. Absent or blank becomes UNKNOWN.")), "barcode", "eventId", "capturedAt"));
"scanSource", text(200).withDescription("Capture method, for example datawedge, camera or manual. Absent or blank becomes UNKNOWN."),
"aimIdentifier", aimIdentifier(), "rawDataBase64", rawDataBase64()), "barcode", "eventId", "capturedAt"));
Map<String, Schema> scanContext = new TreeMap<>();
scanContext.putAll(Map.of("scanId", text(64), "barcode", text(4096), "symbology", text(200), "scanSource", text(200), "eventId", uuid(),
"capturedAt", text(40).withFormat("date-time"), "receivedAt", text(40).withFormat("date-time")));
scanContext.putAll(Map.of("userId", text(200), "deviceId", uuid(), "appId", text(200), "processName", text(200)));
schemas.put("MobileScannersScanContextV1", object("Persisted accepted scan for subsequent backend actions. Decoder identifiers are preserved; absent legacy type is UNKNOWN, or MANUAL for explicit manual source. Identity is server-derived. appId identifies the server-configured application; processName is the registered executing process. Raw event inputs are consumed so the next event cannot inherit them.", scanContext, scanContext.keySet().toArray(String[]::new)));
List<String> requiredContext = List.copyOf(scanContext.keySet());
scanContext.put("aimIdentifier", aimIdentifier());
scanContext.put("rawDataBase64", rawDataBase64());
schemas.put("MobileScannersScanContextV1", object("Persisted accepted scan for subsequent backend actions. Decoder identifiers are preserved; absent legacy type is UNKNOWN, or MANUAL for explicit manual source. Identity is server-derived. appId identifies the server-configured application; processName is the registered executing process. Extension 1.5 adds optional aimIdentifier and rawDataBase64, present only when the accepted scan has them and otherwise omitted. Raw event inputs are consumed so the next event cannot inherit them.", scanContext, requiredContext.toArray(String[]::new)));
schemas.put("MobileScannersScanAcknowledgmentV1", object("Properties in core process response values after persistence. scanSaved=true, acknowledgedEventId matching the submitted eventId, and a nonempty scanId acknowledge success after async polling completes. Extension 1.3 always supplies scanContext for subsequent backend steps. It remains optional in this schema for older 1.x servers. A retry returns its original scanId and context; changed type/source/process is rejected.", Map.of(
"acknowledgedEventId", uuid(), "scanId", text(64), "scanSaved", new Schema().withType(Type.BOOLEAN),
"scanContext", ref("MobileScannersScanContextV1")), "acknowledgedEventId", "scanId", "scanSaved"));
@@ -89,6 +95,26 @@ public final class MobileScannersSchemas
/***************************************************************************
** Extension 1.5 original AIM identifier; optional and never inferred.
***************************************************************************/
private static Schema aimIdentifier()
{
return text(3).withDescription("Extension 1.5, optional. The original AIM identifier reported by the scanner SDK, exactly three characters matching ^\\][A-Za-z][0-9A-Za-z]$. Never guessed from symbology. Omit when unavailable. Present blank, malformed or wrong-typed values are rejected. Participates in exact event retry binding.");
}
/***************************************************************************
** Extension 1.5 exact SDK raw bytes; no decoding or trust is implied.
***************************************************************************/
private static Schema rawDataBase64()
{
return text(10924).withDescription("Extension 1.5, optional. Canonical standard padded Base64 of 1 to 8192 bytes, byte-for-byte the scanner SDK getRawData result. The server does not decode it, strip prefixes or assume it equals the UTF-8 of barcode. It may include SDK code-ID framing, so it is exact SDK output, not symbol codewords or evidence of attested scanner configuration. Omit when unavailable. Present blank, non-canonical, malformed, oversize or wrong-typed values are rejected. Participates in exact event retry binding.");
}
/***************************************************************************
** Canonical installation and event identifiers.
***************************************************************************/
@@ -46,7 +46,7 @@ public final class MobileScannersUiApi
***************************************************************************/
public static MiddlewareSpecExtension extension()
{
return new MiddlewareSpecExtension().withName("mobile-scanners").withSemanticVersion("1.3.0")
return new MiddlewareSpecExtension().withName("mobile-scanners").withSemanticVersion("1.5.0")
.withDescription("Native scanner devices, customer branding and SCANNER process widget contract.")
.withSpec(MobileBrandingSpec.class).withSpec(MobileStatusSpec.class)
.withSpec(MobileEnrollSpec.class).withSpec(MobileCurrentDeviceSpec.class)
@@ -17,6 +17,7 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.regex.Pattern;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
@@ -43,6 +44,11 @@ public final class MobileService
{
static final ThreadLocal<QBackendTransaction> TRANSACTION = new ThreadLocal<>();
static final int AIM_IDENTIFIER_LENGTH = 3;
static final int RAW_DATA_MAXIMUM_BYTES = 8192;
static final int RAW_DATA_MAXIMUM_CHARACTERS = 10924;
private static final Pattern AIM_IDENTIFIER = Pattern.compile("^\\][A-Za-z][0-9A-Za-z]$");
/***************************************************************************
@@ -302,8 +308,11 @@ public final class MobileService
String id = hash(deviceId + ":" + eventId);
String scanSource = normalizedScanSource(optional(input, "scanSource", 200));
String symbology = normalizedSymbology(optional(input, "symbology", 200), scanSource);
String aimIdentifier = aimIdentifier(input);
String rawDataBase64 = rawDataBase64(input);
QRecord record = new QRecord().withValue("id", id).withValue("deviceId", deviceId).withValue("eventId", eventId)
.withValue("barcode", barcode).withValue("symbology", symbology).withValue("scanSource", scanSource)
.withValue("aimIdentifier", aimIdentifier).withValue("rawDataBase64", rawDataBase64)
.withValue("capturedAt", captured).withValue("receivedAt", Instant.now()).withValue("userId", userId())
.withValue("appId", appId).withValue("processName", processName);
for(String field : new String[] { "manufacturer", "model", "osVersion", "appVersion" })
@@ -344,8 +353,9 @@ public final class MobileService
{
throw new QException("This eventId was already used for a different scan.");
}
for(String field : new String[] { "barcode", "userId", "processName", "appId" })
for(String field : new String[] { "barcode", "userId", "processName", "appId", "aimIdentifier", "rawDataBase64" })
{
// Absent (NULL) and present metadata are different events, including historical rows.
if(!Objects.equals(existing.getValueString(field), requested.getValueString(field)))
{
throw new QException("This eventId was already used for a different scan.");
@@ -358,6 +368,51 @@ public final class MobileService
/***************************************************************************
** Optional original AIM identifier, for example ]C1; absent stays absent and
** is never guessed from the symbology.
***************************************************************************/
static String aimIdentifier(Map<String, ?> input) throws QException
{
String value = optional(input, "aimIdentifier", AIM_IDENTIFIER_LENGTH);
if(value != null && !AIM_IDENTIFIER.matcher(value).matches())
{
throw new QBadRequestException("aimIdentifier must be a three character AIM identifier such as ]C1.");
}
return value;
}
/***************************************************************************
** Optional exact SDK raw bytes as canonical padded Base64. Bytes are never
** decoded into text, trimmed or compared with the barcode string.
***************************************************************************/
static String rawDataBase64(Map<String, ?> input) throws QException
{
String value = optional(input, "rawDataBase64", RAW_DATA_MAXIMUM_CHARACTERS);
if(value == null)
{
return null;
}
byte[] decoded;
try
{
decoded = Base64.getDecoder().decode(value);
}
catch(IllegalArgumentException e)
{
throw new QBadRequestException("rawDataBase64 must be canonical padded Base64.");
}
if(decoded.length < 1 || decoded.length > RAW_DATA_MAXIMUM_BYTES || !Base64.getEncoder().encodeToString(decoded).equals(value))
{
throw new QBadRequestException("rawDataBase64 must be canonical padded Base64 of 1 to " + RAW_DATA_MAXIMUM_BYTES + " bytes.");
}
return value;
}
/***************************************************************************
** Legacy clients may omit source; absence must not invent a capture method.
***************************************************************************/
@@ -6,6 +6,17 @@
package com.kingsrook.qbits.mobilescanners;
import java.io.Serializable;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.time.temporal.Temporal;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import com.kingsrook.qqq.backend.core.model.session.QCredentialScope;
import com.kingsrook.qqq.backend.core.model.session.QSession;
@@ -15,7 +26,29 @@ import com.kingsrook.qqq.backend.core.model.session.QSession;
*******************************************************************************/
final class VerifiedDeviceSession extends QSession
{
private final String deviceId;
private final String deviceId;
private final Authority checked;
/***************************************************************************
** Immutable semantic copy of every session field that can grant or narrow
** access. QCredentialScope has no value equality, so its fields are copied.
***************************************************************************/
private record Authority(String userId, String uuid, String channel, Set<String> permissions, Map<String, Set<Serializable>> securityKeys,
Map<String, Serializable> variants, Scope scope, String unverifiable) implements Serializable
{
}
/***************************************************************************
** Credential ceilings and auth selectors (credential id, readOnly, channel).
***************************************************************************/
private record Scope(String credentialId, boolean readOnly, Set<String> apps, Set<String> tables, Set<String> processes,
Set<String> reports, Set<String> widgets, Set<String> operationIds) implements Serializable
{
}
@@ -36,6 +69,7 @@ final class VerifiedDeviceSession extends QSession
setValuesForFrontend(copy.getValuesForFrontend());
setClientChannel(copy.getClientChannel());
setCredentialScope(copy.getCredentialScope());
this.checked = authority(this);
}
@@ -47,4 +81,88 @@ final class VerifiedDeviceSession extends QSession
{
return deviceId;
}
/***************************************************************************
** The authority verified when this wrapper was created.
***************************************************************************/
Serializable checkedAuthority()
{
return checked;
}
/***************************************************************************
** Current semantic authority of any session, for comparison with a prior one.
***************************************************************************/
static Serializable authorityOf(QSession session)
{
return authority(session);
}
/***************************************************************************
** Copies the collections; QSession.clone and QCredentialScope.clone are the
** native isolation and copy elements shallowly. Element values that are not
** known immutable types could change snapshot and session together, so such
** a snapshot is made unequal to every other and the comparison fails closed.
***************************************************************************/
private static Authority authority(QSession session)
{
String unverifiable = null;
Map<String, Set<Serializable>> keys = null;
if(session.getSecurityKeyValues() != null)
{
keys = new HashMap<>();
for(Map.Entry<String, List<Serializable>> entry : session.getSecurityKeyValues().entrySet())
{
//////////////////////////////////////////////////////////////////////////////////
// core row security tests List.contains: membership is authority, so order and //
// repeated identical appends are not changes. A null list stays distinct. //
//////////////////////////////////////////////////////////////////////////////////
Set<Serializable> values = entry.getValue() == null ? null : new HashSet<>(entry.getValue());
if(values != null && !values.stream().allMatch(VerifiedDeviceSession::isImmutable))
{
unverifiable = UUID.randomUUID().toString();
}
keys.put(entry.getKey(), values);
}
}
Map<String, Serializable> variants = session.getBackendVariants() == null ? null : new HashMap<>(session.getBackendVariants());
if(variants != null && !variants.values().stream().allMatch(VerifiedDeviceSession::isImmutable))
{
unverifiable = UUID.randomUUID().toString();
}
QCredentialScope scope = session.getCredentialScope();
return new Authority(session.getUser() == null ? null : session.getUser().getIdReference(), session.getUuid(), session.getClientChannel(),
copy(session.getPermissions()), keys, variants,
scope == null ? null : new Scope(scope.getCredentialId(), scope.getReadOnly(), copy(scope.getApps()), copy(scope.getTables()),
copy(scope.getProcesses()), copy(scope.getReports()), copy(scope.getWidgets()), copy(scope.getOperationIds())), unverifiable);
}
/***************************************************************************
** Only value types that cannot be mutated in place.
***************************************************************************/
private static boolean isImmutable(Serializable value)
{
return value == null || value instanceof String || value instanceof Integer || value instanceof Long || value instanceof Short
|| value instanceof Byte || value instanceof Double || value instanceof Float || value instanceof BigInteger
|| value instanceof BigDecimal || value instanceof Boolean || value instanceof Character || value instanceof Enum<?>
|| value instanceof UUID || value instanceof Temporal;
}
/***************************************************************************
** Null means unrestricted, which must stay distinct from an empty set.
***************************************************************************/
private static Set<String> copy(Set<String> set)
{
return set == null ? null : new HashSet<>(set);
}
}
@@ -74,3 +74,6 @@ ALTER TABLE mobileDevice ALTER COLUMN locationCapturedAt TIMESTAMP;
ALTER TABLE mobileDevice ALTER COLUMN locationReceivedAt TIMESTAMP;
ALTER TABLE mobileScan ALTER COLUMN capturedAt TIMESTAMP;
ALTER TABLE mobileScan ALTER COLUMN receivedAt TIMESTAMP;
-- Extension 1.5 optional exact capture metadata. NULL means absent for historical rows, older clients and manual entry.
ALTER TABLE mobileScan ADD COLUMN IF NOT EXISTS aimIdentifier VARCHAR(3);
ALTER TABLE mobileScan ADD COLUMN IF NOT EXISTS rawDataBase64 VARCHAR(10924);
@@ -327,8 +327,18 @@ components:
\ identifiers are preserved; absent legacy type is UNKNOWN, or MANUAL for\
\ explicit manual source. Identity is server-derived. appId identifies the\
\ server-configured application; processName is the registered executing process.\
\ Raw event inputs are consumed so the next event cannot inherit them."
\ Extension 1.5 adds optional aimIdentifier and rawDataBase64, present only\
\ when the accepted scan has them and otherwise omitted. Raw event inputs\
\ are consumed so the next event cannot inherit them."
properties:
aimIdentifier:
description: "Extension 1.5, optional. The original AIM identifier reported\
\ by the scanner SDK, exactly three characters matching ^\\][A-Za-z][0-9A-Za-z]$.\
\ Never guessed from symbology. Omit when unavailable. Present blank,\
\ malformed or wrong-typed values are rejected. Participates in exact\
\ event retry binding."
maxLength: 3
type: "string"
appId:
maxLength: 200
type: "string"
@@ -350,6 +360,17 @@ components:
processName:
maxLength: 200
type: "string"
rawDataBase64:
description: "Extension 1.5, optional. Canonical standard padded Base64\
\ of 1 to 8192 bytes, byte-for-byte the scanner SDK getRawData result.\
\ The server does not decode it, strip prefixes or assume it equals the\
\ UTF-8 of barcode. It may include SDK code-ID framing, so it is exact\
\ SDK output, not symbol codewords or evidence of attested scanner configuration.\
\ Omit when unavailable. Present blank, non-canonical, malformed, oversize\
\ or wrong-typed values are rejected. Participates in exact event retry\
\ binding."
maxLength: 10924
type: "string"
receivedAt:
format: "date-time"
maxLength: 40
@@ -385,6 +406,14 @@ components:
\ comes only from verified user/device context, never these values. Barcode\
\ is a nonempty string, preserving leading zeroes."
properties:
aimIdentifier:
description: "Extension 1.5, optional. The original AIM identifier reported\
\ by the scanner SDK, exactly three characters matching ^\\][A-Za-z][0-9A-Za-z]$.\
\ Never guessed from symbology. Omit when unavailable. Present blank,\
\ malformed or wrong-typed values are rejected. Participates in exact\
\ event retry binding."
maxLength: 3
type: "string"
barcode:
maxLength: 4096
type: "string"
@@ -396,6 +425,17 @@ components:
format: "uuid"
maxLength: 36
type: "string"
rawDataBase64:
description: "Extension 1.5, optional. Canonical standard padded Base64\
\ of 1 to 8192 bytes, byte-for-byte the scanner SDK getRawData result.\
\ The server does not decode it, strip prefixes or assume it equals the\
\ UTF-8 of barcode. It may include SDK code-ID framing, so it is exact\
\ SDK output, not symbol codewords or evidence of attested scanner configuration.\
\ Omit when unavailable. Present blank, non-canonical, malformed, oversize\
\ or wrong-typed values are rejected. Participates in exact event retry\
\ binding."
maxLength: 10924
type: "string"
scanSource:
description: "Capture method, for example datawedge, camera or manual. Absent\
\ or blank becomes UNKNOWN."
@@ -435,11 +475,23 @@ components:
MobileScannersScannerValuesV1:
description: "SCANNER presentation values. Optional manualLabel defaults to\
\ Manual barcode; submitLabel defaults to Submit barcode; maxLength defaults\
\ to 4096. Unknown additive properties may be ignored."
\ to 4096. Extension 1.4 adds acknowledgmentLabel (default Saved scan) and\
\ manualEntryEnabled (default true). Presentation never changes acknowledgment\
\ requirements. Unknown additive properties may be ignored."
properties:
acknowledgmentLabel:
description: "Trimmed nonempty single-line plain text, without controls,\
\ formatting characters or markup. For example Print queued; this does\
\ not mean physical output completed."
maxLength: 80
type: "string"
instructions:
maxLength: 2000
type: "string"
manualEntryEnabled:
description: "Whether to show manual entry controls. False does not disable\
\ hardware scanning or relax backend validation."
type: "boolean"
manualLabel:
maxLength: 200
type: "string"
@@ -634,7 +686,7 @@ info:
description: "Native scanner devices, customer branding and SCANNER process widget\
\ contract."
title: "QQQ Middleware API extension: mobile-scanners"
version: "1.3.0"
version: "1.5.0"
openapi: "3.0.3"
paths:
/qqq/v1/qbit/mobile-scanners/branding:
@@ -15,6 +15,7 @@ import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
import com.kingsrook.qqq.middleware.javalin.tools.UiApiContract;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -63,10 +64,26 @@ class MobileScannersApiContractTest
assertTrue(model.getComponents().getSchemas().containsKey(schema), schema);
}
assertEquals(7, extension.getSpecs().size());
assertEquals("1.3.0", extension.getSemanticVersion());
assertEquals("1.5.0", extension.getSemanticVersion());
assertEquals(java.util.List.of(), UiApiContract.check(generated, Files.readString(contract), Files.readString(Path.of("src/test/resources/openapi/mobile-scanners-1.4.0.yaml")), 1,
"Regenerate the current extension contract."));
var scanEvent = model.getComponents().getSchemas().get("MobileScannersScanEventV1");
var scanContext = model.getComponents().getSchemas().get("MobileScannersScanContextV1");
for(String optional : java.util.List.of("aimIdentifier", "rawDataBase64"))
{
assertTrue(scanEvent.getProperties().containsKey(optional), optional);
assertTrue(scanContext.getProperties().containsKey(optional), optional);
assertFalse(generated.contains("- \"" + optional + "\""), optional + " must stay optional, never a required key.");
}
assertEquals(3, scanEvent.getProperties().get("aimIdentifier").getMaxLength());
assertEquals(10924, scanEvent.getProperties().get("rawDataBase64").getMaxLength());
assertTrue(generated.contains("rawDataBase64"));
assertTrue(generated.contains("aimIdentifier"));
assertTrue(generated.contains("mobileScannersEnrollDevice"));
assertTrue(generated.contains("X-QQQ-Device-Token"));
assertTrue(generated.contains("acknowledgedEventId"));
assertTrue(generated.contains("acknowledgmentLabel"));
assertTrue(generated.contains("manualEntryEnabled"));
}
@@ -10,8 +10,13 @@ import java.io.Serializable;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.DriverManager;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
@@ -38,6 +43,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.AuthScope;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.session.QCredentialScope;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
@@ -50,6 +56,9 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -111,6 +120,7 @@ class MobileScannersTest
void clear()
{
MobilePermissions.REQUEST_DEVICE.remove();
SessionMutator.hook = null;
QContext.clear();
}
@@ -144,6 +154,256 @@ class MobileScannersTest
/***************************************************************************
** Unchanged authority keeps the exact wrapper so scoped session state is stable.
***************************************************************************/
@Test
void repeatedCaptureRetainsSameVerifiedSession() throws Exception
{
bind(approvedDevice());
QSession bound = QContext.getQSession();
var first = MobileDeviceAuthorization.captureCurrent();
assertSame(bound, QContext.getQSession());
var second = MobileDeviceAuthorization.captureCurrent();
assertSame(bound, QContext.getQSession());
assertTrue(first.isPresent());
assertEquals(first, second);
}
/***************************************************************************
** A retained wrapper never grants after approval, permission, binding or device changes.
***************************************************************************/
@Test
void retainedSessionRejectsChangedAuthority() throws Exception
{
String id = approvedDevice();
bind(id);
QSession bound = QContext.getQSession();
var identity = MobileDeviceAuthorization.captureCurrent().orElseThrow();
MobilePermissions.REQUEST_DEVICE.set(UUID.randomUUID().toString());
assertThrows(QException.class, MobileDeviceAuthorization::captureCurrent);
MobilePermissions.REQUEST_DEVICE.remove();
assertSame(bound, QContext.getQSession());
bound.setUser(new QUser().withIdReference("someoneElse"));
assertThrows(QException.class, () -> MobileDeviceAuthorization.revalidate(identity));
bound.setUser(new QUser().withIdReference("scanner"));
MobileDeviceAuthorization.revalidate(identity);
String uuid = bound.getUuid();
bound.setUuid(UUID.randomUUID().toString());
assertThrows(QException.class, MobileDeviceAuthorization::captureCurrent);
bound.setUuid(uuid);
var permissions = bound.getPermissions();
bound.setPermissions(new HashSet<>());
assertThrows(QException.class, MobileDeviceAuthorization::captureCurrent);
bound.setPermissions(permissions);
MobileDeviceAuthorization.captureCurrent();
assertSame(bound, QContext.getQSession());
QContext.init(instance, admin);
MobileService.setApproval(id, false);
QContext.init(instance, bound);
assertThrows(QException.class, MobileDeviceAuthorization::captureCurrent);
assertThrows(QException.class, () -> MobileDeviceAuthorization.revalidate(identity));
}
/***************************************************************************
** Credential ceilings, auth selectors, channel and keys cannot change under a retained wrapper.
***************************************************************************/
@Test
void retainedSessionRejectsCredentialScopeAndSelectorChanges() throws Exception
{
String id = approvedDevice();
Set<String> apps = new HashSet<>(instance.getApps().keySet());
Set<String> tables = new HashSet<>(instance.getTables().keySet());
scanner.setClientChannel("token:cred-1");
scanner.setCredentialScope(new QCredentialScope().withCredentialId("cred-1").withApps(apps).withTables(tables));
QContext.init(instance, scanner);
bind(id);
QSession bound = QContext.getQSession();
assertTrue(MobileDeviceAuthorization.captureCurrent().isPresent());
assertSame(bound, QContext.getQSession());
QCredentialScope scope = bound.getCredentialScope();
assertNotSame(scanner.getCredentialScope(), scope);
Set<String> widerTables = new HashSet<>(tables);
widerTables.add("extraTable");
assertUnchangedAfterRestore(bound, () -> scope.withTables(widerTables), () -> scope.withTables(tables));
assertUnchangedAfterRestore(bound, () -> scope.withTables(null), () -> scope.withTables(tables));
assertUnchangedAfterRestore(bound, () -> scope.withApps(null), () -> scope.withApps(apps));
assertUnchangedAfterRestore(bound, () -> scope.withProcesses(Set.of()), () -> scope.withProcesses(null));
assertUnchangedAfterRestore(bound, () -> scope.withReports(Set.of("r")), () -> scope.withReports(null));
assertUnchangedAfterRestore(bound, () -> scope.withWidgets(Set.of("w")), () -> scope.withWidgets(null));
assertUnchangedAfterRestore(bound, () -> scope.withOperationIds(Set.of("GET /x")), () -> scope.withOperationIds(null));
assertUnchangedAfterRestore(bound, () -> scope.withReadOnly(true), () -> scope.withReadOnly(false));
assertUnchangedAfterRestore(bound, () -> scope.withCredentialId("cred-2"), () -> scope.withCredentialId("cred-1"));
assertUnchangedAfterRestore(bound, () -> bound.setCredentialScope(null), () -> bound.setCredentialScope(scope));
assertUnchangedAfterRestore(bound, () -> bound.setClientChannel("web"), () -> bound.setClientChannel("token:cred-1"));
assertUnchangedAfterRestore(bound, () -> bound.withSecurityKeyValue("tenant", "t1"), () -> bound.clearSecurityKeyValues());
assertUnchangedAfterRestore(bound, () -> bound.withBackendVariants(Map.of("db", "other")), () -> bound.withBackendVariants(null));
assertUnchangedAfterRestore(bound, () -> bound.withPermission(MobilePermissions.ADMIN), () -> bound.getPermissions().remove(MobilePermissions.ADMIN));
assertSame(bound, QContext.getQSession());
}
/***************************************************************************
** Test-only post-query hook on the device read that approval/observation perform.
***************************************************************************/
public static class SessionMutator extends MobileDeviceOnline
{
static Runnable hook;
/***********************************************************************
** Run the pending change once, in the middle of the authorization checks.
***********************************************************************/
@Override
public java.util.List<QRecord> apply(java.util.List<QRecord> records)
{
Runnable pending = hook;
hook = null;
if(pending != null)
{
pending.run();
}
return super.apply(records);
}
}
/***************************************************************************
** Grants or ceilings changed by host hooks during the checks are never retained.
***************************************************************************/
@Test
void authorityChangedDuringChecksIsNotRetained() throws Exception
{
String id = approvedDevice();
Set<String> tables = new HashSet<>(instance.getTables().keySet());
scanner.setClientChannel("token:cred-1");
scanner.setCredentialScope(new QCredentialScope().withCredentialId("cred-1").withTables(tables));
QContext.init(instance, scanner);
bind(id);
QSession bound = QContext.getQSession();
// swap the production MobileDeviceOnline for a subclass that runs it unchanged after the observed mutation
var role = com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers.POST_QUERY_RECORD.getRole();
var deviceCustomizers = instance.getTable(MobileScannersQBitProducer.DEVICE).getCustomizers();
assertEquals(MobileDeviceOnline.class.getName(), deviceCustomizers.get(role).getName());
deviceCustomizers.put(role, new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(SessionMutator.class));
// scoped first capture wraps a copy with exactly the same ceilings and grants
QContext.init(instance, scanner);
MobileDeviceAuthorization.captureCurrent();
QSession wrapped = QContext.getQSession();
assertInstanceOf(VerifiedDeviceSession.class, wrapped);
assertNotSame(scanner.getCredentialScope(), wrapped.getCredentialScope());
assertEquals(tables, wrapped.getCredentialScope().getTables());
assertEquals(scanner.getPermissions(), wrapped.getPermissions());
assertEquals("token:cred-1", wrapped.getClientChannel());
// retained wrapper: ceiling widened mid-check
QContext.init(instance, bound);
SessionMutator.hook = () -> bound.getCredentialScope().withTables(null);
assertThrows(QException.class, MobileDeviceAuthorization::captureCurrent);
assertNull(SessionMutator.hook);
bound.getCredentialScope().withTables(tables);
MobileDeviceAuthorization.captureCurrent();
// retained wrapper: grant added mid-check
SessionMutator.hook = () -> bound.withPermission(MobilePermissions.ADMIN);
assertThrows(QException.class, MobileDeviceAuthorization::captureCurrent);
assertNull(SessionMutator.hook);
bound.getPermissions().remove(MobilePermissions.ADMIN);
// retained wrapper: context session replaced mid-check
QSession other = new QSession().withUser(bound.getUser());
QContext.init(instance, bound);
SessionMutator.hook = () -> QContext.setQSession(other);
assertThrows(QException.class, MobileDeviceAuthorization::captureCurrent);
assertNull(SessionMutator.hook);
// first capture: ceiling widened mid-check is not wrapped
QContext.init(instance, scanner);
SessionMutator.hook = () -> scanner.getCredentialScope().withTables(null);
assertThrows(QException.class, MobileDeviceAuthorization::captureCurrent);
assertNull(SessionMutator.hook);
assertSame(scanner, QContext.getQSession());
}
/***************************************************************************
** Security keys compare as sets: repeats and order are not authority changes, new values are.
***************************************************************************/
@Test
void securityKeysCompareAsSets() throws Exception
{
String id = approvedDevice();
scanner.withSecurityKeyValue("tenant", "t1").withSecurityKeyValue("tenant", "t2");
bind(id);
QSession bound = QContext.getQSession();
List<java.io.Serializable> original = new ArrayList<>(bound.getSecurityKeyValues().get("tenant"));
bound.withSecurityKeyValue("tenant", "t1");
bound.withSecurityKeyValue("tenant", "t1");
assertTrue(MobileDeviceAuthorization.captureCurrent().isPresent());
bound.getSecurityKeyValues().put("tenant", new ArrayList<>(List.of("t2", "t1")));
assertTrue(MobileDeviceAuthorization.captureCurrent().isPresent());
assertSame(bound, QContext.getQSession());
bound.withSecurityKeyValue("tenant", "t3");
assertThrows(QException.class, MobileDeviceAuthorization::captureCurrent);
bound.getSecurityKeyValues().put("tenant", new ArrayList<>(List.of("t1", "t3")));
assertThrows(QException.class, MobileDeviceAuthorization::captureCurrent);
bound.getSecurityKeyValues().put("tenant", new ArrayList<>(original));
bound.withSecurityKeyValue("other", "x");
assertThrows(QException.class, MobileDeviceAuthorization::captureCurrent);
bound.getSecurityKeyValues().remove("other");
assertTrue(MobileDeviceAuthorization.captureCurrent().isPresent());
bound.setUser(new QUser().withIdReference("someoneElse"));
assertThrows(QException.class, MobileDeviceAuthorization::captureCurrent);
}
/***************************************************************************
** Mutable Serializable key/variant values cannot be proven unchanged, so they fail closed.
***************************************************************************/
@Test
void mutableSessionValuesFailClosed() throws Exception
{
String id = approvedDevice();
scanner.withSecurityKeyValue("tenant", new java.util.ArrayList<>(java.util.List.of("a")));
bind(id);
assertThrows(QException.class, MobileDeviceAuthorization::captureCurrent);
}
/***************************************************************************
** Apply a change (must fail closed, wrapper kept), then restore (must pass again).
***************************************************************************/
private void assertUnchangedAfterRestore(QSession bound, Runnable change, Runnable restore) throws Exception
{
change.run();
assertThrows(QException.class, MobileDeviceAuthorization::captureCurrent);
assertSame(bound, QContext.getQSession());
restore.run();
assertTrue(MobileDeviceAuthorization.captureCurrent().isPresent());
assertSame(bound, QContext.getQSession());
}
/***************************************************************************
** Identical transport retry inserts once; physical rescans get distinct IDs.
***************************************************************************/
@@ -269,6 +529,151 @@ class MobileScannersTest
/***************************************************************************
** Exact SDK bytes, including NUL and high bytes, persist and return unchanged.
***************************************************************************/
@Test
void rawBytesAndAimIdentifierRoundTripExactlyAndBindRetries() throws Exception
{
bind(approvedDevice());
byte[] raw = new byte[] { 0, 'A', 0, (byte) 0xFF, (byte) 0x80, 0x1D, '0', '0' };
String encoded = Base64.getEncoder().encodeToString(raw);
Map<String, Serializable> input = scan();
input.put("barcode", "00001234");
input.put("aimIdentifier", "]C0");
input.put("rawDataBase64", encoded);
QRecord saved = MobileService.capture(input);
assertEquals("00001234", saved.getValueString("barcode"));
assertEquals("]C0", saved.getValueString("aimIdentifier"));
QRecord stored = MobileService.get(MobileScannersQBitProducer.SCAN, saved.getValueString("id"));
assertEquals(encoded, stored.getValueString("rawDataBase64"));
assertTrue(java.util.Arrays.equals(raw, Base64.getDecoder().decode(stored.getValueString("rawDataBase64"))));
assertEquals(saved.getValueString("id"), MobileService.capture(input).getValueString("id"), "Exact retry returns the same row.");
Map<String, Serializable> changedBytes = new HashMap<>(input);
changedBytes.put("rawDataBase64", Base64.getEncoder().encodeToString(new byte[] { 0, 'A', 0, (byte) 0xFE }));
assertThrows(QException.class, () -> MobileService.capture(changedBytes));
Map<String, Serializable> changedAim = new HashMap<>(input);
changedAim.put("aimIdentifier", "]Q1");
assertThrows(QException.class, () -> MobileService.capture(changedAim));
Map<String, Serializable> absentAim = new HashMap<>(input);
absentAim.remove("aimIdentifier");
assertThrows(QException.class, () -> MobileService.capture(absentAim), "Absent versus present metadata must conflict.");
Map<String, Serializable> absentRaw = new HashMap<>(input);
absentRaw.remove("rawDataBase64");
assertThrows(QException.class, () -> MobileService.capture(absentRaw), "Absent versus present metadata must conflict.");
assertEquals(1, scanRows());
Map<String, Serializable> legacy = scan();
QRecord legacyRow = MobileService.capture(legacy);
assertNull(legacyRow.getValueString("aimIdentifier"));
assertNull(legacyRow.getValueString("rawDataBase64"));
assertEquals(legacyRow.getValueString("id"), MobileService.capture(legacy).getValueString("id"));
Map<String, Serializable> addedLater = new HashMap<>(legacy);
addedLater.put("rawDataBase64", encoded);
assertThrows(QException.class, () -> MobileService.capture(addedLater), "A historical NULL row cannot be retried with new metadata.");
assertEquals(2, scanRows());
}
/***************************************************************************
** Present optional metadata must be well typed, canonical and bounded.
***************************************************************************/
@Test
void malformedAimAndRawMetadataAreRejectedWithoutSaving() throws Exception
{
bind(approvedDevice());
Base64.Encoder encoder = Base64.getEncoder();
List<Map<String, Serializable>> invalid = new ArrayList<>();
for(String aim : new String[] { "", " ", "]C", "]C10", "C01", "]01", "]c!", "]C-", "]C0\n" })
{
invalid.add(Map.of("aimIdentifier", aim));
}
invalid.add(Map.of("aimIdentifier", 7));
invalid.add(Map.of("aimIdentifier", true));
for(String raw : new String[] { "", " ", "AEEA/4AdMDA", "AEEA/4AdMDA=\n", "AEEA_4AdMDA=", "AEEA/4AdMDA==", "AEF=", "!!!!", "QQ=", encoder.encodeToString(new byte[8193]) })
{
invalid.add(Map.of("rawDataBase64", raw));
}
invalid.add(Map.of("rawDataBase64", 12));
invalid.add(Map.of("rawDataBase64", new byte[] { 1 }));
invalid.add(Map.of("rawDataBase64", "A".repeat(MobileService.RAW_DATA_MAXIMUM_CHARACTERS + 1)));
for(Map<String, Serializable> metadata : invalid)
{
Map<String, Serializable> input = scan();
input.putAll(metadata);
assertThrows(QException.class, () -> MobileService.capture(input), metadata.toString());
}
assertEquals(0, scanRows());
Map<String, Serializable> boundary = scan();
boundary.put("rawDataBase64", encoder.encodeToString(new byte[MobileService.RAW_DATA_MAXIMUM_BYTES]));
assertEquals(encoder.encodeToString(new byte[MobileService.RAW_DATA_MAXIMUM_BYTES]), MobileService.capture(boundary).getValueString("rawDataBase64"));
Map<String, Serializable> one = scan();
one.put("rawDataBase64", encoder.encodeToString(new byte[] { 0 }));
assertEquals("AA==", MobileService.capture(one).getValueString("rawDataBase64"));
}
/***************************************************************************
** Context omits missing keys, replaces forged values and never carries
** the previous event's metadata into the next event.
***************************************************************************/
@Test
void processContextCarriesOptionalMetadataOnlyForTheAcceptedEvent() throws Exception
{
bind(approvedDevice());
String encoded = Base64.getEncoder().encodeToString(new byte[] { 0, (byte) 0xC3, 0x28 });
RunBackendStepInput input = new RunBackendStepInput().withProcessName(MobileScannersQBitProducer.CAPTURE).withValues(scan());
input.addValue("aimIdentifier", "]E0");
input.addValue("rawDataBase64", encoded);
Map<String, Serializable> first = MobileScannerActions.capture(input);
assertEquals("]E0", first.get("aimIdentifier"));
assertEquals(encoded, first.get("rawDataBase64"));
assertFalse(input.getValues().containsKey("aimIdentifier"));
assertFalse(input.getValues().containsKey("rawDataBase64"));
input.addValue("scanContext", new HashMap<>(Map.of("aimIdentifier", "]Q1", "rawDataBase64", "Zm9yZ2Vk")));
input.addValue("scanSaved", true);
input.addValue("acknowledgedEventId", first.get("eventId"));
input.addValue("barcode", "00001234");
input.addValue("eventId", UUID.randomUUID().toString());
input.addValue("capturedAt", "2026-11-01T12:00:00Z");
Map<String, Serializable> legacy = MobileScannerActions.capture(input);
assertFalse(legacy.containsKey("aimIdentifier"), "Missing optional keys are omitted, not null or inherited.");
assertFalse(legacy.containsKey("rawDataBase64"), "Missing optional keys are omitted, not null or inherited.");
assertEquals("scanner", legacy.get("userId"));
input.addValue("scanContext", new HashMap<>(legacy));
input.addValue("scanSaved", true);
input.addValue("acknowledgedEventId", legacy.get("eventId"));
input.addValue("barcode", "00001234");
input.addValue("eventId", UUID.randomUUID().toString());
input.addValue("capturedAt", "2026-11-01T12:00:00Z");
input.addValue("rawDataBase64", "not base64!");
assertThrows(QException.class, () -> MobileScannerActions.capture(input));
assertFalse(input.getValues().containsKey("rawDataBase64"), "A rejected event must not contaminate the following submission.");
assertFalse(input.getValues().containsKey("aimIdentifier"));
}
/***************************************************************************
** Count persisted scans through JDBC, outside the guarded QQQ actions.
***************************************************************************/
private int scanRows() throws Exception
{
try(Connection connection = DriverManager.getConnection(jdbcUrl, "sa", "");
var statement = connection.createStatement(); var rows = statement.executeQuery("SELECT COUNT(*) FROM mobileScan"))
{
assertTrue(rows.next());
return rows.getInt(1);
}
}
/***************************************************************************
** Pre-context saved state must be reopened rather than reuse stale input.
***************************************************************************/
File diff suppressed because it is too large. Load diff